Jelajahi Sumber

feat: Complete React UI rewrite with new frontend architecture (#112)

* Add homing blocking - prevent movement during homing

Backend changes:
- Add is_homing flag to AppState
- Set flag during homing operation with proper cleanup on success/failure
- Add check_homing_in_progress() helper function
- Block these endpoints while homing: run_theta_rho, run_theta_rho_file,
  run_playlist, move_to_center, move_to_perimeter, send_coordinate
- Add is_homing to status response for frontend awareness
- Return HTTP 409 Conflict if action attempted during homing

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

* fix led

* Add root package.json and package-lock.json

- Update .gitignore to only ignore state.json instead of all *.json
- Add package.json with npm scripts for development
- Add package-lock.json for reproducible installs

npm scripts available:
- npm run dev: Start frontend and backend concurrently
- npm run build: Build React frontend
- npm run start: Build and start server

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

* Ignore additional JSON config files

- playlists.json (runtime data)
- metadata_cache.json (generated cache)
- tsconfig.json (TypeScript config - use frontend's)
- dune-weaver-touch/*.json (touch app configs)

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

* Enable network access for Vite dev server

Add --host flag to dev and preview scripts so the frontend
is accessible from other machines on the network (useful for
remote development on Pi)

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

* Allow all hosts for Vite dev server

Enables access via hostnames like dwg.local on local network

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

* Mount metadata_cache.json for persistence

Without this mount, the metadata cache was regenerated on every
container restart because the file was lost inside the container.

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

* Revert to mounting entire app directory

Mounting individual files caused issues with metadata_cache.json
not being created properly. The .:/app mount is simpler and ensures
all runtime files (state.json, metadata_cache.json, playlists/, etc.)
are persisted automatically.

Also fixed dbus socket path typo.

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

* Add settings enhancements and Docker update functionality

- Add preferred port selection with auto-connect disable option
- Display gear ratio and X/Y steps per mm in machine settings
- Add MQTT test connection button to Home Assistant section
- Change "WLED" to "LED" in Still Sands section
- Implement Docker-based software update for two-container setup
- Update button now always enabled for testing
- Fix sensor offset input to allow clearing value
- Add searchable select component

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

* Improve settings, homing auto-detection, and update flow

Settings & UI:
- Move timezone from Still Sands to Machine Settings (system-wide)
- Add UTC offset timezones (UTC-12 to UTC+12) to selector
- Remove update button, show CLI instructions instead
- Remove "Paused" badge from Now Playing bar
- Logs now display timestamps in configured timezone

Homing:
- Auto-detect sensor homing from FluidNC $22 setting
- Add homing_user_override flag to respect explicit user preference
- Only auto-set sensor mode if user hasn't configured otherwise

Update system:
- Add git pull to update_manager for local file updates
- Remove non-existent frontend image pull
- Handle container self-restart gracefully
- Direct users to use 'dw update' from host for full updates

Bug fixes:
- Validate auto-play playlist exists before running
- Clear invalid playlist references from state automatically

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

* Consolidate cache-all-previews logic and fix Docker restart

- Extract shared cacheAllPreviews() function to previewCache.ts
- Simplify BrowsePage and Layout modal to use shared function
- Fix PlaylistsPage to validate previews before caching
- Fix Docker restart using correct container name (dune-weaver-backend)

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

* Add timeout protection to cache generation

Prevents backend from hanging when parsing problematic pattern files.
Files that take longer than 30 seconds to parse are skipped with an error log.

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

* Fix log handler performance and restore Still Sands timezone

- Remove expensive timezone conversion from log handler that created
  ZoneInfo objects on every log message (major Pi performance issue)
- Logs now use simple local system time
- Move timezone setting back to Still Sands where it's actually needed
- Remove unused timezone from Machine Settings

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

* Switch Docker images to feature-react-ui branch

TODO: Revert to main before merging to main branch.

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

* Fix preview batch requests overwhelming backend on Pi

Both BrowsePage and PlaylistsPage were sending all uncached pattern
previews in a single request, causing:
- 100+ second processing times for 879 files
- 504 gateway timeouts on other API calls
- 8.9MB responses causing nginx buffering warnings

Now batches requests into groups of 10 with 100ms delays between
batches, matching the cacheAllPreviews behavior.

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

* Improve Now Playing bar UX and timezone input

Now Playing bar:
- Fix race condition where auto-collapse undid expansion on playlist start
- Listen to playback-started event for more reliable expansion
- Add 500ms delay before auto-collapse to prevent race conditions
- Click preview image in mini view to expand
- Click canvas in expanded view to collapse back

Settings (Still Sands timezone):
- Replace Select with Input + datalist for custom timezone support
- Add UTC offset options (UTC-12 to UTC+12)
- Users can now type any IANA timezone (e.g., Asia/Kolkata)

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

* Improve Settings page UX and fix Auto Play issues

Auto Play on Boot:
- Fix playlist fetch (backend returns array directly, not object)
- Add pause time unit selector (sec/min/hr) matching Playlists page
- Smart conversion: 300s displays as "5 min", saves back as seconds

Still Sands timezone:
- Replace Select with Input + datalist for custom timezone support
- Add UTC offset options (UTC-12 to UTC+12)
- Users can type any IANA timezone

UI polish:
- Increase spacing between labels and inputs (space-y-2 → space-y-3)

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

* Add serial debug terminal, background homing, and UI improvements

- Fix Python 3.9 compatibility for type hints in scheduling.py and process_pool.py
- Change startup order: backend starts immediately, homing runs in background
- Add homing progress overlay with streaming logs, 5s countdown after completion
- Prevent movement commands (home, center, perimeter, align) while pattern running
- Add serial debug terminal tab for raw command communication without full connection
- Make logs drawer resizable with drag handle

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

* Add multi-table control support with mDNS discovery

Backend:
- Add CORS middleware to enable cross-origin requests from other frontends
- Add table identity endpoints (GET/PATCH /api/table-info) with UUID and name
- Add mDNS advertisement using zeroconf (_duneweaver._tcp.local.)
- Add discovery endpoint (GET /api/discover-tables) to find tables on LAN

Frontend:
- Add centralized API client (apiClient.ts) with configurable base URL
- Add TableContext for multi-table state management with localStorage persistence
- Add TableSelector component in header for switching between tables
- Update WebSocket connections to use apiClient for dynamic URL targeting
- Refactor key fetch calls to use apiClient

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

* Move serial terminal from logs drawer to Table Control page

- Relocate serial terminal UI and logic from Layout to TableControlPage
  for better contextual placement with other table controls
- Add auto-connect feature that detects main connection port
- Update LED pixel order default from GRB to RGB (WS2815/WS2811)
- Use apiClient for consistent WebSocket URL construction

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

* Fix nginx upstream resolution for host network mode

Use 127.0.0.1:8080 instead of backend:8080 since frontend runs
with network_mode: host, which disables Docker DNS resolution.

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

* Fix version_manager method call in get_table_info endpoint

Use await get_current_version() instead of non-existent get_version().

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

* Fix remaining get_version() calls to use get_current_version()

- modules/core/mdns.py: Fix mDNS advertisement (2 occurrences)
- main.py: Fix discover_tables endpoint

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

* Fix DW LEDs race condition and mDNS async warning

- Fix DW LEDs not working after background homing: connect_device() was
  overwriting led_controller with None because it didn't handle "dw_leds"
  provider. Now preserves existing controller or initializes if needed.
- Use AsyncZeroconf in discover_tables() to fix blocking I/O warning
- Add frontend dist volume mount for dev without rebuild

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

* Fix DW LEDs not initializing on application restart

The DW LED controller uses lazy hardware initialization, but startup code
only created the LEDInterface without triggering initialization. Now calls
check_status() and effect_idle() on startup, matching /set_led_config behavior.

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

* Fix DW LEDs staying on green blink after connection

The effect_idle() function was doing nothing when no idle effect was
configured, causing the green "connected" blink to persist. Now defaults
to Rainbow effect using controller's current speed/intensity parameters.

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

* Use controller's current parameters for default Rainbow idle effect

Explicitly pass speed, intensity, and colors from controller state
instead of relying on implicit values.

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

* Remove static/dist volume mount causing nginx 403 on Pi

The mount overrides the built-in frontend with local directory that
may not exist on Pi, causing 403 Forbidden errors.

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

* Remove mDNS implementation and zeroconf dependency

- Remove zeroconf from requirements.txt
- Remove mdns module import and startup/shutdown code
- Remove /api/discover-tables endpoint
- Delete modules/core/mdns.py

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

* Fix remote table control and improve multi-table stability

- Convert 56+ direct fetch() calls to apiClient across all pages
  - BrowsePage, LEDPage, SettingsPage, PlaylistsPage, TableControlPage
  - previewCache, Layout, NowPlayingBar
- Add apiClient.delete() body support for DELETE requests with payload
- Fix table selection persistence across page refreshes
  - Use ref to track restored selection before async state updates
- Reload page on table switch for clean WebSocket/cache state
- Fix WebSocket reconnection bugs in Layout and NowPlayingBar
  - Add shouldReconnect flag to prevent stale onclose handlers
  - Clear pending reconnect timeouts properly
- Optimize NowPlayingBar preview fetching
  - Skip fetch when bar is hidden
  - Track last fetched files to prevent duplicate requests
- Fix DW LED default Rainbow speed to 60 for smoother animation

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

* Improve mobile UI layout and fix pattern stop hanging

Mobile UI improvements:
- Add hamburger menu for header actions on mobile
- Make navbar fixed with safe area insets for iOS
- Use dynamic viewport height (dvh) for proper mobile sizing
- Compact page headers (text-xl on mobile, text-3xl on desktop)
- Improve Playlists page controls layout with stacking
- Make table selector and playlist actions visible on mobile (no hover)
- Compact Browse page filters (search + category on same row)
- Reduce Serial Terminal header overflow on mobile

Pattern execution fix:
- Add stop request checks in motion thread to prevent hanging
- Add 10s timeout on pattern lock acquisition in stop_actions
- Add 30s overall timeout in motion command retry loop
- Clear motion queue when stop is requested

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

* Fix Safari mobile WebSocket issues and improve request handling

- Fix WebSocket race condition causing "closed before established" error
  on Safari mobile by assigning wsRef immediately after creation
- Bypass Vite WebSocket proxy in dev mode for Safari mobile compatibility
  (connect directly to port 8080 instead of proxying through 5173)
- Add AbortController to cancel in-flight preview requests on page
  navigation (BrowsePage, PlaylistsPage)
- Fix cache manager race condition with asyncio.Lock for metadata writes
- Add debouncing (300ms) to LED color picker to reduce API spam
- Remove 30s motion thread timeout while preserving force stop capability
- Add Reset button (Ctrl+X) to serial terminal section
- Remove table discovery refresh button from TableSelector
- Fix TableContext reload loop on initial table selection

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

* Fix TypeScript build error for NodeJS.Timeout type

Use ReturnType<typeof setTimeout> instead of NodeJS.Timeout for
browser compatibility in Docker build environment.

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

* Fix CPU spike on pattern start by moving blocking I/O to thread

- Wrap parse_theta_rho_file() with asyncio.to_thread() in run_theta_rho_file()
- Wrap load_metadata_cache() with asyncio.to_thread() in run_theta_rho_files()

This prevents the event loop from blocking during file parsing, keeping
WebSocket updates, LED control, and status broadcasts responsive while
large pattern files are being read.

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

* Remove timeout from motion thread 'ok' response waiting

The motion thread now waits indefinitely for hardware 'ok' response,
only aborting on stop_requested. Use force stop if hardware becomes
unresponsive. Exception-based retries still in place for connection errors.

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

* Revert motion thread changes from 1b02b8c that caused pattern hangs

Reverts _execute_move() and _send_grbl_coordinates_sync() to their
pre-commit state. The stop_requested checks added in that commit
were interfering with normal pattern playback.

The motion thread now uses the simpler, working logic:
- _execute_move: Only checks self.running, not stop_requested
- _send_grbl_coordinates_sync: Simple retry loop without stop checks

Stop functionality still works via stop_actions() setting state flags
that are checked in the main pattern execution loop.

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

* add stop check

* Clear pause time between patterns on stop

Always clear pause_time_remaining and original_pause_time when stopping,
not just when clearing the playlist. This ensures the UI doesn't show
stale countdown values after stopping.

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

* Add historical ETA, persist pre-execution selection, fix mobile UI

- Display historical execution time as ETA when pattern was previously
  completed at the same speed (shows history icon indicator)
- Persist pre-execution action selection to localStorage (shared between
  Browse and Playlists pages)
- Fix delete pattern 404 by adding endpoint to Vite proxy config
- Fix mobile UI: circular preview containers, progress bar spacing
- Remove marquee effect from pattern name display

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

* Fix dark mode color hierarchy for better visual distinction

- Increase border color from 25% to 32% lightness for visibility
- Increase muted/secondary/accent from 20% to 25% lightness
- Increase input background from 22% to 28% lightness
- Remove nested bg-card from inner containers (use border only)
- Use bg-muted/50 for info cards and stat displays
- Fix Alert component using bg-background (too dark inside cards)
- Fix Still Sands time period cards background
- Fix LED page effect preview boxes background

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

* Fix process pool semaphore leaks and WebSocket connection warnings

Backend:
- Add shutdown flag to prevent race conditions in process pool
- Use wait=True in signal handler to allow workers to release semaphores

Frontend:
- Fix WebSocket cleanup to avoid closing CONNECTING sockets
- Add onopen handlers to gracefully close orphaned connections
- Properly handle React StrictMode double-mounting

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

* Fix "Up Next" showing wrong pattern during clear pattern execution

When a clear pattern was running for pattern 1, "Up Next" incorrectly
showed pattern 2 instead of pattern 1. The issue had two parts:

1. is_clearing flag was immediately reset to False by stop_actions()
   called inside _execute_pattern_internal(), before the pattern started

2. get_status() calculated next_file as playlist[current_index + 1]
   regardless of whether a clear pattern was running

Fixed by:
- Preserving is_clearing flag around stop_actions() call
- Setting is_clearing = True before clear pattern, False after
- Modifying get_status() to return current main pattern as next_file
  when is_clearing is True
- Adding is_clearing to status response for frontend awareness

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

* Improve NowPlayingBar UI: spacing, mobile queue access, drag-and-drop

- Fix remaining time too close to progress bar by increasing width
  from w-16 to w-20 when showing historical ETA and gap to 1.5
- Add queue button to header on mobile for easy queue access
- Refactor queue dialog to use drag-and-drop reordering with dnd-kit
- Replace up/down arrow buttons with drag handles for more intuitive UX
- Remove "hide clear patterns" toggle (no longer needed since playlists
  now only contain main patterns)

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

* simplify playlist playback

* Reduce default process pool workers for low-RAM devices

Default to 1 worker instead of (cpu_count - 1) to conserve RAM on
memory-constrained devices like Pi Zero 2 W (512MB). This prevents
memory pressure that causes UI sluggishness and unresponsive stops.

Added POOL_WORKERS env var to override if more workers are needed
on systems with more RAM.

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

* Fix stale "Waiting for next pattern" UI after skipping pause

Clear pause_time_remaining and original_pause_time when a new pattern
starts executing, not just after the pause loop ends. This prevents
stale waiting state from showing in the UI if skip was triggered
during a pause period.

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

* Fix stop not interrupting motion thread retry loop

The motion thread's retry loop was checking stop_requested only before
sending commands, not at the start of each iteration or while waiting
for responses. This caused infinite "No 'ok' received" retries when
stop was requested.

Added stop checks at:
- Start of each retry iteration (immediate return)
- Inside response wait loop (escape blocking readline)

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

* Bump version to 4.0.0

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

* Auto-create playlist if not found on get_playlist

Instead of returning 404 when a playlist doesn't exist, automatically
create an empty playlist. This handles cases like "Favorites" where
the UI expects the playlist to exist.

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

* Fix status indicator not syncing when switching tables

Reset isConnected and isBackendConnected to false when switching tables
so the header status indicator shows the transitional state until the
new table's WebSocket connection reports its actual status.

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

* Improve UI responsiveness, auto-connect options, and LED stability

Frontend:
- Fix Select dropdowns overflowing viewport (add max-height + scroll)
- Make Custom Logo upload button stack on mobile
- Add all LED color order options (BGR, RBG, GBR, BRG) with grouping
- Fix LED page icon alignment and mobile speed/intensity layout
- Redesign mobile filter bar (compact single row)
- Rename "Uncategorized" to "Default Patterns"

Backend:
- Add auto-connect disabled mode (__none__ option)
- Deprioritize /dev/ttyS0 during auto-connect (still available manually)
- Fix LED hardware change detection using wrong defaults
- Add 500ms delay between LED controller stop/reinit for stability
- Change default pixel order to RGB (WS2815) and GPIO to 18

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

* Auto-save auto-connect setting on dropdown change

- Remove need to click Save button after selecting auto-connect option
- Setting now saves immediately when dropdown value changes
- Shows toast confirmation with selected option

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

* Fix auto-connect settings and SelectLabel error

- Use explicit string values for preferred_port (__auto__, __none__)
- Backend stores values directly instead of converting to null
- Fix SelectLabel must be within SelectGroup error
- Connection manager handles both __auto__ and null for backwards compat

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

* Redesign playlist controls and improve button styling

- Convert playlist controls to floating pill-shaped element with blur backdrop
- Change all outline buttons to secondary variant across app
- Enhance secondary button hover with scale, shadow, and accent color
- Highlight clear pattern icon when a pattern is selected
- Add swap icon to pause time to indicate unit is clickable

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

* Add pattern history display, queue management, and multi-table UX improvements

- Add pattern execution history showing last run time and speed in browse panel
- Add "Play Next" and "Add to Queue" buttons to insert patterns into running playlist
- Replace custom slide-in panel with shadcn Sheet component for better UX
- Show table name in header when multiple tables are connected
- Fix CORS configuration for cross-origin multi-table access
- Cache parsed coordinates in state to avoid re-parsing large files on Pi Zero 2W
- Use thread executor instead of process pool for coordinate parsing (reduces memory pressure)
- Add Now Playing button to desktop header for quick access

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

* Improve mobile responsiveness and unify page styling

- Match navbar background with header (bg-card)
- Reduce page title font size and add left padding
- Make filter bar compact on mobile with icon-only buttons
- Standardize page spacing across all pages (py-3 sm:py-6)

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

* Add play time badges, editable numeric inputs, and UI refinements

- Add play time badges on pattern cards showing last execution duration
- Make Speed, Intensity, Number of LEDs, and Pause inputs editable via typing
- Replace timezone datalist with SearchableSelect for better UX
- Add soft reset button to Control page (sends GRBL Ctrl+X)
- Change Home button to primary (blue) and Reset to secondary styling
- Reduce Select dropdown max-height from 384px to 256px
- Add /soft_reset and /api/pattern_history_all backend endpoints

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

* Fix playlist page layout and silence auto-connect toast

- Fix playlist container height to account for bottom navbar
- Align sidebar and main content headers with matching structure
- Add playlist count subtitle to sidebar header
- Make serial terminal auto-connect silent (no toast on page load)

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

* update

* Fix TypeScript error in serial connect button onClick handler

Wrap handleSerialConnect in arrow function to prevent MouseEvent being
passed as the silent parameter, which caused a type mismatch during build.

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

* Fix stuck pattern state and improve stop/reset reliability

- Add timeout (30s) to check_idle_async() to prevent infinite loops
- check_idle_async() now respects stop_requested flag for early exit
- Add /force_stop endpoint for nuclear cleanup when normal stop fails
- /stop_execution now returns error on timeout, triggering force_stop fallback
- /soft_reset uses direct serial write for more reliable reset
- Reset button calls force_stop first to clear stuck state
- Stop buttons in UI auto-retry with force_stop on failure

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

* Revert backend changes to debug serial terminal issue

Temporarily reverting stop/reset reliability changes to test if they
caused the serial terminal to stop receiving responses.

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

* Prevent crash when sensor homing partially fails

Skip position zeroing (x0 y0) if not both X and Y axes received homing
confirmation. Moving to zero with an unknown axis position would crash
the machine - essentially performing unintended crash homing.

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

* Add force stop endpoint and improve pattern execution control

- Add /force_stop endpoint to clear all pattern state when normal stop times out
- Improve soft_reset to send Ctrl+X directly via serial for reliability
- Add timeout parameter to check_idle_async with stop request handling
- Fix homing to skip zeroing only when X homed but Y failed (avoids Y crash)
- Return success/failure from stop_actions for better error handling
- Change default LED speed from 128 to 50
- Remove "(common)" labels from LED pixel order options

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

* Fix WebSocket race condition on page load for multi-table support

Initialize apiClient baseUrl synchronously from localStorage before any
React components mount. This prevents WebSockets from briefly connecting
to the wrong backend when a remote table was previously selected.

Also skip notifying listeners when setBaseUrl is called with the same
URL to avoid unnecessary WebSocket reconnections.

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

* Move auto-homing to after pause time in playlist execution

Relocate the auto-homing check from immediately after pattern completion
to after the pause time, right before the next clear pattern starts.

This allows users to enjoy the completed pattern during the full pause
duration before homing disrupts it. The homing now logically precedes
the clear pattern which erases the sand anyway.

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

* Fix patterns not loading due to stale localStorage URL initialization

CRITICAL FIX: Reverted apiClient localStorage pre-initialization that
could cause all API calls to silently fail if users had stale table
data pointing to unreachable servers.

Also includes:
- Increase main content top padding (pt-16 → pt-[4.5rem]) to prevent
  header overlap on mobile
- Make Add Pattern and Cache buttons responsive (h-9 on mobile)

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

* Fix nested button HTML error in PatternCard

Change favorite toggle from <button> to <span role="button"> to avoid
invalid nested button elements. Adds proper keyboard accessibility
with tabIndex and onKeyDown handlers for Enter/Space keys.

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

* Fix race condition with stale serial port from wrong backend

When using multi-table mode, the frontend could receive serial port data
from a different backend during the brief window before TableContext
validates the active table URL. This caused connect attempts with
non-existent ports (e.g., Mac's /dev/cu.usbserial on a Pi).

Fix: Validate that the port returned from /serial_status exists in the
available ports list from /list_serial_ports before setting it. If the
port doesn't exist on this machine, log a warning and ignore it.

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

* Fix move_to_center/perimeter not working after pattern stop

When a pattern was stopped, state.stop_requested remained True. The
motion control thread checks this flag and aborts commands immediately,
causing manual move commands (center/perimeter) to return 200 OK but
never actually send G-code to the controller.

Fix: Clear stop_requested at the start of move_polar() so manual
movement commands always execute regardless of previous stop state.

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

* Fix SelectItem highlight not matching pill-shaped dropdown

Changed SelectItem border-radius from rounded-sm to rounded-xl to match
the pill-shaped aesthetic of SelectTrigger (rounded-full) and
SelectContent (rounded-2xl). The highlight now properly fills the item.

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

* Add event-driven stop/skip for instant interrupt response

Previously, stop and skip requests during scheduled pauses were ignored
or delayed up to 1 second due to polling-based checks. This caused the
UI to appear frozen when users tried to skip or stop during Still Sands
periods.

Changes:
- Add asyncio.Event backing to stop_requested/skip_requested flags
- Add wait_for_interrupt() method for instant event-driven waiting
- Fix in-pattern pause loop to respond to stop/skip immediately
- Fix post-pattern scheduled pause to respond to skip (not just stop)
- Increase DEFAULT_WORKERS from 1 to 3 for better parallelism
- Remove deprecated .cursorrules file (replaced by .claude/CLAUDE.md)

The property setters automatically sync flags and events, so existing
code using state.stop_requested = True works unchanged while async
code now gets instant wake-up via events.

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

* Fix stop timeout during pause and stale "waiting" UI state

Two issues fixed:

1. Stop/skip during manual pause could hang indefinitely:
   - The event wait had no timeout, relying solely on asyncio.Event
   - If stop was called from sync context (no event loop), events weren't set
   - Added 1-second timeout to ensure flags are polled as fallback

2. "Waiting for next pattern" shown during pattern playback:
   - Single pattern execution didn't clear pause_time_remaining
   - Stale values from previous playlist caused incorrect UI state
   - Now cleared at start of run_theta_rho_file()

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

* Improve UI consistency and responsiveness

- Browse page: Add gap between icons and text in filter dropdowns
- NowPlayingBar: Make real-time preview responsive (42vh on desktop,
  capped at 500px), increase vertical padding
- PlaylistsPage: Restyle pattern picker modal filters to match Browse
  page (pill-shaped buttons, icon-only on mobile, consistent order:
  folder → sort → direction)

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

* Add host-based update watcher for Docker deployments

Implements a signal-based update mechanism that allows triggering
`dw update` from within Docker containers. The container creates a
trigger file that a host systemd service watches for, then executes
the full update process on the host machine.

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

* Add Update Now button to Settings page

Adds a button to trigger software updates from the web UI instead of
requiring SSH access to run `dw update` manually. Shows status feedback
during update process.

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

* Improve Now Playing Bar positioning and add favorite to pattern panel

- Fix Now Playing Bar position calculation when logs drawer is open
- Pass dynamic logsDrawerHeight instead of hardcoded value
- Add vertical padding to collapsed bar content
- Adjust bar heights for better spacing
- Add favorite toggle button in pattern details panel header
- Add env vars for testing update functionality

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

* Fix docker-compose.yml invalid empty environment mapping

Move environment block to comments since all values were commented out.
An empty environment: key with only comments is invalid YAML.

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

* Fix Python 3.9 compatibility for type hints

Use typing.Optional and typing.Tuple instead of Python 3.10+ union
syntax (str | None) for backwards compatibility.

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

* Revert auto-update feature, instruct users to SSH and run dw update

The host-based update watcher added complexity and required manual
service installation. Simpler to just tell users to SSH in and run
the dw update command directly.

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

* Fix dw update to re-exec with new CLI after git pull

The script now re-executes itself after updating the dw CLI file,
ensuring any new update logic in the script actually runs. Uses
--continue flag to skip the pull phase on re-exec.

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

* Add sort by favorites option to Browse and Playlist pages

- Add 'favorites' to SortOption type in lib/types.ts
- BrowsePage: Add favorites sorting (favorites first, then by name)
- PlaylistsPage: Add favorites state, load from Favorites playlist
- PlaylistsPage: Add favorites sorting to pattern picker modal
- Both pages now show Favorites as first sort option in dropdown

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

* Change header Reset button to do table soft reset instead of Docker restart

The Reset button (restart_alt icon) in the header now:
- Calls /force_stop to clear any stuck state
- Calls /soft_reset to reset the DLC32/ESP32
- Shows "Reset sent. Homing required." toast

This matches the behavior of the Reset button in TableControlPage.

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

* Add missing endpoints to nginx proxy config

Added force_stop, soft_reset, reorder_playlist, and delete_theta_rho_file
to the nginx proxy regex. These were missing, causing 404s when accessing
a remote table through nginx.

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

* Revert header button to Docker restart, remove TableControl Reset button

- Layout.tsx: Revert handleRestart to restart Docker containers (with confirmation)
- Layout.tsx: Change labels back to "Restart Docker"
- TableControlPage.tsx: Remove Reset button (keep only Home and Stop)
- TableControlPage.tsx: Remove handleSoftReset function
- TableControlPage.tsx: Remove client-side check blocking Home button

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

* Fix clipboard copy for non-HTTPS contexts (http://dwg.local)

Add copyToClipboard helper that falls back to document.execCommand('copy')
when navigator.clipboard is unavailable (non-secure HTTP contexts).

Fixes copy button in Homing Log and main logs drawer.

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

* Improve UI consistency and mobile experience

- NowPlayingBar: Add pattern preview in expanded view, optimistic drag-drop
- NowPlayingBar: Show move to top/bottom buttons always on mobile
- Layout: Unify desktop/mobile header with same 3-icon layout (play, table, menu)
- Layout: Tighter icon spacing, fix active table name display
- TableSelector: Remove dropdown arrow, move checkmark to far right
- BrowsePage: Add swipe to dismiss for pattern detail sheet

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

* minor ui change

* Add PWA support and improve mobile experience

- Add vite-plugin-pwa for service worker and caching
- Add iOS safe area support (Dynamic Island, notch)
- Add mobile drill-down navigation for Playlists with swipe-back gesture
- Update favicon/PWA icons to square format (OS applies masks)
- Add circular favicons with transparent background for browser tabs
- Add dynamic manifest endpoint for custom branding support
- Generate proper PWA icons on custom logo upload
- Allow dismissing homing splash screen
- Add scroll-to-top on page navigation
- Enable header blur only on Browse page

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

* Improve UI with floating Now Playing button and various fixes

- Add centered floating Now Playing button above nav bar (all screens)
- Remove Now Playing button from header to declutter
- Fix table selector dropdown alignment with header pill
- Show cache progress percentage on mobile
- Fix toast notifications blocked by Dynamic Island in PWA mode
- Improve lazy loading for pattern previews in playlists and queue

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

* Show table icons in table selector dropdown

- Add customLogo field to Table interface
- Fetch settings when discovering/adding tables to get custom logo
- Refresh remote tables in background to fetch their logos
- Replace wifi icons with table's actual icon/logo
- Add status dot overlay to indicate online/offline state

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

* Improve Now Playing bar mobile UX and PWA safe areas

- Lock body scroll when Now Playing bar is visible
- Add swipe-to-dismiss for queue dialog with visual indicator
- Allow scrolling inside queue while preventing background scroll
- Add safe area padding for Dynamic Island in expanded view
- Position header buttons below Dynamic Island when expanded

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

* Persist added tables in backend for multi-device access

Backend:
- Add known_tables field to AppState for persistent storage
- Add GET/POST/DELETE/PATCH /api/known-tables endpoints
- Tables stored in state.json alongside other settings

Frontend:
- Fetch known tables from backend on discovery
- Persist added tables to backend via POST
- Remove tables from backend via DELETE
- Update known table names in backend for remote tables
- Keep localStorage for active table selection (client-specific)

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

* Fix multi-table WebSocket connections failing in production

The issue was that table switching worked in development but failed in
production (port 80). Two problems were fixed:

1. Removed reliance on stale `isCurrent` flag from localStorage which
   could incorrectly prevent the base URL from being set
2. Added URL normalization for comparison to handle port differences
   (e.g., http://host:80 vs http://host are now correctly recognized
   as the same origin)

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

* Add debug logging for multi-table WebSocket issue

Adds console.log statements to track:
- When setBaseUrl is called and with what values
- What WebSocket URLs are generated
- Table restoration from localStorage and URL comparison

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

* Fix race condition: pre-initialize apiClient base URL from localStorage

WebSocket connections were being established before TableContext had
a chance to set the base URL, causing connections to go to the wrong
server when switching tables.

Solution: Initialize the base URL synchronously at module load time,
before React renders. This ensures the correct URL is available
immediately when components mount and create WebSocket connections.

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

* Remove debug logging from multi-table fix

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

* Center real-time preview canvas in expanded view

Added flex-1 to the canvas container so it expands to fill available
space, allowing the canvas to be properly centered.

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

* Center real-time preview canvas in expanded Now Playing view

- Added h-full to max-width container for proper height inheritance
- Added flex-1 to canvas wrapper to fill available space
- Removed conflicting md:justify-center from inner container

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

* Prevent debug terminal from interfering with pattern execution

- Remove auto-connect to serial debug terminal on page load
- Add warning alert advising users not to use terminal during pattern execution
- Fixes silent pattern failure when debug terminal intercepts GRBL responses

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

* Fix build error and add retry logic for motion commands

- Remove unused mainConnectionPort state (fixes TS6133 build error)
- Add 1-second timeout with automatic resend for motion commands
- Motion thread now retries forever until 'ok' is received
- Prevents silent hang if response is missed

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

* Center warning text vertically in alert box

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

* Add Reset button with confirmation dialog

- Sends Ctrl+X soft reset to controller
- Shows warning that homing is required after reset
- Uses existing /soft_reset API endpoint

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

* Increase serial port selector width to prevent text cutoff

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

* Replace native select with shadcn Select for serial port

- Fixes dropdown positioning issues with native select
- Uses Radix UI portal for proper z-index and positioning
- Auto-refreshes port list when dropdown opens

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

* Revert retry timeout - wait indefinitely for GRBL 'ok'

GRBL only sends 'ok' after a move completes, which can take many
seconds at slow speeds. The 1-second timeout was causing premature
resends and duplicate commands.

Reverted to original behavior: wait indefinitely for 'ok' response.
Added stop_requested checks so patterns can still be cancelled.

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

* Restore null check in get_status_response()

Prevents crash when called without active connection.

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

* Fix serial stability issues caused by asyncio event overhead

- Make stop_requested/skip_requested setters thread-safe using
  call_soon_threadsafe() for cross-thread event manipulation
- Remove state.stop_requested=False from move_polar() which was
  triggering event manipulation thousands of times per pattern
- Add stop_requested clearing to manual move endpoints instead
  (/move_to_center, /move_to_perimeter, /send_coordinate)
- Fix BrowsePage upload refresh to call fetchPatterns()

The root cause was move_polar() setting stop_requested on every
coordinate, causing constant asyncio event manipulation that
created race conditions and overhead during pattern execution.

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

* Update package-lock.json dependencies

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

* Fix CPU spin loop in motion thread readline wait

Added sleep(0.01) after readline() to prevent 100% CPU usage when
readline() returns empty (timeout or bad serial state). Without this,
the inner while loop would spin continuously.

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

* Remove dangerous lock=None in connection close methods

Setting self.lock = None after close() caused race conditions:
- Motion thread calls readline() with "with self.lock:"
- If close() was called, lock is None
- AttributeError is caught, motion thread retries forever
- Pattern hangs waiting for "ok" that never comes

This was a regression not present in main branch.

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

* Fix UI showing stale countdown after skip during pause

Clear both pause_time_remaining and original_pause_time immediately
when skip breaks the pause loop. Previously original_pause_time wasn't
cleared until the next pattern started, causing the UI to show stale
countdown state.

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

* Add 120s timeout to motion thread readline loop

Previously the motion thread would wait indefinitely for 'ok' response.
If serial communication fails silently (no exception, just no data),
the pattern would hang forever.

Now after 120 seconds without 'ok', the motion thread:
- Logs an error about the timeout
- Sets stop_requested = True to stop the pattern
- Returns False to signal the failure

Also added logging for GRBL error/alarm responses for debugging.

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

* Fix race condition with multiple playlist tasks running

Previously, stopping a playlist only set flags but didn't cancel the
actual asyncio task. When starting a new playlist immediately after:
1. Old task continues running (just between patterns)
2. New playlist clears stop_requested
3. Old task sees stop_requested=False and continues!

Now:
- Track the playlist task in _current_playlist_task
- cancel_current_playlist() properly cancels and awaits the task
- stop_actions() calls cancel_current_playlist() when clearing playlist
- run_playlist() cancels any existing task before starting new one

This prevents "ghost" playlists from running in parallel.

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

* Add validation for coordinate calculations to prevent GRBL error:2

GRBL error:2 means "bad number format" - happens when coordinates
contain inf, nan, or other invalid values.

Added:
- Protection against division by zero in offset calculation
- Validation that coordinates are finite before sending to GRBL
- Detailed error logging when invalid values detected
- Stops pattern gracefully instead of hanging on repeated errors

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

* Stop pattern on GRBL error/alarm instead of hanging

When GRBL returns an error (like error:2) or alarm, it will NOT send
'ok' afterwards. Previously the code just logged a warning and kept
waiting for 'ok' until timeout (120s).

Now GRBL errors/alarms immediately:
- Log the error with the failed command
- Set stop_requested = True
- Return False to stop pattern execution

This provides immediate feedback instead of hanging for 2 minutes.

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

* Add NaN/Infinity validation before sending GRBL coordinates

GRBL error:2 ("Numeric value format is not valid") can be caused by
NaN or Infinity values in coordinates. This can happen if:
- y_steps_per_mm = 0 (division by zero in offset calculation)
- gear_ratio = 0 (division by zero in offset calculation)

Now validates coordinates before sending and logs detailed diagnostic
info if invalid values are detected, helping identify the root cause.

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

* Fix homing timeout due to WPos vs MPos format mismatch

- Support both WPos and MPos GRBL status formats (depends on $10 setting)
- Add guard against None machine position when homing fails
- Prevents NoneType + float crash in pattern execution

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

* Fix get_machine_position to also accept WPos format

Missed this function in the previous fix - it was still checking
only for MPos, causing position query timeouts after homing.

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

* Add debug logging for GRBL response timeout investigation

- Remove time.sleep(0.01) to match main branch behavior
- Log unexpected GRBL responses (neither ok/error/alarm)
- Log waiting status every 30s during long waits

This helps diagnose why Pi 3B+ times out on feature branch but not main.

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

* TEMP: Disable real-time scheduling to test Pi 3B+ serial timeout

Hypothesis: SCHED_RR priority 60 + CPU 0 pinning may be blocking
serial interrupt handling on Pi 3B+, causing the motion thread
to never receive 'ok' responses from GRBL.

CPU 0 typically handles hardware interrupts including serial ports.
Running a real-time thread there might starve the interrupt handler.

This is a test commit - will be reverted or made configurable
based on results.

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

* Fix serial echo causing GRBL communication failure on Pi 3B+

Root cause: On Pi 3B+ with /dev/ttyAMA0, the serial port had echo
enabled at the TTY level. This caused:
1. Sent G-code commands echoed back as "responses"
2. Motion thread waiting for 'ok' but receiving its own commands
3. Buffer corruption merging commands (G1 G53 -> G10G53)
4. GRBL error from corrupted command, stopping pattern

Fix: Disable ECHO and related flags via termios when opening the
serial connection. This ensures raw serial communication without
any TTY processing that could interfere.

Also re-enables real-time scheduling which wasn't the cause.

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

* Handle FluidNC command echo gracefully

FluidNC firmware echoes commands back before sending 'ok'.
Instead of logging warnings for each echo, silently skip echoed
G-code commands (G0, G1, G2, G3, $J, M) and continue reading
to get the actual 'ok' response.

This reduces log spam while maintaining proper error detection
for actual GRBL errors and alarms.

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

* Disable SCHED_RR - causes serial buffer corruption on Pi 3B+

The real-time scheduling (SCHED_RR priority 60 + CPU 0 pinning)
appears to cause serial buffer corruption on Pi 3B+, resulting in:
- Commands being merged (e.g., "G1 G53" -> "G10G53")
- GRBL errors from corrupted commands
- Pattern execution failures

This is separate from the FluidNC echo issue (which main branch
handled silently). The corruption is timing-related and specific
to certain Pi models.

Keeping disabled until we can investigate proper CPU affinity
settings that don't interfere with serial I/O.

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

* Revert termios modifications - may cause serial corruption

The termios changes (disabling ECHO, ICANON) may be causing
serial data corruption (G1 -> O1). Main branch doesn't have
these modifications and works fine.

The FluidNC echo is handled in pattern_manager.py by ignoring
echoed G-code commands, so termios-level echo suppression
is not needed.

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

* Add serial corruption retry logic for Pi 3B+ UART timing issues

- Add reset_input_buffer() method to SerialConnection for clearing stale data
- Clear serial input buffer before sending G-code commands
- Add 5ms post-send delay for UART buffer stabilization
- Implement retry logic (up to 3 retries) for corruption-type GRBL errors
  (error:1, error:2, error:20-23 indicate syntax errors from bit flips)
- Throttle WebSocket status polling during pattern execution (2s vs 1s)
- Detect MSG:ERR: Bad GCode messages as corruption indicators

This addresses serial corruption on Pi 3B+ where G-code commands get
garbled (e.g., G53 → G5s) due to UART timing issues under asyncio load.

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

* Add timeout recovery logic for lost 'ok' responses on Pi 3B+

When a 120s timeout occurs waiting for 'ok', the code now attempts recovery:
- Sends status query '?' to check if machine is still responsive
- If machine is Idle: assumes command completed, 'ok' was lost - continues pattern
- If machine is Run: extends timeout as movement is still in progress
- If delayed 'ok' received during recovery: accepts it and continues
- If no response: retries the command (up to 2 times) before stopping

This handles cases where the serial 'ok' response is completely lost
due to UART timing issues on Pi 3B+, without unnecessarily stopping patterns.

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

* Add comprehensive logging to timeout recovery for diagnosis

Enhances the timeout recovery section with detailed logging to help
diagnose why recovery fails in some cases (user reported GRBL was
Idle but recovery didn't detect it). New logging includes:

- Failed G-code command and retry counts
- Connection type and state validation
- Buffer clear confirmation and bytes waiting
- Each response received during recovery (at INFO level)
- Summary of all responses received
- Clear failure summary with possible causes

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

* Switch from machine coordinates (G53) to work coordinates with reset

This prevents MPos from accumulating indefinitely and hitting soft limits
(error:30) during long pattern sequences.

Changes:
- Add reset_work_coordinates() to clear G92/G54 offsets on connection
- Remove G53 from G1 commands to use work coordinates (G54 default)
- Add G92 X0 in reset_theta() to reset work X position each pattern
- Reduce coordinate precision to 2 decimals

The G92 X0 command sets current position as X=0 without moving, keeping
coordinates bounded. Soft limits still check MPos, so safety is preserved.

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

* Increase serial retry limits to 10 for better Pi 3B+ reliability

Raises both corruption and timeout retry limits from 3/2 to 10 to handle
persistent UART timing issues on Raspberry Pi 3B+.

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

* Add Y axis reset to work coordinate reset function

Now resets both X and Y work coordinates with G92 X0 Y0 to prevent
coordinate accumulation on both axes.

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

* Clean up log messages

- Simplify work coordinates reset log message
- Remove noisy real-time scheduling disabled log

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

* Revert process isolation and real-time scheduling (commit 5a8643c)

Remove ProcessPoolExecutor and real-time scheduling features that were
causing issues on some systems:

- Remove modules/core/process_pool.py
- Remove modules/core/scheduling.py
- Revert cache_manager.py to use asyncio.to_thread
- Revert preview.py to use asyncio.to_thread
- Remove CPU pinning and priority elevation from motion/LED threads

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

* Add .vite/ to gitignore

Vite's cache directory should not be committed.

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

* Use $Bye for hard reset instead of G92 X0 Y0

G92 only sets work coordinate offset without changing MPos, causing
position queries to return stale values. $Bye performs a full FluidNC
soft reset which properly clears position counters to 0.

- Add perform_soft_reset() in connection_manager with proper wait logic
- Wait for "Grbl" startup banner (5s timeout) before proceeding
- Send $X unlock after reset in case of alarm state
- Simplify reset_theta() to use shared soft reset function
- Update /soft_reset endpoint to use new function

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

* Add soft reset on device init and support both GRBL/FluidNC

- Perform soft reset before get_machine_steps() in device_init()
- Detect firmware type and use appropriate reset command:
  - FluidNC: $Bye
  - GRBL: Ctrl+X (0x18)
- Refactor to sync/async versions for different contexts
- Ensures controller is in known state before querying

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

* Fix $Bye reset reliability and prevent position drift

- Add idle check before reset_theta() to prevent error:25 when
  controller is still processing commands
- Add retry logic (3 attempts with 5s/7s/9s timeouts) to soft reset
- Fail-fast: only set position to (0,0) when confirmation received,
  preventing position drift from accumulating over long operations
- Add CPU affinity: pin Docker backend to cores 0-2, touch app to
  core 3 with Nice=10 to prevent serial I/O timing issues

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

* Fix device init: check position before reset to avoid unnecessary homing

Query machine position BEFORE soft reset to determine if homing is needed.
Previously, reset was done first which zeroed position, causing the
comparison to always trigger homing even when machine retained position.

New sequence:
1. Query machine position (before reset)
2. Compare with saved state to decide if homing needed
3. Perform soft reset for clean controller state
4. Home only if position was mismatched

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

* Increase soft reset retries to 5 with exponential backoff

- Increase max_retries from 3 to 5 for better reliability
- Timeout uses 1.5x backoff: 5s, 7.5s, 11s, 17s, 25s
- Retry delay uses 2x backoff: 1s, 2s, 4s, 8s between attempts
- Gives controller more time to recover from transient issues

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

* Increase homing timeout from 90s to 120s

Provides buffer for controller to stabilize after soft reset
recovery before expecting reliable communication during homing.

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

* Update frontend: remove Ctrl+X reference from soft reset UI

Backend now auto-detects firmware and sends appropriate command:
- FluidNC: $Bye
- GRBL: Ctrl+X (0x18)

UI now shows generic "Soft Reset" instead of "Ctrl+X Soft Reset"

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

* extend logs

* chore(backend-testing-01): create test directory structure

- tests/ root with __init__.py
- tests/unit/ for mocked unit tests (CI)
- tests/integration/ for hardware tests (local only)
- tests/fixtures/ for test data files

* chore(backend-testing-01): add testing dependencies

- pytest + pytest-asyncio for async test support
- pytest-cov for coverage reporting
- pytest-timeout for test timeouts
- httpx for async API testing
- mock-serial for serial port mocking
- factory-boy + faker for test data generation

* chore(backend-testing-01): configure pytest in pyproject.toml

- testpaths set to tests/
- asyncio_mode = auto for pytest-asyncio
- asyncio_default_fixture_loop_scope = function
- markers for hardware and slow tests
- coverage settings with relative_files = true

* chore(backend-testing-01): create root conftest.py with shared fixtures

- pytest_configure for CI detection and markers
- pytest_collection_modifyitems to auto-skip hardware tests in CI
- async_client fixture for API testing with httpx
- mock_state fixture with comprehensive state defaults
- mock_connection fixture for serial/websocket mocking
- patterns_dir fixture for temporary test patterns

* test(backend-testing-01): add playlist_manager CRUD tests

- test_load_playlists_empty_file, test_load_playlists_with_data
- test_create_playlist, test_create_playlist_overwrites_existing
- test_get_playlist_exists, test_get_playlist_not_found
- test_modify_playlist, test_delete_playlist
- test_list_all_playlists, test_add_to_playlist
- test_rename_playlist_* (6 edge cases)

20 tests total, all passing

* test(backend-testing-01): add pattern_manager parsing tests

- parse_theta_rho_file tests (valid, comments, empty lines, not found)
- parse_theta_rho_file edge cases (invalid lines, whitespace, scientific notation, negative)
- list_theta_rho_files tests (basic, subdirectories, skip cached_images)
- get_status tests (idle, running, paused, playlist)
- is_clear_pattern tests (standard, mini, pro, regular patterns)

22 tests total, all passing

* test(backend-testing-01): add connection_manager parsing tests

- parse_machine_position tests (MPos, WPos formats)
- parse_machine_position edge cases (invalid, run/alarm state, negative, high precision)
- list_serial_ports tests (filter ignored, empty, all ignored)
- BaseConnection interface tests
- IGNORE_PORTS and DEPRIORITIZED_PORTS constant tests
- is_machine_idle tests (no connection, idle, running)

21 tests total, all passing

* chore(backend-testing-01): create unit conftest with mocked dependencies

- mock_state_unit fixture with comprehensive state defaults
- mock_connection_unit fixture for hardware mocking
- app_with_mocked_state fixture for patched app testing
- async_client_with_mocked_state fixture for API testing
- cleanup_app_overrides fixture for dependency cleanup

* test(backend-testing-01): add status and info API endpoint tests

- /serial_status tests (connected, disconnected states)
- /list_serial_ports tests (returns list, empty)
- /api/settings tests (structure, effective_table_type)
- /api/table-info tests (configured, not configured)

9 tests total, all passing

* test(backend-testing-01): add pattern API endpoint tests

- /list_theta_rho_files tests (list, empty)
- /list_theta_rho_files_with_metadata tests (structure)
- /get_theta_rho_coordinates tests (valid file, not found)
- /run_theta_rho tests (disconnected, during homing, not found)
- /stop_execution tests (success, disconnected)
- /pause_execution and /resume_execution tests
- /delete_theta_rho_file tests (success, not found)

14 tests total, all passing

* test(backend-testing-01): add playlist API endpoint tests

- /list_all_playlists tests (list, empty)
- /get_playlist tests (exists, auto-creates if not found)
- /create_playlist and /modify_playlist tests
- /delete_playlist tests (success, not found)
- /rename_playlist tests (success, not found)
- /add_to_playlist tests (success, not found)
- /run_playlist tests (disconnected, during homing)
- /skip_pattern tests (success, no playlist)

16 tests total, all passing

* ci(backend-testing-01): add GitHub Actions workflow for tests

- Triggers on push to main/feature branches and PRs
- Runs unit tests with CI=true (skips hardware tests)
- Generates coverage report with pytest-cov
- Uploads coverage to Codecov
- Includes Ruff linting job (continue-on-error)
- Uses Python 3.11 with pip caching

* test(backend-testing-01): add integration test skeleton for hardware

- Add conftest.py with hardware detection fixtures
- Add test_hardware.py with @pytest.mark.hardware tests
- Tests auto-skip without --run-hardware flag
- Tests auto-skip in CI (when CI=true)

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

* test(integration): expand hardware tests with movement and pattern execution

Fixes:
- Status query: Don't assume Idle state, accept any valid GRBL status
- Soft reset: Auto-detect firmware (FluidNC=$Bye, GRBL=Ctrl+X)

New tests:
- test_firmware_detection: Detect FluidNC vs GRBL firmware
- test_homing_sequence: Full homing cycle with position verification
- test_move_to_perimeter: Move ball to rho=1.0
- test_move_to_center: Move ball to rho=0.0
- test_execute_star_pattern: Run star.thr pattern end-to-end
- test_websocket_status_endpoint: Test /ws/status WebSocket
- test_position_saved_on_disconnect: Verify state persistence

Total: 13 integration tests (all skip without --run-hardware)

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

* test(integration): add playback controls and playlist tests

Playback controls (test_playback_controls.py):
- TestPauseResume: pause_during_pattern, resume_after_pause
- TestStop: stop_during_pattern, force_stop, pause_then_stop
- TestSpeedControl: set_speed_during_playback, speed_bounds, change_speed_while_paused
- TestSkip: skip_pattern_in_playlist, skip_while_paused

Playlist tests (test_playlist.py):
- TestPlaylistModes: single_mode, loop_mode, shuffle
- TestPlaylistPause: pause_between_patterns, stop_during_playlist_pause
- TestPlaylistClearPattern: playlist_with_clear_pattern
- TestPlaylistStateUpdates: current_file_updates, playlist_index_updates, progress_updates
- TestWebSocketStatus: status_updates_during_playback

Total: 33 integration tests (all skip without --run-hardware)

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

* docs: add testing guide README

Includes:
- Quick start commands
- Test structure overview
- Unit vs integration test instructions
- Coverage report generation
- CI behavior explanation
- Examples for adding new tests
- Troubleshooting section

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

* fix(test): account for angular_homing_offset_degrees in homing test

The homing sequence sets theta to the configured offset (e.g., 135°),
not 0. Test now compares against state.angular_homing_offset_degrees.

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

* fix(test): wait for idle after move instead of sleep

Movement tests now use check_idle_async() to properly wait for the
machine to complete the move, instead of unreliable time.sleep(2).

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

* fix: move_to_center and move_to_perimeter wait for idle before returning

API endpoints now call check_idle_async(timeout=60) after move_polar
to ensure the machine has completed the movement before returning success.

This gives the frontend accurate feedback that the move is complete.

Tests updated to use the API endpoints directly instead of calling
move_polar + manual idle check.

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

* fix(test): remove unsupported timeout arg from receive_json()

FastAPI TestClient's WebSocket receive_json() doesn't accept timeout.

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

* fix(test): fix WebSocket status format and add fast test speed

- WebSocket returns {'type': 'status_update', 'data': {...}}
  Tests now check message.get("data") for status fields
- Add autouse fixture to set speed=500 for faster integration tests
- Restores original speed after each test

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

* fix: preserve playlist state when async task is cancelled by TestClient

Root cause: FastAPI TestClient cancels async background tasks when HTTP
requests complete, causing playlist state to be cleared before tests
could verify it.

Changes:
- playlist_manager: Set all state vars (playlist_mode, current_playlist_index,
  current_playing_file) BEFORE creating async task for immediate visibility
- pattern_manager: Handle CancelledError specially to preserve state when
  task is externally cancelled (vs normal completion or user stop)
- pattern_manager: Only clear current_playing_file in stop_actions() when
  clear_playlist=True, since caller sets it immediately after otherwise
- pattern_manager: Fix Python 3.9 compatibility (asyncio.timeout -> wait_for)
- main.py: Skip endpoint proactively advances state when task not running
- main.py: Fix HTTPException being incorrectly wrapped in 500 error
- tests: Fix pattern path check (./patterns/ prefix was missing)
- tests: Add reset_asyncio_events fixture to handle event loop isolation

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

* fix: stop_execution and send_coordinate wait for idle before returning

- stop_actions() now waits for hardware to reach idle state (30s timeout)
- send_coordinate endpoint waits for idle after move (60s timeout)
- Clear stop_requested flag before idle check to allow check_idle_async to work

This ensures API responses only return after the machine has physically
stopped moving, consistent with move_to_center and move_to_perimeter.

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

* chore(01-01): install Vitest and React Testing Library

- Add vitest, @vitest/ui, jsdom as dev dependencies
- Add @testing-library/react, @testing-library/jest-dom, @testing-library/user-event
- Create vitest.config.ts with jsdom environment, globals, and coverage
- Create src/test/setup.ts with jest-dom matchers and cleanup
- Add test scripts: test, test:watch, test:ui, test:coverage

* chore(01-01): install and configure Playwright

- Add @playwright/test as dev dependency
- Install Chromium browser for E2E testing
- Create playwright.config.ts with chromium project, webServer, and CI settings
- Add test:e2e and test:e2e:ui scripts

* chore(01-01): configure MSW for API mocking

- Add msw as dev dependency for mocking API requests
- Create src/test/mocks/handlers.ts with mock endpoints for patterns, playlists, status
- Create src/test/mocks/server.ts with MSW server setup
- Update src/test/setup.ts to start/stop MSW server and reset handlers between tests

* test(01-01): add sample tests and TypeScript types

- Create src/__tests__/sample.test.tsx to verify Vitest + RTL + jest-dom work
- Create e2e/sample.spec.ts to verify Playwright configuration
- Add vitest/globals to TypeScript types for test globals (describe, it, expect)

* chore(01-01): add @vitest/coverage-v8 for coverage reports

- Add @vitest/coverage-v8@3.2.4 to match vitest version
- npm run test:coverage now generates v8 coverage reports

* chore(01-01): ignore frontend test artifacts

- Add frontend/coverage/ for Vitest coverage reports
- Add frontend/playwright-report/ for Playwright HTML reports
- Add frontend/test-results/ for Playwright test results

* docs(01): complete test-infrastructure phase

- Vitest configured with jsdom, RTL, coverage (INF-1)
- Playwright installed with Chrome configuration (INF-2)
- MSW configured for API mocking (INF-3)
- Directory structure established (INF-4)

All 4 must-haves verified. 2 component tests + 1 E2E test passing.

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

* feat(02-01): add test utilities and expanded MSW handlers

- Create renderWithProviders helper with BrowserRouter wrapper
- Add mock data generators for patterns, playlists, and status
- Expand MSW handlers for all component endpoints (50+ handlers)
- Add pattern, playlist, playback, and table control endpoints
- Create MockWebSocket class for WebSocket testing
- Add browser API mocks (IntersectionObserver, ResizeObserver, canvas)
- Update test setup with browser mocks and data reset

* test(02-01): add component tests for critical pages

BrowsePage tests:
- Pattern listing renders from API
- Search filters patterns by name
- Empty state and error handling
- Pattern selection opens sheet

PlaylistsPage tests:
- Playlist listing and selection
- Create/edit/delete buttons present
- Run playlist triggers API
- Pattern count display

TableControlPage tests:
- Renders all control sections (Home/Stop/Reset/Speed)
- Movement controls call correct APIs
- Speed input submits on Enter and button click
- Dialog trigger has correct aria attributes

NowPlayingBar tests:
- Visibility prop handling
- Props acceptance without crashing
- onClose callback handling

Test utilities:
- Updated setup.ts for WebSocket mocking
- Changed onUnhandledRequest to 'warn' for WebSocket compatibility

* docs(02-01): complete component-tests phase

Tasks completed: 6/6
- Test utilities module with renderWithProviders
- Expanded MSW handlers (50+ endpoints)
- WebSocket mock utility
- Browser API mocks (IntersectionObserver, canvas, localStorage)
- Component tests for BrowsePage, PlaylistsPage, TableControlPage, NowPlayingBar
- Test verification with 42 passing tests

SUMMARY: .planning/phases/02-component-tests/02-01-SUMMARY.md

* chore(03-01): add integration test infrastructure

- Add apiCallLog tracking for verifying API call sequences
- Add resetApiCallLog() to reset between tests
- Add logApiCall() to key handlers (run_theta_rho, playlists, playback)
- Update test setup to reset API call log in beforeEach
- Add renderApp() helper for full app integration tests

* test(03-01): add pattern flow integration tests

- Test browse -> select -> run pattern flow
- Verify API call (run_theta_rho) with correct file parameter
- Test search -> filter -> run filtered pattern
- Verify mock state updates after pattern starts
- Verify API call logging with timestamp and method

* test(03-01): add playlist flow integration tests

- Test view and select playlist flow
- Verify run_playlist API call with correct playlist_name
- Test queue population from playlist files
- Test create playlist via dialog
- Test delete playlist with confirmation
- Fix preview_thr_batch handler to accept file_names parameter

* test(03-01): add playback flow integration tests

- Test pattern playback lifecycle (start/stop)
- Test playlist playback with queue population
- Test stop_execution API resets all playback state
- Verify state transitions (idle -> running -> stopped)
- Verify API call sequences for pattern and playlist runs

* docs(03-01): complete integration tests phase

Tasks completed: 5/5
- Integration test infrastructure (apiCallLog, renderApp)
- Pattern flow tests (6 tests)
- Playlist flow tests (8 tests)
- Playback flow tests (8 tests)

SUMMARY: .planning/phases/03-integration-tests/03-01-SUMMARY.md

* test(04-01): add Playwright API mock utilities

- Add mock data for patterns, playlists, and status
- Implement setupApiMocks() for route interception
- Add getMockStatus/setMockStatus for state verification
- Support pattern, playlist, and playback control endpoints

* test(04-01): add pattern flow E2E tests

- Test pattern list display on browse page
- Test pattern selection opens detail panel
- Test pattern execution triggers running state
- Test search filtering functionality

* test(04-01): add playlist flow E2E tests

- Test playlist list display
- Test playlist selection and execution
- Test navigation between browse and playlists pages

* test(04-01): add table control E2E tests

- Test control page displays buttons
- Test home action can be triggered
- Test navigation bar shows all page links

* test(04-01): update sample spec with infrastructure tests

- Test app loads and renders header
- Test bottom navigation is visible
- Test dark mode toggle presence

* ci(04-01): extend workflow for frontend tests

- Add frontend-test job for Vitest tests
- Add frontend-e2e job for Playwright tests
- Update paths trigger to include frontend files
- Configure Node.js 20 with npm caching
- Install Playwright browsers in CI
- Upload Playwright report on failure

* fix(04-01): fix E2E tests and add WebSocket mocking

- Add WebSocket mocking for /ws/status, /ws/logs, /ws/cache-progress
- Fix Playwright config to use port 5174 to avoid conflicts
- Add static file and additional API endpoint mocks
- Fix pattern-flow test to use exact button name
- Fix playlist-flow test to use title selector for Run button

* docs(04-01): complete E2E and CI phase

Tasks completed: 7/7
- Playwright API mock utilities with WebSocket support
- Pattern flow E2E tests (4 tests)
- Playlist flow E2E tests (3 tests)
- Table control E2E tests (3 tests)
- Updated sample spec (3 tests)
- Extended CI workflow for frontend tests
- Fixed E2E tests and WebSocket mocking

SUMMARY: .planning/phases/04-e2e-ci/04-01-SUMMARY.md

* add gitignore

* feat: add sensor homing failure recovery popup

When sensor homing fails during startup or manual homing:
- Show a blocking popup notifying user to check sensor position
- Provide "Retry Sensor Homing" option
- Provide "Switch to Crash Homing" option
- Connection is not established until user takes action
- Add /recover_sensor_homing API endpoint for recovery

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

* fix: resolve TypeScript errors in test files

- Remove unused 'screen' import from NowPlayingBar.test.tsx
- Add missing IntersectionObserver properties to MockIntersectionObserver
- Use type-only imports for PatternMetadata, PreviewData in handlers.ts
- Use type-only imports for RenderOptions, ReactElement, ReactNode in utils.tsx
- Export IntegrationWrapper to resolve unused variable warning

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

* fix: add GRBL Hold and Alarm state recovery during pattern execution

When the GRBL controller enters Hold state (e.g., from serial byte corruption
triggering '!' command) or Alarm state, the timeout recovery logic now properly
handles these states instead of failing after multiple retries.

- Hold state: sends '~' (cycle start) to resume motion
- Alarm state: sends '$X' (unlock) to clear fault condition
- Both states now detected as valid status responses during recovery

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

* docs: define v3 requirements for touch app CPU optimization

16 requirements across 5 categories:
- QML logging cleanup (6)
- Timer optimization (2)
- WebSocket optimization (2)
- Preview cache (2)
- Verification (4)

Target: <20% idle CPU on Pi 3B+, Pi 4, Pi 5

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

* docs: create v3 roadmap with single-phase CPU optimization

Phase 1: CPU Optimization
- 16 requirements covered (100%)
- QML logging cleanup, timer optimization, WebSocket, preview cache
- Target: <20% idle CPU on Pi 3B+, Pi 4, Pi 5

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

* docs(01): create CPU optimization execution plan

6 tasks covering 16 requirements:
- Remove QML console.log spam (5 tasks)
- Optimize backend timer, WebSocket, preview cache (1 task)

Target: <20% idle CPU on Pi 3B+, Pi 4, Pi 5

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

* perf(01-01): remove MouseArea console.log spam in main.qml

- Remove console.log from onPressed handler
- Remove console.log from onPositionChanged handler
- Remove console.log from onClicked handler
- Keep backend.resetActivityTimer() calls for screen timeout

* perf(01-01): remove remaining debug logs from main.qml

- Remove console.log from onCurrentPageIndexChanged
- Remove console.log from onShouldNavigateToExecutionChanged
- Remove console.log from onExecutionStarted
- Remove console.log from onScreenStateChanged
- Remove console.log from onBackendConnectionChanged
- Remove console.log from onRetryConnection
- Remove console.log from StackLayout.onCompleted
- Remove console.log from onTabClicked

* perf(01-01): remove debug logs from ConnectionStatus.qml

- Remove all console.log statements from color binding
- Remove onSerialConnectionChanged debug handler
- Remove Component.onCompleted debug handler
- Remove onBackendChanged debug handler
- Simplify color binding to single-line expression

* perf(01-01): remove debug logs from ExecutionPage.qml

- Remove onBackendChanged debug handler
- Remove Component.onCompleted debug handler
- Remove onSerialConnectionChanged debug handler
- Remove onConnectionChanged debug handler
- Remove console.log from onExecutionStarted
- Remove console.log from Image source binding
- Remove onStatusChanged debug handler
- Remove onSourceChanged debug handler
- Simplify Image source binding

* perf(01-01): remove debug logs from remaining QML files

TableControlPage.qml:
- Remove console.log from signal handlers

ModernPlaylistPage.qml:
- Remove console.log from onSelectedPlaylistChanged
- Remove Component.onCompleted debug handler
- Remove console.log from playlist execution
- Remove console.log from shuffle toggle
- Remove console.log from run mode and clear pattern handlers

ThemeManager.qml:
- Remove console.log from onDarkModeChanged

BottomNavTab.qml:
- Remove console.log from icon mapping

* perf(01-01): optimize timer, WebSocket, and preview cache in backend.py

Timer optimization (REQ-TIMER-01, REQ-TIMER-02):
- Replace 1-second continuous polling with event-driven single-shot timer
- Timer now fires once after timeout duration, not every second
- _reset_activity_timer restarts timer with full timeout
- Renamed _check_screen_timeout to _screen_timeout_triggered

WebSocket optimization (REQ-WS-01, REQ-WS-02):
- Remove print statements from pattern change detection
- Remove print statements from pause state changes
- Remove print statements from serial connection changes
- Remove print statements from speed changes
- Only emit signals when values actually change (existing behavior)

Preview cache (REQ-CACHE-01, REQ-CACHE-02):
- Add _preview_cache dictionary in __init__
- Check cache before filesystem lookup in _find_pattern_preview
- Cache positive results after successful lookup
- Add _clear_preview_cache method for cache invalidation

* docs(01): complete CPU optimization phase

Phase 1 verified: 6/6 must-haves passed (code inspection)

Code changes complete:
- Removed 50+ console.log statements from QML files
- Event-driven screen timeout (single-shot timer)
- WebSocket signal optimization
- Pattern preview path caching

Pending: Hardware verification on Pi (5 requirements)

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

* chore: stop tracking .planning directory

Files were tracked before .gitignore entry was added.
Removing from index only - files remain locally.

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

* fix(touch): eliminate CPU busy-wait in touch monitor thread

The touch monitor thread was using a 10ms polling loop (100 iterations/sec)
while waiting for touch input, causing 100% CPU usage when screen was off.

Fixed by:
- Using select() with 1-second timeout instead of busy polling
- Removed time.sleep() calls that created artificial delays
- Changed evtest subprocess to binary mode for select() compatibility

This should reduce idle CPU from 100%+ to under 10% when screen is off.

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

* fix(led): turn off LEDs during Still Sands when table goes idle

Extended start_idle_led_timeout() to handle Still Sands LED control:
- Now checks if in scheduled pause period with LED control enabled
- Turns off LEDs instead of showing idle effect during Still Sands
- Added check_still_sands parameter for cases where caller handles logic

This fixes LEDs not turning off when table goes idle during Still Sands
period (pattern stops, skips, completes, or playlist ends).

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

* fix(touch): resolve 100% CPU usage with qasync event loop pattern

Root cause: The QEventLoop + run_forever() pattern in qasync causes
CPU spinning. Combined with hoverEnabled MouseArea generating continuous
events on capacitive touchscreens.

Changes:
- main.py: Use qasync.run() with async_main() pattern instead of
  QEventLoop.run_forever() - this properly yields to Qt event loop
- main.qml: Remove hoverEnabled and onPositionChanged from MouseArea
  to prevent continuous event generation on touch displays
- backend.py: Add 100ms throttling to _reset_activity_timer() as
  defense-in-depth against any remaining event storms
- requirements.txt: Bump qasync to >=0.28.0 for latest fixes

This supersedes the previous fix in c2b1851 which addressed a symptom
(touch monitor thread) rather than the root cause (event loop pattern).

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

* fix(dw): update touch app dependencies during dw update

The previous touch app update logic did a redundant git pull (code is
already part of main repo), but didn't update pip dependencies. This
caused issues when requirements.txt changed (e.g., qasync version bump).

Now properly:
- Updates pip dependencies from requirements.txt
- Restarts the touch app service to apply changes

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

* fix(touch): use proper qasync event wait pattern

The previous fix used a polling loop with app.processEvents() which
caused RuntimeError when aiohttp tasks tried to run concurrently.

The issue: manually calling processEvents() inside an async function
creates reentrant async execution, conflicting with other tasks.

Fix: Use asyncio.Event with app.aboutToQuit signal instead of polling.
qasync handles Qt/asyncio integration internally - we just await the
quit signal without manual event processing.

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

* fix(touch): add proper logging to touch monitoring

- Add logger to backend.py (was using print() which journald doesn't capture)
- Add logging to _screen_timeout_triggered, _start_touch_monitoring
- Simplify _monitor_touch_input with proper select() non-blocking I/O
- Remove unused touch-monitor script code path
- Use binary mode for evtest subprocess to work with select()

This will help diagnose touch-to-wake issues and ensure the flow is
visible in systemd journal logs.

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

* fix(touch): restore QEventLoop pattern for proper QTimer support

The qasync.run() + await quit_event.wait() pattern doesn't properly
process QTimer.singleShot callbacks, which broke touch-to-wake
(the callback to start touch monitoring never fired).

Restored the original QEventLoop + run_forever() pattern which
properly integrates Qt timers. Using qasync 0.28.0 which should
have fixes for the CPU spin issues reported in earlier versions.

This should fix touch-to-wake while maintaining reasonable CPU usage.

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

* revert(touch): revert CPU optimization attempts - none resolved 100% CPU

Reverting to e5265ac state. The following approaches were tried but
did not fix the 100% CPU usage issue:

## Approach 1: Remove QML console.log statements
- Removed 50+ console.log from main.qml, ExecutionPage.qml,
  ConnectionStatus.qml, ModernPlaylistPage.qml, TableControlPage.qml,
  ThemeManager.qml, BottomNavTab.qml
- Result: Did not fix CPU issue

## Approach 2: Remove MouseArea hover events
- Removed hoverEnabled and onPositionChanged from main.qml MouseArea
- Theory: Capacitive touchscreens generate continuous hover events
- Result: Did not fix CPU issue

## Approach 3: Event-driven screen timeout timer
- Changed from 1-second polling loop to single-shot QTimer
- Timer fires once after timeout instead of every second
- Result: Did not fix CPU issue

## Approach 4: Touch monitor thread select() optimization
- Changed from 10ms busy-polling to select() with 1-second timeout
- Used binary mode for evtest subprocess
- Result: Did not fix CPU issue

## Approach 5: qasync event loop pattern changes
- Tried qasync.run() + async_main() instead of QEventLoop.run_forever()
- Bumped qasync to >=0.28.0
- Result: Broke touch-to-wake (QTimer callbacks didn't fire)

## Approach 6: asyncio.Event wait pattern
- Used asyncio.Event with app.aboutToQuit signal
- Avoided manual processEvents() to prevent reentrant async execution
- Result: Did not fix CPU issue

## Approach 7: Activity timer throttling
- Added 100ms throttle to _reset_activity_timer()
- Result: Did not fix CPU issue

## Approach 8: Restored QEventLoop pattern
- Restored QEventLoop + run_forever() for proper QTimer support
- Result: Fixed touch-to-wake but CPU issue remained

Root cause still unknown. Need fresh investigation approach.

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

* fix(touch): resolve 100% CPU on screen timeout with proper thread cleanup

Root cause: Touch monitoring thread had blocking readline() that couldn't
exit when screen woke up, plus missing cleanup in _turn_screen_on().

Changes:
- Add threading.Event stop flag for clean thread signaling
- Use select() with 0.5s timeout for non-blocking reads
- Add thread cleanup in _turn_screen_on() with join timeout
- Add finally block for guaranteed subprocess termination
- Check stop flag during all delays and loops

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

* fix(touch): add CPUQuota limit and fix evtest binary mode buffering

- Add CPUQuota=25% to systemd service as hard limit safety net
- Switch evtest to binary mode to fix select() buffering issues
- Skip evtest initialization output before monitoring for events
- Prevents tight loop during evtest startup device info dump

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

* test(touch): disable touch monitoring to verify 100% CPU hypothesis

Temporarily disable touch monitoring on screen timeout to confirm
that evtest subprocess is the root cause of 100% CPU usage.

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

* feat(touch): add full-page pattern selector and enhance playlist management

Replace the pattern selector popup with a full-page navigation that mirrors
the Browse page layout. Patterns already in the playlist are highlighted with
a blue border and checkmark badge for easy identification.

Key changes:
- Add PatternSelectorPage.qml with grid layout and instant visual feedback
- Update ModernPlaylistPage to navigate to selector instead of popup
- Add playlist creation/deletion buttons and pattern removal functionality
- Enhance search with Enter-to-search pattern across pages
- Add raw pattern tracking for proper API calls

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

* chore: remove dead /get_speed proxy and add missing /upload_theta_rho proxy

- Remove /get_speed proxy entry that was never used by frontend or backend
- Add /upload_theta_rho proxy for pattern file uploads

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

* fix(ui): replace Material Icons delete button with Lucide SVG icon

Material Icons font wasn't loading on Raspberry Pi. Lucide React icons
are SVG-based and work reliably across all devices.

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

* fix(touch): improve text visibility in dark mode

- Fix placeholderText color in ThemeManager (#606060 -> #9a9a9a)
  The old color was nearly invisible on dark backgrounds
- Add placeholderTextColor to all search TextField components
- Add proper text colors to PatternListPage search field
- Add Components import alias to PatternListPage for ThemeManager access

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

* fix(touch): improve dark mode visibility for buttons and text

- Style search close (✕) buttons with proper themed colors
- Add contentItem with ThemeManager colors for visibility
- Add hover/press states using buttonBackgroundHover
- Fix PlaylistPage.qml with full theme support:
  - Background colors, borders, text colors
  - Back button with proper contentItem
- Fix PatternListPage "No patterns found" label color

All flat buttons and text elements now use ThemeManager colors
for proper visibility in both light and dark modes.

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

* fix(ui): replace all Material Icons with Lucide SVG icons in TableControlPage

Material Icons font doesn't load reliably on Raspberry Pi.
Lucide React icons are bundled SVGs that work everywhere.

Replaced ~30 icons including:
- Loading spinners (sync → Loader2)
- Navigation (home, back arrows)
- Actions (power, restart, delete, send)
- Status indicators (check, warning)

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

* fix(ui): replace all Material Icons with Lucide SVG icons in PlaylistsPage

Replaced ~25 icons including:
- Navigation (add, back, close)
- Actions (play, save, edit, delete, shuffle, repeat)
- Content (playlist, folder, music, image)
- Feedback (check, search, sort arrows)

Material Icons font doesn't load reliably on Raspberry Pi.
Lucide React icons are bundled SVGs that work everywhere.

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

* fix(frontend): replace all Material Icons with Lucide SVG icons in Layout

Replace bottom navigation icons and all remaining Material Icons in Layout.tsx
with Lucide React icons. This ensures icons render properly on Raspberry Pi
which has issues loading the Material Icons web font.

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

* fix(touch): fix invisible button icons in QML touch app

- Add color: "white" to ModernControlButton icon Text (was defaulting to black)
- Replace 🗑 emoji with ✕ symbol for delete button (emoji not in Pi fonts)
- Revert unnecessary frontend Lucide icon changes (3 commits)

Fixes Shutdown Pi button and Delete Playlist button visibility on Pi.

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

* fix(touch): remove unsupported power icon, make delete button red

- Remove ⏻ icon from Shutdown Pi button (not in Pi fonts)
- Change delete playlist button to always red (#dc2626)

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

* feat(touch): add iconSize property to ModernControlButton

Allows independent control of icon size vs text size.
Usage: iconSize: 24 (defaults to fontSize + 2 if not set)

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

* fix(touch): increase icon sizes on TableControlPage buttons

Added iconSize: 20 to all buttons with icons for better visibility.

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

* feat(touch): persist playlist settings with new defaults

Settings now saved to touch_settings.json and remembered between sessions:
- Shuffle: default ON
- Run mode: default LOOP
- Pause between patterns: default 3 HOURS
- Clear pattern: default ADAPTIVE

Backend changes:
- Added playlistShuffle, playlistRunMode, playlistClearPattern properties
- Added setPlaylist* slots to save settings
- Updated _load/_save_local_settings for playlist settings

QML changes:
- Radio buttons now reflect loaded settings (checked bindings)
- All controls call backend to persist changes

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

* refactor(touch): replace print statements with proper logging

- Added Python logging module with INFO level by default
- Converted 227 print statements to appropriate log levels:
  - DEBUG: Verbose info (API requests/responses, file searches, screen state)
  - INFO: Normal operations (connections, pattern execution, settings changes)
  - WARNING: Non-critical issues (timeouts, missing resources)
  - ERROR: Failures and exceptions
- Removed emojis from log messages for cleaner output
- Log format: HH:MM:SS [LEVEL] message

To enable debug logging, set: logging.getLogger("DuneWeaver").setLevel(logging.DEBUG)

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

* refactor(touch): remove console.log debug statements from QML

Removed 73 verbose debug logs from QML files:
- main.qml: navigation, screen state, touch events
- ConnectionStatus.qml: backend state logging
- BottomNavTab.qml: icon value logging
- ThemeManager.qml: dark mode toggle
- TableControlPage.qml: serial connection state
- ModernPlaylistPage.qml: playlist operations
- ExecutionPage.qml: image loading, execution signals
- PatternSelectorPage.qml: pattern selection

QML doesn't support log levels, so debug output is removed entirely.
For debugging, re-add console.log statements as needed.

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

* fix(touch): fix syntax error in ModernPlaylistPage.qml

Removed orphaned object literal code left behind from console.log removal.

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

* fix(touch): fix shuffle toggle and improve stop reliability

- Increase stop timeout from 10s to 45s to account for backend's
  idle check (10s lock + 30s idle wait)
- Fix shuffle toggle breaking QML binding by updating backend
  directly instead of assigning to bound property
- Remove ~200 lines of dead Python touch monitoring code since
  QML's global MouseArea handles wake-on-touch

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

* feat(ui): add draggable now playing button and optimize logo uploads

- Now Playing button can be dragged to snap to left, center, or right
- Position persists across sessions via localStorage
- Touch and mouse support with 8px drag threshold

- Increase logo upload limit from 5MB to 10MB
- Auto-optimize uploaded logos: resize to 512px max, convert to WebP
- Typical 95%+ file size reduction for large images
- SVG files pass through unchanged

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

* fix(pwa): iOS safe area handling and PWA icon transparency

- Add env(safe-area-inset-bottom) to main content bottom padding in Layout
  to prevent content from being hidden behind nav bar on iPhone
- Increase base bottom padding from 5rem to 8rem to clear floating Now
  Playing pill button
- Add safe area inset subtraction to viewport height calculations in
  PlaylistsPage, LEDPage, and Now Playing bar expanded state
- Fix PWA icon generation to composite onto solid #0a0a0a background,
  preventing iOS from showing white behind transparent custom logos
- Add maskable purpose icon entries to web manifest for better Android
  adaptive icon support
- Add hard_reset_theta setting for optional machine reset on theta
  normalization

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

* fix(ui): add custom day selector and fix time input overlap in Still Sands

Add day-of-week toggle buttons when "Custom" is selected in the Days
dropdown, and fix time inputs overlapping on mobile by adding overflow
handling and explicit width.

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

* fix(lint): resolve Ruff errors in dune-weaver-touch

- Remove unused imports: QTouchEvent, QMouseEvent, QQmlContext (F401)
- Move load_dotenv() call after all imports to fix E402
- Remove f-string prefix from strings with no placeholders (F541)
- Replace bare except with except Exception (E722)

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

* fix(ui): wrap MQTT buttons on mobile to prevent overflow

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

* fix(lint+test): remove unused imports, fix bare except, update delete button tests

Ruff fixes:
- Remove unused imports: Response, atexit, Tuple, Dict, Any, Union (main.py)
- Remove unused Tuple import (png_cache_manager.py)
- Remove unused Signal import (pattern_model.py, playlist_model.py)
- Replace bare except with except Exception (dune-weaver-touch/main.py)

Test fixes:
- Update playlist delete button selectors to match Trash2 lucide icon
  (component switched from material-icons to lucide-react Trash2)

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

* chore: add pre-commit hook for Ruff lint and frontend tests

- Runs Ruff on staged .py files, blocks commit on lint errors
- Runs vitest on staged .ts/.tsx files, blocks commit on test failures
- Gracefully skips if ruff or node_modules not available
- Auto-installs via npm prepare script
- Shareable hook in scripts/pre-commit

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

* Improve ETA accuracy with rho-weighted time estimation

This is the same change from PR#107, applied to the feature/react-ui branch. I have not tested this branch so please review.

The ETA calculation on the Live Pattern Preview window is erratic, often jumping around by minutes at a time +/-. This is especially noticeable when there are a lot of small moves near the center of the pattern.

This pull request replaces rate-based estimation with an algorithm that:

* Weights coordinates by rho position (center moves are slower)
* Smooths the rate using exponential moving average (alpha=0.02)
* Excludes pause time from calculations
* Requires 100 coords and 10 seconds before showing estimates

The weight formula 1/(rho+0.15) accounts for polar geometry where moves near center cover less linear distance per theta change. In my limited testing this does smooth out the ETA significantly and makes it less prone to large increases. Additional testing and validation is recommended.

This patch was written with the assistance of Claude Code

* feat(ui+led): add log search, fix PWA toast positioning, and respect LED power state on Still Sands exit

Add real-time search filtering to the log drawer (by message/logger),
use CSS env(safe-area-inset-top) for Sonner toasts instead of hardcoded
offset to fix Dynamic Island obstruction in PWA, and prevent LEDs from
auto-powering on after Still Sands when no playing/idle effect is configured
so manually-set effects persist through scheduled pause cycles.

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

* fix(ui): correct "Clear Sideway" typo to "Clear Sideways"

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

* docs: add CONTRIBUTING.md and update README

Add a contributing guide covering dev setup, testing (unit, frontend,
e2e, hardware integration), linting, branch/commit conventions, the
Vite proxy gotcha for new API endpoints, and fork-based PR workflow.
Point contributors to the Discord #dev channel.

Update README with refreshed content, swap hero image to og-image.jpg,
and link the Contributing section to the new in-repo guide.

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

* fix(nginx): add missing endpoint proxies to prevent 405 errors

add_to_queue, restart_connection, controller_restart,
recover_sensor_homing, run_theta_rho_file, download,
check_software_update, and update_software were missing from the
nginx proxy allowlist, causing POST requests to fall through to the
static file handler which returns 405 Method Not Allowed.

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

* feat(ui): add update notification indicator and version link

- Add download icon with blue pulse dot in header when updates are available
- Make "Latest Version" in Settings a clickable link to GitHub releases
- Revert VERSION to 3.5.0 for pre-merge state
- Update docker-compose.yml image tags from :feature-react-ui to :main

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

* update version

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: PxT <paul@droflet.net>
Tuan Nguyen 1 hari lalu
induk
melakukan
5a8083d14a
100 mengubah file dengan 33149 tambahan dan 820 penghapusan
  1. 0 86
      .cursorrules
  2. 44 0
      .dockerignore
  3. 6 0
      .gitattributes
  4. 55 11
      .github/workflows/docker-publish.yml
  5. 151 0
      .github/workflows/test.yml
  6. 18 2
      .gitignore
  7. 0 92
      CLAUDE.md
  8. 187 0
      CONTRIBUTING.md
  9. 6 2
      Dockerfile
  10. 64 154
      README.md
  11. 1 1
      VERSION
  12. 33 16
      docker-compose.yml
  13. 237 305
      dune-weaver-touch/backend.py
  14. 5 1
      dune-weaver-touch/dune-weaver-touch.service
  15. 6 6
      dune-weaver-touch/main.py
  16. 1 1
      dune-weaver-touch/models/pattern_model.py
  17. 18 2
      dune-weaver-touch/models/playlist_model.py
  18. 1 1
      dune-weaver-touch/png_cache_manager.py
  19. 0 2
      dune-weaver-touch/qml/components/BottomNavTab.qml
  20. 0 7
      dune-weaver-touch/qml/components/ConnectionStatus.qml
  21. 3 1
      dune-weaver-touch/qml/components/ModernControlButton.qml
  22. 1 2
      dune-weaver-touch/qml/components/ThemeManager.qml
  23. 0 18
      dune-weaver-touch/qml/main.qml
  24. 3 21
      dune-weaver-touch/qml/pages/ExecutionPage.qml
  25. 77 7
      dune-weaver-touch/qml/pages/ModernPatternListPage.qml
  26. 386 49
      dune-weaver-touch/qml/pages/ModernPlaylistPage.qml
  27. 211 4
      dune-weaver-touch/qml/pages/PatternDetailPage.qml
  28. 4 1
      dune-weaver-touch/qml/pages/PatternListPage.qml
  29. 401 0
      dune-weaver-touch/qml/pages/PatternSelectorPage.qml
  30. 36 16
      dune-weaver-touch/qml/pages/PlaylistPage.qml
  31. 59 4
      dune-weaver-touch/qml/pages/TableControlPage.qml
  32. 24 8
      dw
  33. 6 0
      frontend/.env.example
  34. 24 0
      frontend/.gitignore
  35. 11 0
      frontend/.mcp.json
  36. 29 0
      frontend/Dockerfile
  37. 73 0
      frontend/README.md
  38. 19 0
      frontend/components.json
  39. 231 0
      frontend/e2e/mocks/api.ts
  40. 72 0
      frontend/e2e/pattern-flow.spec.ts
  41. 57 0
      frontend/e2e/playlist-flow.spec.ts
  42. 35 0
      frontend/e2e/sample.spec.ts
  43. 47 0
      frontend/e2e/table-control.spec.ts
  44. 23 0
      frontend/eslint.config.js
  45. 66 0
      frontend/index.html
  46. 15135 0
      frontend/package-lock.json
  47. 82 0
      frontend/package.json
  48. 28 0
      frontend/playwright.config.ts
  49. 5 0
      frontend/postcss.config.js
  50. 28 0
      frontend/src/App.tsx
  51. 79 0
      frontend/src/__tests__/components/NowPlayingBar.test.tsx
  52. 218 0
      frontend/src/__tests__/integration/patternFlow.test.tsx
  53. 274 0
      frontend/src/__tests__/integration/playbackFlow.test.tsx
  54. 257 0
      frontend/src/__tests__/integration/playlistFlow.test.tsx
  55. 176 0
      frontend/src/__tests__/pages/BrowsePage.test.tsx
  56. 222 0
      frontend/src/__tests__/pages/PlaylistsPage.test.tsx
  57. 269 0
      frontend/src/__tests__/pages/TableControlPage.test.tsx
  58. 20 0
      frontend/src/__tests__/sample.test.tsx
  59. 1470 0
      frontend/src/components/NowPlayingBar.tsx
  60. 131 0
      frontend/src/components/ShinyText.tsx
  61. 310 0
      frontend/src/components/TableSelector.tsx
  62. 1935 0
      frontend/src/components/layout/Layout.tsx
  63. 56 0
      frontend/src/components/ui/accordion.tsx
  64. 59 0
      frontend/src/components/ui/alert.tsx
  65. 36 0
      frontend/src/components/ui/badge.tsx
  66. 58 0
      frontend/src/components/ui/button.tsx
  67. 79 0
      frontend/src/components/ui/card.tsx
  68. 70 0
      frontend/src/components/ui/color-picker.tsx
  69. 120 0
      frontend/src/components/ui/dialog.tsx
  70. 22 0
      frontend/src/components/ui/input.tsx
  71. 24 0
      frontend/src/components/ui/label.tsx
  72. 29 0
      frontend/src/components/ui/popover.tsx
  73. 26 0
      frontend/src/components/ui/progress.tsx
  74. 44 0
      frontend/src/components/ui/radio-group.tsx
  75. 125 0
      frontend/src/components/ui/searchable-select.tsx
  76. 158 0
      frontend/src/components/ui/select.tsx
  77. 31 0
      frontend/src/components/ui/separator.tsx
  78. 138 0
      frontend/src/components/ui/sheet.tsx
  79. 26 0
      frontend/src/components/ui/slider.tsx
  80. 48 0
      frontend/src/components/ui/sonner.tsx
  81. 29 0
      frontend/src/components/ui/switch.tsx
  82. 55 0
      frontend/src/components/ui/tabs.tsx
  83. 28 0
      frontend/src/components/ui/tooltip.tsx
  84. 470 0
      frontend/src/contexts/TableContext.tsx
  85. 44 0
      frontend/src/hooks/useBackendConnection.ts
  86. 252 0
      frontend/src/index.css
  87. 246 0
      frontend/src/lib/apiClient.ts
  88. 380 0
      frontend/src/lib/previewCache.ts
  89. 33 0
      frontend/src/lib/types.ts
  90. 23 0
      frontend/src/lib/utils.ts
  91. 25 0
      frontend/src/main.tsx
  92. 1474 0
      frontend/src/pages/BrowsePage.tsx
  93. 768 0
      frontend/src/pages/LEDPage.tsx
  94. 1100 0
      frontend/src/pages/PlaylistsPage.tsx
  95. 2239 0
      frontend/src/pages/SettingsPage.tsx
  96. 912 0
      frontend/src/pages/TableControlPage.tsx
  97. 115 0
      frontend/src/test/mocks/browser.ts
  98. 359 0
      frontend/src/test/mocks/handlers.ts
  99. 4 0
      frontend/src/test/mocks/server.ts
  100. 74 0
      frontend/src/test/mocks/websocket.ts

+ 0 - 86
.cursorrules

@@ -1,86 +0,0 @@
-You are an expert in Python, FastAPI, and scalable API development.
-
-Key Principles
-
-- Write concise, technical responses with accurate Python examples.
-- Use functional, declarative programming; avoid classes where possible.
-- Prefer iteration and modularization over code duplication.
-- Use descriptive variable names with auxiliary verbs (e.g., is_active, has_permission).
-- Use lowercase with underscores for directories and files (e.g., routers/user_routes.py).
-- Favor named exports for routes and utility functions.
-- Use the Receive an Object, Return an Object (RORO) pattern.
-
-Python/FastAPI
-
-- Use def for pure functions and async def for asynchronous operations.
-- Use type hints for all function signatures. Prefer Pydantic models over raw dictionaries for input validation.
-- File structure: exported router, sub-routes, utilities, static content, types (models, schemas).
-- Avoid unnecessary curly braces in conditional statements.
-- For single-line statements in conditionals, omit curly braces.
-- Use concise, one-line syntax for simple conditional statements (e.g., if condition: do_something()).
-
-Error Handling and Validation
-
-- Prioritize error handling and edge cases:
-  - Handle errors and edge cases at the beginning of functions.
-  - Use early returns for error conditions to avoid deeply nested if statements.
-  - Place the happy path last in the function for improved readability.
-  - Avoid unnecessary else statements; use the if-return pattern instead.
-  - Use guard clauses to handle preconditions and invalid states early.
-  - Implement proper error logging and user-friendly error messages.
-  - Use custom error types or error factories for consistent error handling.
-
-Dependencies
-
-- FastAPI
-- Pydantic v2
-- Async database libraries like asyncpg or aiomysql
-
-FastAPI-Specific Guidelines
-
-- Use functional components (plain functions) and Pydantic models for input validation and response schemas.
-- Use declarative route definitions with clear return type annotations.
-- Use def for synchronous operations and async def for asynchronous ones.
-- Minimize @app.on_event("startup") and @app.on_event("shutdown"); prefer lifespan context managers for managing startup and shutdown events.
-- Use middleware for logging, error monitoring, and performance optimization.
-- Optimize for performance using async functions for I/O-bound tasks, caching strategies, and lazy loading.
-- Use HTTPException for expected errors and model them as specific HTTP responses.
-- Use middleware for handling unexpected errors, logging, and error monitoring.
-- Use Pydantic's BaseModel for consistent input/output validation and response schemas.
-
-Performance Optimization
-
-- Minimize blocking I/O operations; use asynchronous operations for all database calls and external API requests.
-- Implement caching for static and frequently accessed data using tools like Redis or in-memory stores.
-- Optimize data serialization and deserialization with Pydantic.
-- Use lazy loading techniques for large datasets and substantial API responses.
-
-Key Conventions
-
-1. Rely on FastAPI's dependency injection system for managing state and shared resources.
-2. Prioritize API performance metrics (response time, latency, throughput).
-3. Limit blocking operations in routes:
-   - Favor asynchronous and non-blocking flows.
-   - Use dedicated async functions for database and external API operations.
-   - Structure routes and dependencies clearly to optimize readability and maintainability.
-
-Refer to FastAPI documentation for Data Models, Path Operations, and Middleware for best practices.
-
-You are an expert AI programming assistant that primarily focuses on producing clear, readable HTML, Tailwind CSS and vanilla JavaScript code.
-
-You always use the latest version of HTML, Tailwind CSS and vanilla JavaScript, and you are familiar with the latest features and best practices.
-
-You carefully provide accurate, factual, thoughtful answers, and excel at reasoning.
-
-- Follow the user's requirements carefully & to the letter.
-- Confirm, then write code!
-- Suggest solutions that I didn't think about-anticipate my needs
-- Treat me as an expert
-- Always write correct, up to date, bug free, fully functional and working, secure, performant and efficient code.
-- Focus on readability over being performant.
-- Fully implement all requested functionality.
-- Leave NO todo's, placeholders or missing pieces.
-- Be concise. Minimize any other prose.
-- Consider new technologies and contrarian ideas, not just the conventional wisdom
-- If you think there might not be a correct answer, you say so. If you do not know the answer, say so instead of guessing.
-- If I ask for adjustments to code, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make. 

+ 44 - 0
.dockerignore

@@ -0,0 +1,44 @@
+# Git
+.git
+.gitignore
+
+# Node modules (built in Docker)
+frontend/node_modules
+node_modules
+
+# Build outputs (built in Docker)
+static/dist
+
+# Development files
+.env
+.env.*
+*.log
+.DS_Store
+
+# IDE
+.vscode
+.idea
+*.swp
+*.swo
+
+# Python cache
+__pycache__
+*.pyc
+*.pyo
+.pytest_cache
+.mypy_cache
+
+# Documentation
+*.md
+!README.md
+
+# Docker files (not needed in image)
+docker-compose*.yml
+Dockerfile*
+
+# Test files
+tests/
+*.test.*
+
+# Dune Weaver Touch (separate app)
+dune-weaver-touch/

+ 6 - 0
.gitattributes

@@ -0,0 +1,6 @@
+# Ensure shell scripts always have LF line endings
+*.sh text eol=lf
+
+# Keep these as LF regardless of platform
+Dockerfile text eol=lf
+nginx.conf text eol=lf

+ 55 - 11
.github/workflows/docker-publish.yml

@@ -5,17 +5,24 @@ on:
     branches: [ "main" ]
     paths:
       - 'Dockerfile'
+      - 'frontend/Dockerfile'
+      - 'frontend/**'
       - 'requirements.txt'
       - 'VERSION'
-  # Allow manual trigger for when you need to rebuild anyway
+      - '**.py'
+  # Allow manual trigger on any branch
   workflow_dispatch:
-    
+    inputs:
+      branch:
+        description: 'Branch to build from'
+        required: false
+        default: ''
+
 env:
   REGISTRY: ghcr.io
-  IMAGE_NAME: ${{ github.repository }}
 
 jobs:
-  build:
+  build-backend:
     runs-on: ubuntu-latest
     permissions:
       contents: read
@@ -30,7 +37,6 @@ jobs:
         uses: docker/setup-buildx-action@v3
 
       - name: Log into registry ${{ env.REGISTRY }}
-        if: github.event_name != 'pull_request'
         uses: docker/login-action@v3
         with:
           registry: ${{ env.REGISTRY }}
@@ -41,16 +47,54 @@ jobs:
         id: meta
         uses: docker/metadata-action@v5
         with:
-          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+          images: ${{ env.REGISTRY }}/${{ github.repository }}
 
-      - name: Build and push Docker image for Raspberry Pi
-        id: build-and-push
+      - name: Build and push backend image
         uses: docker/build-push-action@v5
         with:
           context: .
-          push: ${{ github.event_name != 'pull_request' }}
+          push: true
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+          platforms: linux/amd64,linux/arm64
+          cache-from: type=gha,scope=backend
+          cache-to: type=gha,mode=max,scope=backend
+
+  build-frontend:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      packages: write
+      id-token: write
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+
+      - name: Log into registry ${{ env.REGISTRY }}
+        uses: docker/login-action@v3
+        with:
+          registry: ${{ env.REGISTRY }}
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Extract Docker metadata
+        id: meta
+        uses: docker/metadata-action@v5
+        with:
+          images: ${{ env.REGISTRY }}/${{ github.repository }}-frontend
+
+      - name: Build and push frontend image
+        uses: docker/build-push-action@v5
+        with:
+          context: ./frontend
+          file: ./frontend/Dockerfile
+          push: true
           tags: ${{ steps.meta.outputs.tags }}
           labels: ${{ steps.meta.outputs.labels }}
           platforms: linux/amd64,linux/arm64
-          cache-from: type=gha
-          cache-to: type=gha,mode=max
+          cache-from: type=gha,scope=frontend
+          cache-to: type=gha,mode=max,scope=frontend

+ 151 - 0
.github/workflows/test.yml

@@ -0,0 +1,151 @@
+name: Tests
+
+on:
+  push:
+    branches: [ "main", "feature/*" ]
+    paths:
+      - '**.py'
+      - 'requirements*.txt'
+      - 'pyproject.toml'
+      - 'tests/**'
+      - 'frontend/**'
+      - '.github/workflows/test.yml'
+  pull_request:
+    branches: [ "main" ]
+    paths:
+      - '**.py'
+      - 'requirements*.txt'
+      - 'pyproject.toml'
+      - 'tests/**'
+      - 'frontend/**'
+      - '.github/workflows/test.yml'
+  # Allow manual trigger
+  workflow_dispatch:
+
+jobs:
+  # Backend tests
+  test:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Set up Python 3.11
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
+          cache: 'pip'
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+          pip install -r requirements-dev.txt
+
+      - name: Run unit tests with coverage
+        env:
+          CI: true
+        run: |
+          pytest tests/unit/ -v --cov=modules --cov=main --cov-report=xml --cov-report=term-missing
+
+      - name: Upload coverage reports to Codecov
+        uses: codecov/codecov-action@v4
+        if: always()
+        with:
+          file: ./coverage.xml
+          fail_ci_if_error: false
+          verbose: true
+        env:
+          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+
+  lint:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Set up Python 3.11
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
+          cache: 'pip'
+
+      - name: Install linting dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install ruff
+
+      - name: Run Ruff linter
+        run: |
+          ruff check --output-format=github .
+        continue-on-error: true
+
+  # Frontend unit/integration tests
+  frontend-test:
+    runs-on: ubuntu-latest
+
+    defaults:
+      run:
+        working-directory: frontend
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: '20'
+          cache: 'npm'
+          cache-dependency-path: frontend/package-lock.json
+
+      - name: Install dependencies
+        run: npm ci
+
+      - name: Run Vitest tests
+        run: npm test
+
+      - name: Run Vitest with coverage
+        run: npm run test:coverage
+        continue-on-error: true
+
+  # Frontend E2E tests
+  frontend-e2e:
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+
+    defaults:
+      run:
+        working-directory: frontend
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: '20'
+          cache: 'npm'
+          cache-dependency-path: frontend/package-lock.json
+
+      - name: Install dependencies
+        run: npm ci
+
+      - name: Install Playwright browsers
+        run: npx playwright install --with-deps chromium
+
+      - name: Run Playwright tests
+        run: npm run test:e2e
+        env:
+          CI: true
+
+      - name: Upload Playwright report
+        uses: actions/upload-artifact@v4
+        if: failure()
+        with:
+          name: playwright-report
+          path: frontend/playwright-report/
+          retention-days: 7

+ 18 - 2
.gitignore

@@ -4,8 +4,17 @@ __pycache__/
 *.pyo
 .env
 .idea
-*.json
+# Ignore state and data JSON files, but not package.json
+state.json
+playlists.json
+metadata_cache.json
+tsconfig.json
 *.jsonl
+!package.json
+!package-lock.json
+
+# Touch app config files
+dune-weaver-touch/*.json
 .venv/
 patterns/cached_svg/
 patterns/cached_images/custom_*
@@ -13,10 +22,17 @@ patterns/cached_images/custom_*
 .DS_Store
 # Node.js and build files
 node_modules/
+.vite/
 *.log
 *.png
 # Custom branding assets (user uploads)
 static/custom/*
 !static/custom/.gitkeep
 .claude/
-static/dist/
+static/dist/
+.planning/
+.coverage
+# Frontend test artifacts
+frontend/coverage/
+frontend/playwright-report/
+frontend/test-results/

+ 0 - 92
CLAUDE.md

@@ -1,92 +0,0 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Development Commands
-
-### CSS/Frontend Development
-- `npm run dev` or `npm run watch-css` - Watch mode for Tailwind CSS development 
-- `npm run build-css` - Build and minify Tailwind CSS for production
-
-### Python Application
-- `python main.py` - Start the FastAPI server on port 8080
-- The application uses uvicorn internally and runs on 0.0.0.0:8080
-
-## Architecture Overview
-
-Dune Weaver is a web-controlled kinetic sand table system with both hardware and software components:
-
-### Core Application Structure
-- **FastAPI backend** (`main.py`) - Main web server with REST APIs and WebSocket support
-- **Modular design** with organized modules:
-  - `modules/connection/` - Serial and WebSocket connection management for hardware
-  - `modules/core/` - Core business logic (patterns, playlists, state management, caching)
-  - `modules/led/` - WLED integration for lighting effects  
-  - `modules/mqtt/` - MQTT integration capabilities
-  - `modules/update/` - Software update management
-
-### Coordinate System
-The sand table uses **polar coordinates (θ, ρ)** instead of traditional Cartesian:
-- **Theta (θ)**: Angular position in degrees (0-360°)
-- **Rho (ρ)**: Radial distance from center (0.0 at center, 1.0 at perimeter)
-
-### Pattern System
-- **Pattern files**: `.thr` files in `patterns/` directory containing theta-rho coordinate pairs
-- **Pattern format**: Each line contains `theta rho` values separated by space, comments start with `#`
-- **Cached previews**: WebP images generated in `patterns/cached_images/` for UI display
-- **Custom patterns**: User uploads stored in `patterns/custom_patterns/`
-
-### Hardware Communication
-- Supports both **Serial** and **WebSocket** connections to hardware controllers
-- **ESP32** or **Arduino** boards control stepper motors
-- **Homing system**: Crash-homing method without limit switches
-- **Hardware coupling**: Angular and radial axes are mechanically coupled, requiring software compensation
-
-### State Management
-- Global state managed in `modules/core/state.py`
-- Persistent state saved to `state.json`
-- Real-time status updates via WebSocket (`/ws/status`)
-
-### Key Features
-- **Playlist system**: Sequential pattern execution with timing control
-- **WLED integration**: Synchronized lighting effects during pattern execution
-- **Image caching**: Automatic preview generation for all patterns
-- **Pattern execution control**: Play, pause, resume, stop, skip functionality
-- **MQTT support**: External system integration
-- **Software updates**: Git-based update system
-
-## Important Implementation Notes
-
-### Cursor Rules Integration
-The project follows FastAPI best practices from `.cursorrules`:
-- Use functional programming patterns where possible
-- Implement proper error handling with early returns
-- Use Pydantic models for request/response validation
-- Prefer async operations for I/O-bound tasks
-- Follow proper dependency injection patterns
-
-### Hardware Constraints
-- Angular axis movement affects radial position due to mechanical coupling
-- Software compensates for this coupling automatically
-- No physical limit switches - relies on crash-homing for position reference
-
-### Threading and Concurrency
-- Uses asyncio for concurrent operations
-- Pattern execution runs in background tasks
-- Thread-safe connection management with locks
-- WebSocket connections for real-time status updates
-
-## Testing and Development
-
-### Running the Application
-1. Install Python dependencies: `pip install -r requirements.txt`
-2. Install Node dependencies: `npm install`
-3. Build CSS: `npm run build-css`
-4. Start server: `python main.py`
-
-### File Structure Conventions
-- Pattern files in `patterns/` (can have subdirectories)
-- Static assets in `static/` (CSS, JS, images)
-- HTML templates in `templates/`
-- Configuration files in root directory
-- Firmware configurations in `firmware/` subdirectories for different hardware versions

+ 187 - 0
CONTRIBUTING.md

@@ -0,0 +1,187 @@
+# Contributing to Dune Weaver
+
+Thanks for your interest in contributing to Dune Weaver! Whether it's a bug fix, a new feature, or improved docs, every contribution helps make kinetic sand tables more accessible.
+
+If you have questions or ideas, join the [#dev channel on Discord](https://discord.com/channels/864079106424832021/1329553521032560791) or browse the existing [Issues](https://github.com/tuanchris/dune-weaver/issues).
+
+## Development Setup
+
+### Prerequisites
+
+- Python 3.10+
+- Node.js 18+ and npm
+- Git
+
+### Clone and install
+
+```bash
+git clone https://github.com/tuanchris/dune-weaver.git
+cd dune-weaver
+
+# Python dependencies (use nonrpi on a dev machine, full requirements.txt on a Raspberry Pi)
+pip install -r requirements-nonrpi.txt
+
+# Testing/dev extras
+pip install -r requirements-dev.txt
+
+# Frontend + root dependencies
+npm install
+cd frontend && npm install && cd ..
+```
+
+### Start the dev server
+
+```bash
+npm run dev
+```
+
+This runs **both** servers concurrently:
+
+| Service  | URL                    | Notes                              |
+| -------- | ---------------------- | ---------------------------------- |
+| Frontend | `http://localhost:5173` | Vite dev server with hot reload   |
+| Backend  | `http://localhost:8080` | FastAPI (proxied by Vite in dev)  |
+
+Open `http://localhost:5173` in your browser. Vite proxies all API and WebSocket requests to the backend automatically.
+
+## Project Structure
+
+```
+dune-weaver/
+├── frontend/           # React 19 + TypeScript + Vite
+│   ├── src/
+│   │   ├── pages/      # Route-level page components
+│   │   ├── components/ # Shared UI and feature components
+│   │   ├── hooks/      # Custom React hooks
+│   │   ├── lib/        # Utilities and API client
+│   │   └── contexts/   # React contexts (multi-table, etc.)
+│   └── package.json
+├── modules/            # Backend modules
+│   ├── core/           # Pattern/playlist managers, state, scheduling
+│   ├── connection/     # Serial and WebSocket hardware communication
+│   ├── led/            # WLED integration
+│   └── mqtt/           # MQTT integration
+├── patterns/           # .thr pattern files (theta-rho coordinates)
+├── main.py             # FastAPI application entry point
+└── package.json        # Root scripts (dev, build, prepare)
+```
+
+## Running Tests
+
+### Backend (pytest)
+
+```bash
+pytest tests/unit/ -v
+pytest tests/unit/ -v --cov       # with coverage
+```
+
+### Frontend (Vitest)
+
+```bash
+cd frontend && npm test           # single run
+cd frontend && npm run test:watch # watch mode
+cd frontend && npm run test:coverage
+```
+
+### End-to-end (Playwright)
+
+```bash
+cd frontend && npx playwright install chromium   # first time only
+cd frontend && npm run test:e2e
+cd frontend && npm run test:e2e:ui               # interactive UI mode
+```
+
+### Hardware Integration Tests (pytest)
+
+Integration tests exercise real hardware — serial connections, homing, movement, and pattern execution. They are **skipped by default** and in CI.
+
+```bash
+# Run all integration tests (hardware must be connected via USB)
+pytest tests/integration/ --run-hardware -v
+
+# Run a specific suite
+pytest tests/integration/test_hardware.py --run-hardware -v
+
+# Show live output (useful for watching motor activity)
+pytest tests/integration/ --run-hardware -v -s
+```
+
+| Test file | What it covers | Approx. duration |
+| --------- | -------------- | ---------------- |
+| `test_hardware.py` | Serial connection, homing, movement, pattern execution | ~5–10 min |
+| `test_playback_controls.py` | Pause, resume, stop, skip, speed control | ~5 min |
+| `test_playlist.py` | Playlist modes, clear patterns, state updates | ~5 min |
+
+> **Safety:** These tests physically move the table. Make sure the ball path is clear and the table is powered on before running them.
+
+## Linting
+
+### Python — Ruff
+
+```bash
+ruff check .                # check for issues
+ruff check --fix .          # auto-fix what it can
+```
+
+### Frontend — ESLint
+
+```bash
+cd frontend && npm run lint
+```
+
+### Pre-commit hook
+
+A pre-commit hook runs Ruff on staged Python files and Vitest on staged TypeScript files automatically. It's installed when you run `npm install` at the repo root (via the `prepare` script). You can also install it manually:
+
+```bash
+cp scripts/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
+```
+
+## Branch and Commit Conventions
+
+### Branching
+
+Create branches from `main` using these prefixes:
+
+- `feature/` — new functionality (e.g., `feature/pattern-editor`)
+- `fix/` — bug fixes (e.g., `fix/playlist-reorder`)
+- `chore/` — maintenance, tooling, docs (e.g., `chore/update-deps`)
+
+### Commit messages
+
+We follow [Conventional Commits](https://www.conventionalcommits.org/):
+
+```
+feat(ui): add dark mode toggle to settings page
+fix(backend): prevent crash when serial port disconnects
+chore: update Python dependencies
+```
+
+## Adding API Endpoints
+
+When you add a new backend endpoint, you **must** also register its path in the Vite proxy so it works during development:
+
+1. Add the route in `main.py` (or the appropriate module).
+2. Open `frontend/vite.config.ts` and add the endpoint path to the `server.proxy` object:
+   ```ts
+   '/my_new_endpoint': 'http://localhost:8080',
+   ```
+3. Restart the Vite dev server (`npm run dev`).
+
+> **Tip:** Endpoints under the `/api` prefix are already proxied by a single rule, so prefer using `/api/...` paths for new routes.
+
+## Submitting a Pull Request
+
+1. **Fork** the repository and clone your fork.
+2. Create a branch from `main` (see [Branch and Commit Conventions](#branch-and-commit-conventions)).
+3. Make sure all tests pass and linting is clean:
+   ```bash
+   ruff check .
+   cd frontend && npm run lint && npm test && cd ..
+   pytest tests/unit/ -v
+   ```
+4. Push your branch to your fork and open a PR against `main` on the upstream repo.
+5. Fill in a clear description of **what** changed and **why**.
+6. CI will run backend tests, frontend tests, E2E tests, and Ruff lint automatically.
+
+Thanks for helping make Dune Weaver better!

+ 6 - 2
Dockerfile

@@ -1,4 +1,5 @@
-FROM --platform=$TARGETPLATFORM python:3.11-slim-bookworm
+# Backend-only Dockerfile
+FROM python:3.11-slim-bookworm
 
 # Faster, repeatable builds
 ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -30,7 +31,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     && apt-get purge -y gcc g++ make scons \
     && rm -rf /var/lib/apt/lists/*
 
+# Copy backend code
 COPY . .
 
+# Expose backend API port
 EXPOSE 8080
-CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
+
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

+ 64 - 154
README.md

@@ -1,192 +1,102 @@
 # Dune Weaver
 
-[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/tuanchris)
+[![Patreon](https://img.shields.io/badge/Patreon-F96854?style=for-the-badge&logo=patreon&logoColor=white)](https://www.patreon.com/cw/DuneWeaver)
 
-![Dune Weaver Gif](./static/IMG_7404.gif)
+![Dune Weaver](./static/og-image.jpg)
 
-Dune Weaver is a web-controlled kinetic sand table system that creates mesmerizing patterns in sand using a steel ball guided by magnets beneath the surface. This project seamlessly integrates hardware control with a modern web interface, featuring real-time pattern execution, playlist management, and synchronized lighting effects.
+**An open-source kinetic sand art table that creates mesmerizing patterns using a ball controlled by precision motors.**
 
-## 🌟 Key Features
+## Features
 
-- **Web-Based Control Interface**: Modern, responsive web UI for pattern management and table control
-- **Real-Time Pattern Execution**: Live preview and control of pattern drawing with progress tracking
-- **Playlist System**: Queue multiple patterns for continuous execution
-- **WLED Integration**: Synchronized lighting effects during pattern execution
-- **Pattern Library**: Browse, upload, and manage custom patterns with preview generation
-- **Polar Coordinate System**: Specialized θ-ρ coordinate system optimized for radial designs
-- **Auto-Update System**: GitHub-integrated version management with update notifications
+- **Modern React UI** — A responsive, touch-friendly web interface that installs as a PWA on any device
+- **Pattern Library** — Browse, upload, and manage hundreds of sand patterns with auto-generated previews
+- **Live Preview** — Watch your pattern come to life in real time with progress tracking
+- **Playlists** — Queue up multiple patterns with configurable pause times and automatic clearing between drawings
+- **LED Integration** — Synchronized lighting via native DW LEDs or WLED, with separate idle, playing, and scheduled modes
+- **Still Sands Scheduling** — Set quiet hours so the table pauses automatically on your schedule
+- **Multi-Table Support** — Control several sand tables from a single interface
+- **Home Assistant Integration** — Connect to Home Assistant or other home automation systems using MQTT
+- **Auto-Updates** — One-click software updates right from the settings page
+- **Add-Ons** — Optional [Desert Compass](https://duneweaver.com/docs) for auto-homing and [DW Touch](https://duneweaver.com/docs) for dedicated touchscreen control
 
-### **📚 Complete Documentation: [Dune Weaver Wiki](https://github.com/tuanchris/dune-weaver/wiki)**
+## How It Works
 
----
-
-The Dune Weaver comes in two versions:
-
-1. **Small Version (Mini Dune Weaver)**:
-   - Uses two **28BYJ-48 DC 5V stepper motors**.
-   - Controlled via **ULN2003 motor drivers**.
-   - Powered by an **ESP32**.
-
-2. **Larger Version (Dune Weaver)**:
-   - Uses two **NEMA 17 or NEMA 23 stepper motors**.
-   - Controlled via **TMC2209 or DRV8825 motor drivers**.
-   - Powered by an **Arduino UNO with a CNC shield**.
-
-Each version operates similarly but differs in power, precision, and construction cost.
-
-The sand table consists of two main bases:
-1. **Lower Base**: Houses all the electronic components, including motor drivers, and power connections.
-2. **Upper Base**: Contains the sand and the marble, which is moved by a magnet beneath.
-
-Both versions of the table use two stepper motors:
-
-- **Radial Axis Motor**: Controls the in-and-out movement of the arm.
-- **Angular Axis Motor**: Controls the rotational movement of the arm.
+The system is split across two devices connected via USB:
 
-The small version uses **28BYJ-48 motors** driven by **ULN2003 drivers**, while the larger version uses **NEMA 17 or NEMA 23 motors** with **TMC2209 or DRV8825 drivers**.: Controls the in-and-out movement of the arm.
-- **Angular Axis Motor**: Controls the rotational movement of the arm.
+```
+┌─────────────────┐         USB          ┌─────────────────┐
+│  Raspberry Pi   │ ◄──────────────────► │  DLC32 / ESP32  │
+│  (Dune Weaver   │                      │  (FluidNC)      │
+│   Backend)      │                      │                 │
+└─────────────────┘                      └─────────────────┘
+        │                                        │
+        │ Wi-Fi                                  │ Motor signals
+        ▼                                        ▼
+   Web Browser                            Stepper Motors
+   (Control UI)                           (Theta & Rho)
+```
 
-Each motor is connected to a motor driver that dictates step and direction. The motor drivers are, in turn, connected to the ESP32 board, which serves as the system's main controller. The entire table is powered by a single USB cable attached to the ESP32.
+The **Raspberry Pi** runs the web UI, manages pattern files and playlists, and converts patterns into G-code. The **DLC32/ESP32** running [FluidNC](https://github.com/bdring/FluidNC) firmware receives that G-code and drives the stepper motors in real time.
 
----
+## Hardware
 
-## Coordinate System
-Unlike traditional CNC machines that use an **X-Y coordinate system**, the sand table operates on a **theta-rho (θ, ρ) coordinate system**:
-- **Theta (θ)**: Represents the angular position of the arm, with **2π radians (360 degrees) for one full revolution**.
-- **Rho (ρ)**: Represents the radial distance of the marble from the center, with **0 at the center and 1 at the perimeter**.
+Dune Weaver comes in three premium models:
 
-This system allows the table to create intricate radial designs that differ significantly from traditional Cartesian-based CNC machines.
+| | [DW Pro](https://duneweaver.com/products/dwp) | [DW Mini Pro](https://duneweaver.com/products/dwmp) | [DW Gold](https://duneweaver.com/products/dwg) |
+|---|---|---|---|
+| **Size** | 75 cm (29.5") | 25 cm (10") | 45 cm (17") |
+| **Enclosure** | IKEA VITTSJÖ table | IKEA BLANDA bowl | IKEA TORSJÖ side table |
+| **Motors** | 2 × NEMA 17 | 2 × NEMA 17 | 2 × NEMA 17 |
+| **Controller** | DLC32 | DLC32 | DLC32 |
+| **Best for** | Living rooms | Desktops | Side-table accent piece |
 
----
+All models run the same software with [FluidNC](https://github.com/bdring/FluidNC) firmware — only the mechanical parts differ.
 
-## Homing and Position Tracking
-Unlike conventional CNC machines, the sand table **does not have a limit switch** for homing. Instead, it uses a **crash-homing method**:
-1. Upon power-on, the radial axis moves inward to its physical limit, ensuring the marble is positioned at the center.
-2. The software then assumes this as the **home position (0,0 in polar coordinates)**.
-3. The system continuously tracks all executed coordinates to maintain an accurate record of the marble’s position.
+Free 3D-printable models on MakerWorld: [DW OG](https://makerworld.com/en/models/841332-dune-weaver-a-3d-printed-kinetic-sand-table#profileId-787553) · [DW Mini](https://makerworld.com/en/models/896314-mini-dune-weaver-not-your-typical-marble-run#profileId-854412)
 
----
-
-## Mechanical Constraints and Software Adjustments
-### Coupled Angular and Radial Motion
-Due to the **hardware design choice**, the angular axis **does not move independently**. This means that when the angular motor moves one full revolution, the radial axis **also moves slightly**—either inwards or outwards, depending on the rotation direction.
+> **Build guides, BOMs, and wiring diagrams** are in the [Dune Weaver Docs](https://duneweaver.com/docs).
 
-To counteract this behavior, the software:
-- Monitors how many revolutions the angular axis has moved.
-- Applies an offset to the radial axis to compensate for unintended movements.
+## Quick Start
 
-This correction ensures that the table accurately follows the intended path without accumulating errors over time.
+The fastest way to get running on a Raspberry Pi:
 
----
-
-Each pattern file consists of lines with theta and rho values (in degrees and normalized units, respectively), separated by a space. Comments start with #.
-
-Example:
-
-```
-# Example pattern
-0 0.5
-90 0.7
-180 0.5
-270 0.7
+```bash
+curl -fsSL https://raw.githubusercontent.com/tuanchris/dune-weaver/main/setup-pi.sh | bash
 ```
 
-## API Endpoints
-
-The project exposes RESTful APIs for various actions. Here are some key endpoints:
- • List Serial Ports: /list_serial_ports (GET)
- • Connect to Serial: /connect (POST)
- • Upload Pattern: /upload_theta_rho (POST)
- • Run Pattern: /run_theta_rho (POST)
- • Stop Execution: /stop_execution (POST)
-
-## 🚀 Quick Start
-
-1. **Clone the repository**:
-   ```bash
-   git clone https://github.com/tuanchris/dune-weaver.git
-   cd dune-weaver
-   ```
+This installs Docker, clones the repo, and starts the application. Once it finishes, open **http://\<hostname\>.local** in your browser.
 
-2. **Install dependencies**:
+For full deployment options (Docker, manual install, development setup, Windows, and more), see the **[Deploying Backend](https://duneweaver.com/docs/deploying-backend)** guide.
 
-   **On Raspberry Pi (full hardware support):**
-   ```bash
-   pip install -r requirements.txt
-   npm install
-   ```
+### Polar coordinates
 
-   **On Windows/Linux/macOS (development/testing):**
-   ```bash
-   pip install -r requirements-nonrpi.txt
-   npm install
-   ```
-   > **Note**: The development installation excludes Raspberry Pi GPIO libraries. The application will run fully but DW LED features will be disabled. WLED integration will still work.
+The sand table uses **polar coordinates** instead of the typical X-Y grid:
 
-3. **Build CSS**:
-   ```bash
-   npm run build-css
-   ```
+- **Theta (θ)** — the angle in radians (2π = one full revolution)
+- **Rho (ρ)** — the distance from the center (0.0 = center, 1.0 = edge)
 
-4. **Start the application**:
-   ```bash
-   python main.py
-   ```
-
-5. **Open your browser** and navigate to `http://localhost:8080`
-
-## 📁 Project Structure
+Patterns are stored as `.thr` text files — one coordinate pair per line:
 
 ```
-dune-weaver/
-├── main.py                     # FastAPI application entry point
-├── VERSION                     # Current software version
-├── modules/
-│   ├── connection/             # Serial & WebSocket connection management
-│   ├── core/                   # Core business logic
-│   │   ├── cache_manager.py    # Pattern preview caching
-│   │   ├── pattern_manager.py  # Pattern file handling
-│   │   ├── playlist_manager.py # Playlist system
-│   │   ├── state.py           # Global state management
-│   │   └── version_manager.py  # GitHub version checking
-│   ├── led/                    # WLED integration
-│   ├── mqtt/                   # MQTT support
-│   └── update/                 # Software update management
-├── patterns/                   # Pattern files (.thr format)
-├── static/                     # Web assets (CSS, JS, images)
-├── templates/                  # HTML templates
-├── firmware/                   # Hardware controller firmware
-└── requirements.txt            # Python dependencies
+# A simple four-point star
+0.000 0.5
+1.571 0.7
+3.142 0.5
+4.712 0.7
 ```
 
-## 🔧 Configuration
-
-The application uses several configuration methods:
-- **Environment Variables**: `LOG_LEVEL`, connection settings
-- **State Persistence**: Settings saved to `state.json`
-- **Version Management**: Automatic GitHub release checking
-
-## 🌐 API Endpoints
+The same pattern file works on any table size thanks to the normalized coordinate system. You can create patterns by hand, generate them with code, or browse the built-in library.
 
-Core API endpoints for integration:
+## Documentation
 
-- **Pattern Management**: `/upload_theta_rho`, `/list_theta_rho_files`
-- **Execution Control**: `/run_theta_rho`, `/pause_execution`, `/stop_execution`
-- **Hardware Control**: `/connect`, `/send_home`, `/set_speed`
-- **Version Management**: `/api/version`, `/api/update`
-- **Real-time Updates**: WebSocket at `/ws/status`
+Full setup instructions, hardware assembly, firmware flashing, and advanced configuration:
 
-## 🤝 Contributing
+**[Dune Weaver Docs](https://duneweaver.com/docs)**
 
-We welcome contributions! Please check out our [Contributing Guide](https://github.com/tuanchris/dune-weaver/wiki/Contributing) for details.
+## Contributing
 
-## 📖 Documentation
-
-For detailed setup instructions, hardware assembly, and advanced configuration:
-
-**🔗 [Visit the Complete Wiki](https://github.com/tuanchris/dune-weaver/wiki)**
+We welcome contributions! See the [Contributing Guide](CONTRIBUTING.md) for how to get started.
 
 ---
 
-**Happy sand drawing with Dune Weaver! ✨**
-
+**Happy sand drawing!**

+ 1 - 1
VERSION

@@ -1 +1 @@
-3.6.0
+4.0.0

+ 33 - 16
docker-compose.yml

@@ -1,7 +1,9 @@
 services:
-  dune-weaver:
-    build: . # Uncomment this if you need to build
-    image: ghcr.io/tuanchris/dune-weaver:main # Use latest production image
+  frontend:
+    build:
+      context: ./frontend
+      dockerfile: Dockerfile
+    image: ghcr.io/tuanchris/dune-weaver-frontend:main
     restart: always
     cap_add:
       - SYS_NICE  # Enable real-time thread priority for smooth UART communication
@@ -9,23 +11,38 @@ services:
     #   - "8080:8080" # Map port 8080 of the container to 8080 of the host (access via http://localhost:8080)
     network_mode: "host" # Use host network for device access
     volumes:
-      - .:/app  # Mount entire app for development
-      # Mount Docker socket to allow container to restart itself
+      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+    depends_on:
+      - backend
+    container_name: dune-weaver-frontend
+
+  backend:
+    build: .
+    image: ghcr.io/tuanchris/dune-weaver:main
+    restart: always
+    # Pin motion-critical backend to cores 0-2 (Raspberry Pi 4/5 has cores 0-3)
+    # This prevents CPU contention from touch app blocking I/O calls
+    cpuset: "0,1,2"
+    ports:
+      - "8080:8080"
+    # Environment variables for testing (uncomment to enable):
+    # environment:
+    #   FORCE_UPDATE_AVAILABLE: "1"        # Always show update available
+    #   FAKE_LATEST_VERSION: "99.0.0"      # Fake a newer version
+    volumes:
+      # Mount entire app directory for persistence
+      - .:/app
+      # Mount Docker socket for container self-restart/update
       - /var/run/docker.sock:/var/run/docker.sock
-      # Mount timezone file from host for Still Sands scheduling
+      # Mount timezone file from host for scheduling features
       - /etc/timezone:/etc/host-timezone:ro
-      # Mount systemd to allow host shutdown
+      # Mount systemd for host shutdown capability
       - /run/systemd/system:/run/systemd/system:ro
       - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro
       - /sys/fs/cgroup:/sys/fs/cgroup:ro
       # Mount GPIO for DW LEDs and Desert Compass (reed switch)
       - /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
-      - "/dev/pio0:/dev/pio0"        # PIO device for Pi 5 NeoPixel support
-    privileged: true  # Required for GPIO/PWM access
-    container_name: dune-weaver
+      # Mount /dev for serial port access (devices may not exist at start time)
+      - /dev:/dev
+    privileged: true
+    container_name: dune-weaver-backend

File diff ditekan karena terlalu besar
+ 237 - 305
dune-weaver-touch/backend.py


+ 5 - 1
dune-weaver-touch/dune-weaver-touch.service

@@ -16,7 +16,11 @@ Environment=QT_QPA_EGLFS_ALWAYS_SET_MODE=1
 Environment=QT_QPA_EGLFS_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
+# CPU isolation: Pin touch app to core 3, lower priority to prevent starving motion backend
+# Backend runs in Docker pinned to cores 0-2 for serial I/O timing reliability
+Nice=10
+CPUQuota=25%
+ExecStart=/usr/bin/taskset -c 3 /home/pi/dune-weaver-touch/venv/bin/python /home/pi/dune-weaver-touch/main.py
 Restart=always
 RestartSec=10
 StartLimitInterval=200

+ 6 - 6
dune-weaver-touch/main.py

@@ -6,19 +6,19 @@ 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, QQmlContext
+from PySide6.QtGui import QGuiApplication
+from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType
 from qasync import QEventLoop
-
-# Load environment variables from .env file if it exists
 from dotenv import load_dotenv
-load_dotenv(Path(__file__).parent / ".env")
 
 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
 
+# Load environment variables from .env file if it exists
+load_dotenv(Path(__file__).parent / ".env")
+
 # Configure logging
 logging.basicConfig(
     level=logging.INFO,
@@ -93,7 +93,7 @@ def is_pi5():
         with open('/proc/device-tree/model', 'r') as f:
             model = f.read()
             return 'Pi 5' in model
-    except:
+    except Exception:
         return False
 
 def main():

+ 1 - 1
dune-weaver-touch/models/pattern_model.py

@@ -1,4 +1,4 @@
-from PySide6.QtCore import QAbstractListModel, Qt, Slot, Signal
+from PySide6.QtCore import QAbstractListModel, Qt, Slot
 from PySide6.QtQml import QmlElement
 from pathlib import Path
 

+ 18 - 2
dune-weaver-touch/models/playlist_model.py

@@ -1,4 +1,4 @@
-from PySide6.QtCore import QAbstractListModel, Qt, Slot, Signal
+from PySide6.QtCore import QAbstractListModel, Qt, Slot
 from PySide6.QtQml import QmlElement
 from pathlib import Path
 import json
@@ -67,7 +67,7 @@ class PlaylistModel(QAbstractListModel):
     
     @Slot(str, result=list)
     def getPatternsForPlaylist(self, playlistName):
-        """Get the list of patterns for a given playlist"""
+        """Get the list of patterns for a given playlist (cleaned for display)"""
         if hasattr(self, '_playlist_data') and playlistName in self._playlist_data:
             patterns = self._playlist_data[playlistName]
             if isinstance(patterns, list):
@@ -82,4 +82,20 @@ class PlaylistModel(QAbstractListModel):
                         clean_name = clean_name[:-4]
                     cleaned_patterns.append(clean_name)
                 return cleaned_patterns
+        return []
+
+    @Slot(str, result=list)
+    def getRawPatternsForPlaylist(self, playlistName):
+        """Get the raw list of patterns for a playlist (with full paths, for API calls)"""
+        if hasattr(self, '_playlist_data') and playlistName in self._playlist_data:
+            patterns = self._playlist_data[playlistName]
+            if isinstance(patterns, list):
+                return patterns
+        return []
+
+    @Slot(result=list)
+    def getAllPlaylistNames(self):
+        """Get a list of all playlist names"""
+        if hasattr(self, '_playlist_data'):
+            return sorted(list(self._playlist_data.keys()))
         return []

+ 1 - 1
dune-weaver-touch/png_cache_manager.py

@@ -7,7 +7,7 @@ import asyncio
 import os
 import logging
 from pathlib import Path
-from typing import List, Tuple
+from typing import List
 try:
     from PIL import Image
 except ImportError:

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

@@ -32,7 +32,6 @@ Rectangle {
             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) {
@@ -42,7 +41,6 @@ Rectangle {
                     case "play_arrow": return "▶"   // U+25B6 - Play without variant selector
                     case "lightbulb": return "☀"   // U+2600 - Sun symbol for LED
                     default: {
-                        console.log("Unknown icon:", iconValue, "- using default")
                         return "□"  // U+25A1 - Simple box, universally supported
                     }
                 }

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

@@ -12,12 +12,10 @@ Rectangle {
     // 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
@@ -31,23 +29,18 @@ Rectangle {
         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)
         }
     }
     

+ 3 - 1
dune-weaver-touch/qml/components/ModernControlButton.qml

@@ -9,6 +9,7 @@ Rectangle {
     property color buttonColor: "#2196F3"
     property bool enabled: true
     property int fontSize: 16
+    property int iconSize: -1  // -1 means use fontSize + 2
 
     signal clicked()
 
@@ -51,7 +52,8 @@ Rectangle {
         
         Text {
             text: parent.parent.icon
-            font.pixelSize: parent.parent.fontSize + 2
+            font.pixelSize: parent.parent.iconSize > 0 ? parent.parent.iconSize : parent.parent.fontSize + 2
+            color: "white"
             visible: parent.parent.icon !== ""
         }
         

+ 1 - 2
dune-weaver-touch/qml/components/ThemeManager.qml

@@ -42,7 +42,7 @@ QtObject {
 
     // Placeholder colors
     property color placeholderBackground: darkMode ? "#2d2d2d" : "#f0f0f0"
-    property color placeholderText: darkMode ? "#606060" : "#cccccc"
+    property color placeholderText: darkMode ? "#9a9a9a" : "#999999"
 
     // Preview background - lighter in dark mode for better pattern visibility
     property color previewBackground: darkMode ? "#707070" : "#f8f9fa"
@@ -67,7 +67,6 @@ QtObject {
     onDarkModeChanged: {
         // Save preference
         settings.darkMode = darkMode
-        console.log("🎨 Dark mode:", darkMode ? "enabled" : "disabled")
     }
 
     // Helper function to get contrast color

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

@@ -25,22 +25,17 @@ ApplicationWindow {
     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 (index 4)
-            console.log("🎯 Setting currentPageIndex to 4")
             currentPageIndex = 4
             shouldNavigateToExecution = false
         }
@@ -50,14 +45,11 @@ ApplicationWindow {
         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) {
@@ -72,16 +64,12 @@ ApplicationWindow {
         }
         
         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)
             }
         }
@@ -95,17 +83,14 @@ ApplicationWindow {
         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()
         }
     }
@@ -134,7 +119,6 @@ ApplicationWindow {
                 showRetryButton: backend.reconnectStatus === "Cannot connect to backend"
                 
                 onRetryConnection: {
-                    console.log("🔄 Manual retry requested")
                     backend.retryConnection()
                 }
             }
@@ -154,7 +138,6 @@ ApplicationWindow {
                     currentIndex: window.currentPageIndex
                     
                     Component.onCompleted: {
-                        console.log("📱 StackLayout created with currentIndex:", currentIndex, "bound to window.currentPageIndex:", window.currentPageIndex)
                     }
                     
                     // Patterns Page
@@ -214,7 +197,6 @@ ApplicationWindow {
                     currentIndex: window.currentPageIndex
                     
                     onTabClicked: function(index) {
-                        console.log("📱 Tab clicked:", index)
                         window.currentPageIndex = index
                     }
                 }

+ 3 - 21
dune-weaver-touch/qml/pages/ExecutionPage.qml

@@ -14,17 +14,12 @@ Page {
     
     // 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)
         }
     }
     
@@ -33,20 +28,14 @@ Page {
         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
@@ -125,9 +114,7 @@ Page {
                             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
@@ -135,18 +122,13 @@ Page {
                         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 {
@@ -429,10 +411,10 @@ Page {
                                     
                                     // Speed buttons
                                     Repeater {
-                                        model: ["100", "150", "200", "300", "500"]
-                                        
+                                        model: ["50", "100", "150", "200", "300", "500"]
+
                                         Rectangle {
-                                            width: (speedControlRow.width - 32) / 5  // Distribute evenly with spacing
+                                            width: (speedControlRow.width - 40) / 6  // Distribute evenly with spacing
                                             height: 50
                                             color: speedControlRow.currentSelection === modelData ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
                                             border.color: speedControlRow.currentSelection === modelData ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder

+ 77 - 7
dune-weaver-touch/qml/pages/ModernPatternListPage.qml

@@ -11,6 +11,27 @@ Page {
     property var backend
     property var stackView
     property bool searchExpanded: false
+    property bool isRefreshing: false
+    property int patternCount: patternModel ? patternModel.rowCount() : 0
+
+    // Handle pattern refresh completion from backend
+    Connections {
+        target: backend
+        function onPatternsRefreshCompleted(success, message) {
+            if (patternModel) {
+                patternModel.refresh()
+            }
+            isRefreshing = false
+        }
+    }
+
+    // Update pattern count when model resets (rowCount() is not reactive)
+    Connections {
+        target: patternModel
+        function onModelReset() {
+            patternCount = patternModel.rowCount()
+        }
+    }
 
     Rectangle {
         anchors.fill: parent
@@ -56,14 +77,52 @@ Page {
 
                 // Pattern count
                 Label {
-                    text: patternModel.rowCount() + " patterns"
+                    text: patternCount + " patterns"
                     font.pixelSize: 12
                     color: Components.ThemeManager.textTertiary
                     visible: !searchExpanded
                 }
-                
-                Item { 
-                    Layout.fillWidth: true 
+
+                // Refresh button
+                Rectangle {
+                    Layout.preferredWidth: 32
+                    Layout.preferredHeight: 32
+                    radius: 16
+                    color: refreshMouseArea.pressed ? Components.ThemeManager.buttonBackgroundHover :
+                           (refreshMouseArea.containsMouse ? Components.ThemeManager.cardColor : "transparent")
+                    visible: !searchExpanded
+
+                    Text {
+                        id: refreshIcon
+                        anchors.centerIn: parent
+                        text: "↻"
+                        font.pixelSize: 16
+                        color: isRefreshing ? Components.ThemeManager.accentBlue : Components.ThemeManager.textSecondary
+
+                        SequentialAnimation on opacity {
+                            running: isRefreshing
+                            loops: Animation.Infinite
+                            NumberAnimation { to: 0.4; duration: 500 }
+                            NumberAnimation { to: 1.0; duration: 500 }
+                        }
+                    }
+
+                    MouseArea {
+                        id: refreshMouseArea
+                        anchors.fill: parent
+                        hoverEnabled: true
+                        enabled: !isRefreshing
+                        onClicked: {
+                            if (backend) {
+                                isRefreshing = true
+                                backend.refreshPatterns()
+                            }
+                        }
+                    }
+                }
+
+                Item {
+                    Layout.fillWidth: true
                     visible: !searchExpanded
                 }
                 
@@ -98,6 +157,7 @@ Page {
                             id: searchField
                             Layout.fillWidth: true
                             placeholderText: searchExpanded ? "Search patterns... (press Enter)" : "Search"
+                            placeholderTextColor: Components.ThemeManager.textTertiary
                             font.pixelSize: 14
                             color: Components.ThemeManager.textPrimary
                             visible: searchExpanded || text.length > 0
@@ -191,8 +251,7 @@ Page {
                 
                 // Close button when expanded
                 Button {
-                    text: "✕"
-                    font.pixelSize: 18
+                    id: searchCloseBtn
                     flat: true
                     visible: searchExpanded
                     Layout.preferredWidth: 32
@@ -205,6 +264,17 @@ Page {
                         // Clear the filter when closing search
                         patternModel.filter("")
                     }
+                    contentItem: Text {
+                        text: "✕"
+                        font.pixelSize: 18
+                        color: Components.ThemeManager.textSecondary
+                        horizontalAlignment: Text.AlignHCenter
+                        verticalAlignment: Text.AlignVCenter
+                    }
+                    background: Rectangle {
+                        color: searchCloseBtn.pressed ? Components.ThemeManager.buttonBackgroundHover : "transparent"
+                        radius: 4
+                    }
                 }
             }
         }
@@ -254,7 +324,7 @@ Page {
         Item {
             Layout.fillWidth: true
             Layout.fillHeight: true
-            visible: patternModel.rowCount() === 0 && searchField.text !== ""
+            visible: patternCount === 0 && searchField.text !== ""
 
             Column {
                 anchors.centerIn: parent

+ 386 - 49
dune-weaver-touch/qml/pages/ModernPlaylistPage.qml

@@ -17,12 +17,13 @@ Page {
     property string selectedPlaylist: ""
     property var selectedPlaylistData: null
     property var currentPlaylistPatterns: []
+    property var currentPlaylistRawPatterns: []  // Raw patterns with full paths for API calls
     
-    // Playlist execution settings
-    property real pauseTime: backend ? backend.pauseBetweenPatterns : 0
-    property string clearPattern: "adaptive"
-    property string runMode: "single"
-    property bool shuffle: false
+    // Playlist execution settings (loaded from backend/persisted settings)
+    property real pauseTime: backend ? backend.pauseBetweenPatterns : 10800
+    property string clearPattern: backend ? backend.playlistClearPattern : "adaptive"
+    property string runMode: backend ? backend.playlistRunMode : "loop"
+    property bool shuffle: backend ? backend.playlistShuffle : true
     
     PlaylistModel {
         id: playlistModel
@@ -32,16 +33,24 @@ Page {
     onSelectedPlaylistChanged: {
         if (selectedPlaylist) {
             currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
-            console.log("Loaded patterns for", selectedPlaylist + ":", currentPlaylistPatterns)
+            currentPlaylistRawPatterns = playlistModel.getRawPatternsForPlaylist(selectedPlaylist)
         } else {
             currentPlaylistPatterns = []
+            currentPlaylistRawPatterns = []
+        }
+    }
+
+    // Function to remove a pattern from the current playlist
+    function removePatternAtIndex(index) {
+        if (index >= 0 && index < currentPlaylistRawPatterns.length && backend) {
+            var updatedPatterns = currentPlaylistRawPatterns.slice()  // Create a copy
+            updatedPatterns.splice(index, 1)  // Remove the pattern at index
+            backend.updatePlaylistPatterns(selectedPlaylist, updatedPatterns)
         }
     }
     
     // Debug playlist loading
     Component.onCompleted: {
-        console.log("ModernPlaylistPage completed, playlist count:", playlistModel.rowCount())
-        console.log("showingPlaylistDetail:", showingPlaylistDetail)
     }
     
     // Function to navigate to playlist detail
@@ -110,9 +119,24 @@ Page {
                         font.pixelSize: 12
                         color: Components.ThemeManager.textTertiary
                     }
-                    
-                    Item { 
-                        Layout.fillWidth: true 
+
+                    Item {
+                        Layout.fillWidth: true
+                    }
+
+                    // Create new playlist button
+                    Text {
+                        text: "+"
+                        font.pixelSize: 32
+                        font.bold: true
+                        color: createPlaylistMouseArea.pressed ? "#1e40af" : "#2563eb"
+
+                        MouseArea {
+                            id: createPlaylistMouseArea
+                            anchors.fill: parent
+                            anchors.margins: -8  // Increase touch area
+                            onClicked: createPlaylistDialog.open()
+                        }
                     }
                 }
             }
@@ -306,6 +330,20 @@ Page {
                         font.pixelSize: 12
                         color: Components.ThemeManager.textTertiary
                     }
+
+                    // Delete playlist button
+                    Text {
+                        text: "✕"
+                        font.pixelSize: 20
+                        color: deletePlaylistMouseArea.pressed ? "#991b1b" : "#dc2626"
+
+                        MouseArea {
+                            id: deletePlaylistMouseArea
+                            anchors.fill: parent
+                            anchors.margins: -8
+                            onClicked: deletePlaylistDialog.open()
+                        }
+                    }
                 }
             }
             
@@ -328,14 +366,43 @@ Page {
                             anchors.fill: parent
                             anchors.margins: 15
                             spacing: 10
-                            
-                            Label {
-                                text: "Patterns"
-                                font.pixelSize: 14
-                                font.bold: true
-                                color: Components.ThemeManager.textPrimary
+
+                            RowLayout {
+                                Layout.fillWidth: true
+                                spacing: 10
+
+                                Label {
+                                    text: "Patterns"
+                                    font.pixelSize: 14
+                                    font.bold: true
+                                    color: Components.ThemeManager.textPrimary
+                                    Layout.fillWidth: true
+                                }
+
+                                // Add pattern button
+                                Text {
+                                    text: "+"
+                                    font.pixelSize: 24
+                                    font.bold: true
+                                    color: addPatternMouseArea.pressed ? "#1e40af" : "#2563eb"
+
+                                    MouseArea {
+                                        id: addPatternMouseArea
+                                        anchors.fill: parent
+                                        anchors.margins: -8
+                                        onClicked: {
+                                            // Navigate to full-page pattern selector
+                                            stackView.push("PatternSelectorPage.qml", {
+                                                backend: backend,
+                                                stackView: stackView,
+                                                playlistName: selectedPlaylist,
+                                                existingPatterns: currentPlaylistRawPatterns
+                                            })
+                                        }
+                                    }
+                                }
                             }
-                            
+
                             ScrollView {
                                 Layout.fillWidth: true
                                 Layout.fillHeight: true
@@ -349,22 +416,25 @@ Page {
                                     
                                     delegate: Rectangle {
                                         width: patternListView.width
-                                        height: 35
+                                        height: 40
                                         color: index % 2 === 0 ? Components.ThemeManager.cardColor : Components.ThemeManager.surfaceColor
                                         radius: 6
                                         border.color: Components.ThemeManager.borderColor
                                         border.width: 1
-                                        
+
                                         RowLayout {
                                             anchors.fill: parent
-                                            anchors.margins: 10
-                                            spacing: 8
-                                            
+                                            anchors.leftMargin: 10
+                                            anchors.rightMargin: 5
+                                            anchors.topMargin: 4
+                                            anchors.bottomMargin: 4
+                                            spacing: 6
+
                                             Label {
                                                 text: (index + 1) + "."
                                                 font.pixelSize: 11
                                                 color: Components.ThemeManager.textSecondary
-                                                Layout.preferredWidth: 25
+                                                Layout.preferredWidth: 22
                                             }
 
                                             Label {
@@ -374,6 +444,23 @@ Page {
                                                 Layout.fillWidth: true
                                                 elide: Text.ElideRight
                                             }
+
+                                            // Remove pattern button - aligned right
+                                            Text {
+                                                text: "✕"
+                                                font.pixelSize: 16
+                                                color: removePatternArea.pressed ? "#ef4444" : Components.ThemeManager.textTertiary
+                                                Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
+
+                                                MouseArea {
+                                                    id: removePatternArea
+                                                    anchors.fill: parent
+                                                    anchors.margins: -8  // Increase touch area
+                                                    onClicked: {
+                                                        removePatternAtIndex(index)
+                                                    }
+                                                }
+                                            }
                                         }
                                     }
                                 }
@@ -458,21 +545,11 @@ Page {
                                         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")
                                                 }
                                             }
                                         }
@@ -497,8 +574,9 @@ Page {
                                         id: shuffleMouseArea
                                         anchors.fill: parent
                                         onClicked: {
-                                            shuffle = !shuffle
-                                            console.log("Shuffle toggled:", shuffle)
+                                            // Don't assign directly to shuffle - that breaks the binding
+                                            // Just update backend and let the binding propagate the change
+                                            if (backend) backend.setPlaylistShuffle(!backend.playlistShuffle)
                                         }
                                     }
                                 }
@@ -558,10 +636,10 @@ Page {
                                                 id: singleModeRadio
                                                 text: "Single"
                                                 font.pixelSize: 11
-                                                checked: true  // Default
+                                                checked: runMode === "single"
                                                 onClicked: {
                                                     runMode = "single"
-                                                    console.log("Run mode set to:", runMode)
+                                                    if (backend) backend.setPlaylistRunMode("single")
                                                 }
 
                                                 contentItem: Text {
@@ -577,10 +655,10 @@ Page {
                                                 id: loopModeRadio
                                                 text: "Loop"
                                                 font.pixelSize: 11
-                                                checked: false
+                                                checked: runMode === "loop"
                                                 onClicked: {
                                                     runMode = "loop"
-                                                    console.log("Run mode set to:", runMode)
+                                                    if (backend) backend.setPlaylistRunMode("loop")
                                                 }
 
                                                 contentItem: Text {
@@ -1003,10 +1081,10 @@ Page {
                                                 id: adaptiveRadio
                                                 text: "Adaptive"
                                                 font.pixelSize: 11
-                                                checked: true  // Default
+                                                checked: clearPattern === "adaptive"
                                                 onClicked: {
                                                     clearPattern = "adaptive"
-                                                    console.log("Clear pattern set to:", clearPattern)
+                                                    if (backend) backend.setPlaylistClearPattern("adaptive")
                                                 }
 
                                                 contentItem: Text {
@@ -1022,10 +1100,10 @@ Page {
                                                 id: clearCenterRadio
                                                 text: "Clear Center"
                                                 font.pixelSize: 11
-                                                checked: false
+                                                checked: clearPattern === "clear_center"
                                                 onClicked: {
                                                     clearPattern = "clear_center"
-                                                    console.log("Clear pattern set to:", clearPattern)
+                                                    if (backend) backend.setPlaylistClearPattern("clear_center")
                                                 }
 
                                                 contentItem: Text {
@@ -1041,10 +1119,10 @@ Page {
                                                 id: clearEdgeRadio
                                                 text: "Clear Edge"
                                                 font.pixelSize: 11
-                                                checked: false
+                                                checked: clearPattern === "clear_perimeter"
                                                 onClicked: {
                                                     clearPattern = "clear_perimeter"
-                                                    console.log("Clear pattern set to:", clearPattern)
+                                                    if (backend) backend.setPlaylistClearPattern("clear_perimeter")
                                                 }
 
                                                 contentItem: Text {
@@ -1060,10 +1138,10 @@ Page {
                                                 id: noneRadio
                                                 text: "None"
                                                 font.pixelSize: 11
-                                                checked: false
+                                                checked: clearPattern === "none"
                                                 onClicked: {
                                                     clearPattern = "none"
-                                                    console.log("Clear pattern set to:", clearPattern)
+                                                    if (backend) backend.setPlaylistClearPattern("none")
                                                 }
 
                                                 contentItem: Text {
@@ -1086,4 +1164,263 @@ Page {
             }
         }
     }
+
+    // ==================== Dialogs ====================
+
+    // Create Playlist Dialog
+    Popup {
+        id: createPlaylistDialog
+        modal: true
+        x: (parent.width - width) / 2
+        y: (parent.height - height) / 2
+        width: 320
+        height: 200
+        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+        background: Rectangle {
+            color: Components.ThemeManager.surfaceColor
+            radius: 16
+            border.color: Components.ThemeManager.borderColor
+            border.width: 1
+        }
+
+        contentItem: ColumnLayout {
+            anchors.fill: parent
+            anchors.margins: 20
+            spacing: 15
+
+            Label {
+                text: "Create New Playlist"
+                font.pixelSize: 18
+                font.bold: true
+                color: Components.ThemeManager.textPrimary
+                Layout.alignment: Qt.AlignHCenter
+            }
+
+            TextField {
+                id: newPlaylistNameField
+                Layout.fillWidth: true
+                Layout.preferredHeight: 45
+                placeholderText: "Enter playlist name..."
+                placeholderTextColor: Components.ThemeManager.textTertiary
+                font.pixelSize: 14
+                color: Components.ThemeManager.textPrimary
+
+                background: Rectangle {
+                    color: Components.ThemeManager.backgroundColor
+                    radius: 8
+                    border.color: newPlaylistNameField.activeFocus ? "#2563eb" : Components.ThemeManager.borderColor
+                    border.width: 1
+                }
+
+                onAccepted: {
+                    if (text.trim().length > 0 && backend) {
+                        backend.createPlaylist(text.trim())
+                    }
+                }
+            }
+
+            RowLayout {
+                Layout.fillWidth: true
+                spacing: 10
+
+                // Cancel button
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 45
+                    radius: 8
+                    color: cancelCreateArea.pressed ? Components.ThemeManager.buttonBackgroundHover : Components.ThemeManager.cardColor
+                    border.color: Components.ThemeManager.borderColor
+                    border.width: 1
+
+                    Text {
+                        anchors.centerIn: parent
+                        text: "Cancel"
+                        color: Components.ThemeManager.textPrimary
+                        font.pixelSize: 14
+                    }
+
+                    MouseArea {
+                        id: cancelCreateArea
+                        anchors.fill: parent
+                        onClicked: {
+                            newPlaylistNameField.text = ""
+                            createPlaylistDialog.close()
+                        }
+                    }
+                }
+
+                // Create button
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 45
+                    radius: 8
+                    color: createArea.pressed ? "#1e40af" : "#2563eb"
+                    opacity: newPlaylistNameField.text.trim().length > 0 ? 1.0 : 0.5
+
+                    Text {
+                        anchors.centerIn: parent
+                        text: "Create"
+                        color: "white"
+                        font.pixelSize: 14
+                        font.bold: true
+                    }
+
+                    MouseArea {
+                        id: createArea
+                        anchors.fill: parent
+                        enabled: newPlaylistNameField.text.trim().length > 0
+                        onClicked: {
+                            if (backend) {
+                                backend.createPlaylist(newPlaylistNameField.text.trim())
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        onOpened: {
+            newPlaylistNameField.text = ""
+            newPlaylistNameField.forceActiveFocus()
+        }
+    }
+
+    // Delete Playlist Confirmation Dialog
+    Popup {
+        id: deletePlaylistDialog
+        modal: true
+        x: (parent.width - width) / 2
+        y: (parent.height - height) / 2
+        width: 320
+        height: 180
+        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+        background: Rectangle {
+            color: Components.ThemeManager.surfaceColor
+            radius: 16
+            border.color: Components.ThemeManager.borderColor
+            border.width: 1
+        }
+
+        contentItem: ColumnLayout {
+            anchors.fill: parent
+            anchors.margins: 20
+            spacing: 15
+
+            Label {
+                text: "Delete Playlist?"
+                font.pixelSize: 18
+                font.bold: true
+                color: Components.ThemeManager.textPrimary
+                Layout.alignment: Qt.AlignHCenter
+            }
+
+            Label {
+                text: "Are you sure you want to delete\n\"" + selectedPlaylist + "\"?"
+                font.pixelSize: 14
+                color: Components.ThemeManager.textSecondary
+                horizontalAlignment: Text.AlignHCenter
+                Layout.alignment: Qt.AlignHCenter
+            }
+
+            RowLayout {
+                Layout.fillWidth: true
+                spacing: 10
+
+                // Cancel button
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 45
+                    radius: 8
+                    color: cancelDeleteArea.pressed ? Components.ThemeManager.buttonBackgroundHover : Components.ThemeManager.cardColor
+                    border.color: Components.ThemeManager.borderColor
+                    border.width: 1
+
+                    Text {
+                        anchors.centerIn: parent
+                        text: "Cancel"
+                        color: Components.ThemeManager.textPrimary
+                        font.pixelSize: 14
+                    }
+
+                    MouseArea {
+                        id: cancelDeleteArea
+                        anchors.fill: parent
+                        onClicked: deletePlaylistDialog.close()
+                    }
+                }
+
+                // Delete button
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 45
+                    radius: 8
+                    color: confirmDeleteArea.pressed ? "#991b1b" : "#dc2626"
+
+                    Text {
+                        anchors.centerIn: parent
+                        text: "Delete"
+                        color: "white"
+                        font.pixelSize: 14
+                        font.bold: true
+                    }
+
+                    MouseArea {
+                        id: confirmDeleteArea
+                        anchors.fill: parent
+                        onClicked: {
+                            if (backend && selectedPlaylist) {
+                                backend.deletePlaylist(selectedPlaylist)
+                            }
+                            deletePlaylistDialog.close()
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    // ==================== Backend Signal Handlers ====================
+
+    Connections {
+        target: backend
+
+        function onPlaylistCreated(success, message) {
+            if (success) {
+                playlistModel.refresh()
+            }
+            newPlaylistNameField.text = ""
+            createPlaylistDialog.close()
+        }
+
+        function onPlaylistDeleted(success, message) {
+            if (success) {
+                playlistModel.refresh()
+                showPlaylistList()  // Navigate back to list
+            }
+        }
+
+        function onPatternAddedToPlaylist(success, message) {
+            if (success) {
+                playlistModel.refresh()
+                // Refresh current playlist patterns if we're viewing one
+                if (selectedPlaylist) {
+                    currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
+                    currentPlaylistRawPatterns = playlistModel.getRawPatternsForPlaylist(selectedPlaylist)
+                }
+            }
+        }
+
+        function onPlaylistModified(success, message) {
+            if (success) {
+                playlistModel.refresh()
+                // Refresh current playlist patterns
+                if (selectedPlaylist) {
+                    currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
+                    currentPlaylistRawPatterns = playlistModel.getRawPatternsForPlaylist(selectedPlaylist)
+                }
+            }
+        }
+    }
 }

+ 211 - 4
dune-weaver-touch/qml/pages/PatternDetailPage.qml

@@ -1,6 +1,7 @@
 import QtQuick 2.15
 import QtQuick.Controls 2.15
 import QtQuick.Layouts 1.15
+import DuneWeaver 1.0
 import "../components"
 import "../components" as Components
 
@@ -10,6 +11,12 @@ Page {
     property string patternPath: ""
     property string patternPreview: ""
     property var backend: null
+    property bool showAddedFeedback: false
+
+    // Playlist model for selecting which playlist to add to
+    PlaylistModel {
+        id: playlistModel
+    }
 
     Rectangle {
         anchors.fill: parent
@@ -141,7 +148,7 @@ Page {
                         height: 50
                         radius: 8
                         color: playMouseArea.pressed ? "#1e40af" : (backend ? "#2563eb" : "#9ca3af")
-                        
+
                         Text {
                             anchors.centerIn: parent
                             text: "▶ Play Pattern"
@@ -149,7 +156,7 @@ Page {
                             font.pixelSize: 16
                             font.bold: true
                         }
-                        
+
                         MouseArea {
                             id: playMouseArea
                             anchors.fill: parent
@@ -160,13 +167,49 @@ Page {
                                     if (centerRadio.checked) preExecution = "clear_center"
                                     else if (perimeterRadio.checked) preExecution = "clear_perimeter"
                                     else if (noneRadio.checked) preExecution = "none"
-                                    
+
                                     backend.executePattern(patternName, preExecution)
                                 }
                             }
                         }
                     }
-                    
+
+                    // Add to Playlist Button
+                    Rectangle {
+                        width: parent.width
+                        height: 45
+                        radius: 8
+                        color: addToPlaylistArea.pressed ? "#065f46" : "#059669"
+
+                        Row {
+                            anchors.centerIn: parent
+                            spacing: 8
+
+                            Text {
+                                text: showAddedFeedback ? "✓" : "♪"
+                                font.pixelSize: 16
+                                color: "white"
+                            }
+
+                            Text {
+                                text: showAddedFeedback ? "Added!" : "Add to Playlist"
+                                color: "white"
+                                font.pixelSize: 14
+                                font.bold: true
+                            }
+                        }
+
+                        MouseArea {
+                            id: addToPlaylistArea
+                            anchors.fill: parent
+                            enabled: backend !== null && !showAddedFeedback
+                            onClicked: {
+                                playlistModel.refresh()  // Refresh playlist list
+                                playlistSelectorPopup.open()
+                            }
+                        }
+                    }
+
                     // Pre-Execution Options
                     Rectangle {
                         width: parent.width
@@ -294,4 +337,168 @@ Page {
             }
         }
     }
+
+    // ==================== Playlist Selector Popup ====================
+
+    Popup {
+        id: playlistSelectorPopup
+        modal: true
+        x: (parent.width - width) / 2
+        y: (parent.height - height) / 2
+        width: 320
+        height: Math.min(400, 120 + playlistModel.rowCount() * 50)
+        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+        background: Rectangle {
+            color: Components.ThemeManager.surfaceColor
+            radius: 16
+            border.color: Components.ThemeManager.borderColor
+            border.width: 1
+        }
+
+        contentItem: ColumnLayout {
+            anchors.fill: parent
+            anchors.margins: 15
+            spacing: 10
+
+            Label {
+                text: "Add to Playlist"
+                font.pixelSize: 18
+                font.bold: true
+                color: Components.ThemeManager.textPrimary
+                Layout.alignment: Qt.AlignHCenter
+            }
+
+            // Playlist list
+            ListView {
+                Layout.fillWidth: true
+                Layout.fillHeight: true
+                clip: true
+                model: playlistModel
+                spacing: 6
+
+                delegate: Rectangle {
+                    width: ListView.view.width
+                    height: 45
+                    radius: 8
+                    color: playlistItemArea.pressed ? Components.ThemeManager.selectedBackground : Components.ThemeManager.cardColor
+                    border.color: Components.ThemeManager.borderColor
+                    border.width: 1
+
+                    RowLayout {
+                        anchors.fill: parent
+                        anchors.margins: 12
+                        spacing: 10
+
+                        Text {
+                            text: "♪"
+                            font.pixelSize: 16
+                            color: "#2196F3"
+                        }
+
+                        Label {
+                            text: model.name
+                            font.pixelSize: 14
+                            color: Components.ThemeManager.textPrimary
+                            Layout.fillWidth: true
+                            elide: Text.ElideRight
+                        }
+
+                        Label {
+                            text: model.itemCount + " patterns"
+                            font.pixelSize: 11
+                            color: Components.ThemeManager.textTertiary
+                        }
+                    }
+
+                    MouseArea {
+                        id: playlistItemArea
+                        anchors.fill: parent
+                        onClicked: {
+                            if (backend) {
+                                backend.addPatternToPlaylist(model.name, patternName)
+                            }
+                            playlistSelectorPopup.close()
+                        }
+                    }
+                }
+            }
+
+            // Empty state
+            Column {
+                Layout.fillWidth: true
+                Layout.fillHeight: true
+                spacing: 10
+                visible: playlistModel.rowCount() === 0
+
+                Item { Layout.fillHeight: true }
+
+                Text {
+                    text: "♪"
+                    font.pixelSize: 32
+                    color: Components.ThemeManager.placeholderText
+                    anchors.horizontalCenter: parent.horizontalCenter
+                }
+
+                Label {
+                    text: "No playlists yet"
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textSecondary
+                    font.pixelSize: 14
+                }
+
+                Label {
+                    text: "Create a playlist first"
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textTertiary
+                    font.pixelSize: 12
+                }
+
+                Item { Layout.fillHeight: true }
+            }
+
+            // Cancel button
+            Rectangle {
+                Layout.fillWidth: true
+                Layout.preferredHeight: 40
+                radius: 8
+                color: cancelArea.pressed ? Components.ThemeManager.buttonBackgroundHover : Components.ThemeManager.cardColor
+                border.color: Components.ThemeManager.borderColor
+                border.width: 1
+
+                Text {
+                    anchors.centerIn: parent
+                    text: "Cancel"
+                    color: Components.ThemeManager.textPrimary
+                    font.pixelSize: 14
+                }
+
+                MouseArea {
+                    id: cancelArea
+                    anchors.fill: parent
+                    onClicked: playlistSelectorPopup.close()
+                }
+            }
+        }
+    }
+
+    // ==================== Backend Signal Handlers ====================
+
+    Connections {
+        target: backend
+
+        function onPatternAddedToPlaylist(success, message) {
+            if (success) {
+                // Show feedback
+                showAddedFeedback = true
+                feedbackTimer.start()
+            }
+        }
+    }
+
+    Timer {
+        id: feedbackTimer
+        interval: 2000  // Show "Added!" for 2 seconds
+        onTriggered: showAddedFeedback = false
+    }
 }

+ 4 - 1
dune-weaver-touch/qml/pages/PatternListPage.qml

@@ -2,6 +2,7 @@ import QtQuick 2.15
 import QtQuick.Controls 2.15
 import QtQuick.Layouts 1.15
 import "../components"
+import "../components" as Components
 
 Page {
     id: page
@@ -15,6 +16,8 @@ Page {
                 id: searchField
                 Layout.fillWidth: true
                 placeholderText: "Search patterns..."
+                placeholderTextColor: Components.ThemeManager.textTertiary
+                color: Components.ThemeManager.textPrimary
                 onTextChanged: patternModel.filter(text)
                 font.pixelSize: 16
             }
@@ -62,7 +65,7 @@ Page {
         anchors.centerIn: parent
         text: "No patterns found"
         visible: patternModel.rowCount() === 0 && searchField.text !== ""
-        color: "#999"
+        color: Components.ThemeManager.textTertiary
         font.pixelSize: 18
     }
 }

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

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

+ 36 - 16
dune-weaver-touch/qml/pages/PlaylistPage.qml

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

+ 59 - 4
dune-weaver-touch/qml/pages/TableControlPage.qml

@@ -18,17 +18,14 @@ Page {
         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
             }
@@ -36,7 +33,6 @@ Page {
         
         
         function onSettingsLoaded() {
-            console.log("Settings loaded")
             if (backend) {
                 autoPlayOnBoot = backend.autoPlayOnBoot
                 isSerialConnected = backend.serialConnected
@@ -213,6 +209,7 @@ Page {
                                 Layout.preferredHeight: 40
                                 text: isSerialConnected ? "Disconnect" : "Connect"
                                 icon: isSerialConnected ? "◉" : "○"
+                                iconSize: 20
                                 buttonColor: isSerialConnected ? "#dc2626" : "#059669"
                                 fontSize: 11
                                 enabled: isSerialConnected || selectedPort !== ""
@@ -239,6 +236,7 @@ Page {
                                 Layout.preferredHeight: 35
                                 text: "Refresh Ports"
                                 icon: "↻"
+                                iconSize: 18
                                 buttonColor: "#6b7280"
                                 fontSize: 10
                                 
@@ -279,6 +277,7 @@ Page {
                                 Layout.preferredHeight: 45
                                 text: "Home"
                                 icon: "⌂"
+                                iconSize: 20
                                 buttonColor: "#2563eb"
                                 fontSize: 12
                                 enabled: isSerialConnected
@@ -293,6 +292,7 @@ Page {
                                 Layout.preferredHeight: 45
                                 text: "Center"
                                 icon: "◎"
+                                iconSize: 20
                                 buttonColor: "#2563eb"
                                 fontSize: 12
                                 enabled: isSerialConnected
@@ -307,6 +307,7 @@ Page {
                                 Layout.preferredHeight: 45
                                 text: "Perimeter"
                                 icon: "○"
+                                iconSize: 20
                                 buttonColor: "#2563eb"
                                 fontSize: 12
                                 enabled: isSerialConnected
@@ -581,6 +582,60 @@ Page {
                     }
                 }
 
+                // System Controls Section
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 100
+                    Layout.margins: 5
+                    radius: 8
+                    color: Components.ThemeManager.surfaceColor
+
+                    ColumnLayout {
+                        anchors.fill: parent
+                        anchors.margins: 15
+                        spacing: 10
+
+                        Label {
+                            text: "System Controls"
+                            font.pixelSize: 14
+                            font.bold: true
+                            color: Components.ThemeManager.textPrimary
+                        }
+
+                        RowLayout {
+                            Layout.fillWidth: true
+                            spacing: 8
+
+                            ModernControlButton {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: 45
+                                text: "Restart Backend"
+                                icon: "↻"
+                                iconSize: 20
+                                buttonColor: "#f59e0b"
+                                fontSize: 11
+
+                                onClicked: {
+                                    if (backend) backend.restartBackend()
+                                }
+                            }
+
+                            ModernControlButton {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: 45
+                                text: "Shutdown Pi"
+                                icon: ""
+                                buttonColor: "#dc2626"
+                                fontSize: 11
+
+                                onClicked: {
+                                    if (backend) backend.shutdownPi()
+                                }
+                            }
+                        }
+                    }
+                }
+
                 // Add some bottom spacing for better scrolling
                 Item {
                     Layout.preferredHeight: 20

+ 24 - 8
dw

@@ -128,13 +128,20 @@ cmd_update() {
     echo -e "${BLUE}Updating Dune Weaver...${NC}"
     cd "$INSTALL_DIR"
 
-    echo "Pulling latest code..."
-    git pull
+    # Check if we should skip the pull phase (called after re-exec)
+    if [[ "$1" != "--continue" ]]; then
+        echo "Pulling latest code..."
+        git pull
+
+        # Update dw CLI
+        echo "Updating dw command..."
+        sudo cp "$INSTALL_DIR/dw" /usr/local/bin/dw
+        sudo chmod +x /usr/local/bin/dw
 
-    # Update dw CLI
-    echo "Updating dw command..."
-    sudo cp "$INSTALL_DIR/dw" /usr/local/bin/dw
-    sudo chmod +x /usr/local/bin/dw
+        # Re-exec with the new script to ensure new code runs
+        echo "Restarting with updated CLI..."
+        exec /usr/local/bin/dw update --continue
+    fi
 
     if is_docker_mode; then
         echo "Stopping current container..."
@@ -164,7 +171,16 @@ cmd_update() {
         echo ""
         echo -e "${BLUE}Updating touch app...${NC}"
         cd "$touch_dir"
-        git pull
+
+        # Update Python dependencies (code already pulled with main repo)
+        if [[ -f "requirements.txt" ]] && [[ -d ".venv" ]]; then
+            echo "Updating touch app dependencies..."
+            source .venv/bin/activate
+            pip install -q -r requirements.txt
+            deactivate
+        fi
+
+        # Restart to apply changes
         sudo systemctl restart dune-weaver-touch
         echo -e "${GREEN}Touch app updated!${NC}"
     fi
@@ -318,7 +334,7 @@ case "${1:-help}" in
         cmd_restart
         ;;
     update)
-        cmd_update
+        cmd_update "$2"
         ;;
     logs)
         cmd_logs "$2"

+ 6 - 0
frontend/.env.example

@@ -0,0 +1,6 @@
+# Frontend Development Server Configuration
+# Copy this file to .env to customize
+
+# Dev server port (default: 5173)
+# Note: Ports below 1024 require root privileges on macOS/Linux
+PORT=5173

+ 24 - 0
frontend/.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 11 - 0
frontend/.mcp.json

@@ -0,0 +1,11 @@
+{
+  "mcpServers": {
+    "shadcn": {
+      "command": "npx",
+      "args": [
+        "shadcn@latest",
+        "mcp"
+      ]
+    }
+  }
+}

+ 29 - 0
frontend/Dockerfile

@@ -0,0 +1,29 @@
+# Build stage
+FROM node:20-slim AS builder
+
+WORKDIR /app
+
+# Copy package files
+COPY package*.json ./
+
+# Install dependencies
+RUN npm ci
+
+# Copy source
+COPY . .
+
+# Override output to local directory for Docker build
+RUN npm run build -- --outDir ./dist
+
+# Production stage
+FROM nginx:alpine
+
+# Copy built files from builder
+COPY --from=builder /app/dist /usr/share/nginx/html
+
+# Copy nginx config (will be mounted or copied separately)
+# Note: nginx.conf should be copied in docker-compose or here
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]

+ 73 - 0
frontend/README.md

@@ -0,0 +1,73 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
+
+```js
+export default defineConfig([
+  globalIgnores(['dist']),
+  {
+    files: ['**/*.{ts,tsx}'],
+    extends: [
+      // Other configs...
+
+      // Remove tseslint.configs.recommended and replace with this
+      tseslint.configs.recommendedTypeChecked,
+      // Alternatively, use this for stricter rules
+      tseslint.configs.strictTypeChecked,
+      // Optionally, add this for stylistic rules
+      tseslint.configs.stylisticTypeChecked,
+
+      // Other configs...
+    ],
+    languageOptions: {
+      parserOptions: {
+        project: ['./tsconfig.node.json', './tsconfig.app.json'],
+        tsconfigRootDir: import.meta.dirname,
+      },
+      // other options...
+    },
+  },
+])
+```
+
+You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
+
+```js
+// eslint.config.js
+import reactX from 'eslint-plugin-react-x'
+import reactDom from 'eslint-plugin-react-dom'
+
+export default defineConfig([
+  globalIgnores(['dist']),
+  {
+    files: ['**/*.{ts,tsx}'],
+    extends: [
+      // Other configs...
+      // Enable lint rules for React
+      reactX.configs['recommended-typescript'],
+      // Enable lint rules for React DOM
+      reactDom.configs.recommended,
+    ],
+    languageOptions: {
+      parserOptions: {
+        project: ['./tsconfig.node.json', './tsconfig.app.json'],
+        tsconfigRootDir: import.meta.dirname,
+      },
+      // other options...
+    },
+  },
+])
+```

+ 19 - 0
frontend/components.json

@@ -0,0 +1,19 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "default",
+  "rsc": false,
+  "tsx": true,
+  "tailwind": {
+    "config": "tailwind.config.js",
+    "css": "src/index.css",
+    "baseColor": "slate",
+    "cssVariables": true
+  },
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  }
+}

+ 231 - 0
frontend/e2e/mocks/api.ts

@@ -0,0 +1,231 @@
+import { Page, WebSocketRoute } from '@playwright/test'
+
+// Mock data
+export const mockPatterns = [
+  { path: 'patterns/star.thr', name: 'star.thr', category: 'geometric', date_modified: Date.now(), coordinates_count: 150 },
+  { path: 'patterns/spiral.thr', name: 'spiral.thr', category: 'organic', date_modified: Date.now(), coordinates_count: 200 },
+  { path: 'patterns/wave.thr', name: 'wave.thr', category: 'organic', date_modified: Date.now(), coordinates_count: 175 },
+]
+
+export const mockPlaylists = {
+  default: ['patterns/star.thr', 'patterns/spiral.thr'],
+  favorites: ['patterns/star.thr'],
+}
+
+// Mutable status for simulating playback
+let currentStatus = {
+  is_running: false,
+  is_paused: false,
+  current_file: null as string | null,
+  speed: 100,
+  progress: 0,
+  playlist_mode: false,
+  playlist_name: null as string | null,
+  queue: [] as string[],
+  connection_status: 'connected',
+  theta: 0,
+  rho: 0.5,
+}
+
+export function resetMockStatus() {
+  currentStatus = {
+    is_running: false,
+    is_paused: false,
+    current_file: null,
+    speed: 100,
+    progress: 0,
+    playlist_mode: false,
+    playlist_name: null,
+    queue: [],
+    connection_status: 'connected',
+    theta: 0,
+    rho: 0.5,
+  }
+}
+
+export async function setupApiMocks(page: Page) {
+  // Pattern endpoints
+  await page.route('**/list_theta_rho_files_with_metadata', async route => {
+    await route.fulfill({ json: mockPatterns })
+  })
+
+  await page.route('**/list_theta_rho_files', async route => {
+    await route.fulfill({ json: mockPatterns.map(p => ({ name: p.name, path: p.path })) })
+  })
+
+  await page.route('**/preview_thr_batch', async route => {
+    const request = route.request()
+    const body = request.postDataJSON() as { files: string[] }
+    const previews: Record<string, unknown> = {}
+    for (const file of body?.files || []) {
+      previews[file] = {
+        image_data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
+        first_coordinate: { x: 0, y: 0 },
+        last_coordinate: { x: 100, y: 100 },
+      }
+    }
+    await route.fulfill({ json: previews })
+  })
+
+  await page.route('**/run_theta_rho', async route => {
+    const body = route.request().postDataJSON() as { file_name?: string; file?: string }
+    const file = body?.file_name || body?.file || null
+    currentStatus.is_running = true
+    currentStatus.current_file = file
+    await route.fulfill({ json: { success: true } })
+  })
+
+  // Playlist endpoints
+  await page.route('**/list_all_playlists', async route => {
+    await route.fulfill({ json: Object.keys(mockPlaylists) })
+  })
+
+  await page.route('**/get_playlist**', async route => {
+    const url = new URL(route.request().url())
+    const name = url.searchParams.get('name') || ''
+    await route.fulfill({
+      json: { name, files: mockPlaylists[name as keyof typeof mockPlaylists] || [] }
+    })
+  })
+
+  await page.route('**/run_playlist', async route => {
+    const body = route.request().postDataJSON() as { playlist_name?: string; name?: string }
+    // Support both playlist_name (actual API) and name (legacy)
+    const playlistName = body?.playlist_name || body?.name
+    const playlist = mockPlaylists[playlistName as keyof typeof mockPlaylists]
+    if (playlist && playlist.length > 0) {
+      currentStatus.is_running = true
+      currentStatus.playlist_mode = true
+      currentStatus.playlist_name = playlistName || null
+      currentStatus.current_file = playlist[0]
+      currentStatus.queue = playlist.slice(1)
+    }
+    await route.fulfill({ json: { success: true } })
+  })
+
+  // Playback control endpoints
+  await page.route('**/pause_execution', async route => {
+    currentStatus.is_paused = true
+    await route.fulfill({ json: { success: true } })
+  })
+
+  await page.route('**/resume_execution', async route => {
+    currentStatus.is_paused = false
+    await route.fulfill({ json: { success: true } })
+  })
+
+  await page.route('**/stop_execution', async route => {
+    currentStatus.is_running = false
+    currentStatus.is_paused = false
+    currentStatus.current_file = null
+    currentStatus.playlist_mode = false
+    currentStatus.queue = []
+    await route.fulfill({ json: { success: true } })
+  })
+
+  // Status endpoint
+  await page.route('**/serial_status', async route => {
+    await route.fulfill({ json: currentStatus })
+  })
+
+  // Table info (for TableContext)
+  await page.route('**/api/table-info', async route => {
+    await route.fulfill({
+      json: { id: 'test-table', name: 'Test Table', version: '1.0.0' }
+    })
+  })
+
+  // Settings
+  await page.route('**/api/settings', async route => {
+    await route.fulfill({ json: { app: { name: 'Dune Weaver' } } })
+  })
+
+  // Known tables
+  await page.route('**/api/known-tables', async route => {
+    await route.fulfill({ json: { tables: [] } })
+  })
+
+  // Pattern history
+  await page.route('**/api/pattern_history_all', async route => {
+    await route.fulfill({ json: {} })
+  })
+
+  // Logs endpoint
+  await page.route('**/api/logs**', async route => {
+    await route.fulfill({ json: { logs: [], total: 0, has_more: false } })
+  })
+
+  // Pattern history (individual)
+  await page.route('**/api/pattern_history/**', async route => {
+    await route.fulfill({ json: { actual_time_formatted: null, speed: null } })
+  })
+
+  // Serial ports
+  await page.route('**/list_serial_ports', async route => {
+    await route.fulfill({ json: [] })
+  })
+
+  // Static files - return 200 with placeholder
+  await page.route('**/static/**', async route => {
+    // Return a 1x1 transparent PNG for images
+    const base64Png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
+    await route.fulfill({
+      status: 200,
+      contentType: 'image/png',
+      body: Buffer.from(base64Png, 'base64'),
+    })
+  })
+
+  // WebSocket mocking - critical for bypassing the "Connecting to Backend" overlay
+  // The Layout component shows a blocking overlay until WebSocket connects
+  await page.routeWebSocket('**/ws/status', (ws: WebSocketRoute) => {
+    // Don't connect to server - we're mocking everything
+    // Send status updates to simulate backend status messages
+    const statusMessage = JSON.stringify({
+      type: 'status_update',
+      data: {
+        ...currentStatus,
+        connection_status: true,
+        is_homing: false,
+      }
+    })
+
+    // Send initial status immediately after connection
+    // The client's onopen handler will fire, setting isBackendConnected = true
+    setTimeout(() => {
+      ws.send(statusMessage)
+    }, 100)
+
+    // Send periodic updates
+    const interval = setInterval(() => {
+      ws.send(statusMessage)
+    }, 1000)
+
+    ws.onClose(() => {
+      clearInterval(interval)
+    })
+  })
+
+  // Mock other WebSocket endpoints
+  await page.routeWebSocket('**/ws/logs', (_ws: WebSocketRoute) => {
+    // Just accept the connection - don't need to send anything
+  })
+
+  await page.routeWebSocket('**/ws/cache-progress', (ws: WebSocketRoute) => {
+    // Send "not running" status
+    setTimeout(() => {
+      ws.send(JSON.stringify({
+        type: 'cache_progress',
+        data: { is_running: false, stage: 'idle' }
+      }))
+    }, 100)
+  })
+}
+
+export function getMockStatus() {
+  return { ...currentStatus }
+}
+
+export function setMockStatus(updates: Partial<typeof currentStatus>) {
+  Object.assign(currentStatus, updates)
+}

+ 72 - 0
frontend/e2e/pattern-flow.spec.ts

@@ -0,0 +1,72 @@
+import { test, expect } from '@playwright/test'
+import { setupApiMocks, resetMockStatus, getMockStatus } from './mocks/api'
+
+test.describe('Pattern Flow E2E', () => {
+  test.beforeEach(async ({ page }) => {
+    resetMockStatus()
+    await setupApiMocks(page)
+  })
+
+  test('displays pattern list on browse page', async ({ page }) => {
+    await page.goto('/')
+
+    // Wait for patterns to load
+    await expect(page.getByText('star.thr')).toBeVisible()
+    await expect(page.getByText('spiral.thr')).toBeVisible()
+    await expect(page.getByText('wave.thr')).toBeVisible()
+  })
+
+  test('can select pattern to view details', async ({ page }) => {
+    await page.goto('/')
+
+    // Wait for patterns to load
+    await expect(page.getByText('star.thr')).toBeVisible()
+
+    // Click on pattern
+    await page.getByText('star.thr').click()
+
+    // Detail panel should open (Sheet component)
+    // The sheet contains a "Play" button with exact text (not "Play Next")
+    await expect(page.getByRole('button', { name: 'play_arrow Play' })).toBeVisible({ timeout: 5000 })
+  })
+
+  test('can run pattern and UI shows running state', async ({ page }) => {
+    await page.goto('/')
+
+    // Wait for patterns
+    await expect(page.getByText('star.thr')).toBeVisible()
+
+    // Click pattern to open detail
+    await page.getByText('star.thr').click()
+
+    // Wait for detail panel
+    await page.waitForTimeout(500)
+
+    // Find and click run button
+    const runButton = page.getByRole('button', { name: /run|play/i }).first()
+    await expect(runButton).toBeVisible()
+    await runButton.click()
+
+    // Verify API was called and status updated
+    await page.waitForTimeout(500)
+    const status = getMockStatus()
+    expect(status.is_running).toBe(true)
+    expect(status.current_file).toContain('star')
+  })
+
+  test('search filters patterns correctly', async ({ page }) => {
+    await page.goto('/')
+
+    // Wait for patterns
+    await expect(page.getByText('star.thr')).toBeVisible()
+    await expect(page.getByText('spiral.thr')).toBeVisible()
+
+    // Type in search
+    const searchInput = page.getByPlaceholder(/search/i)
+    await searchInput.fill('spiral')
+
+    // Only spiral should be visible
+    await expect(page.getByText('spiral.thr')).toBeVisible()
+    await expect(page.getByText('star.thr')).not.toBeVisible()
+  })
+})

+ 57 - 0
frontend/e2e/playlist-flow.spec.ts

@@ -0,0 +1,57 @@
+import { test, expect } from '@playwright/test'
+import { setupApiMocks, resetMockStatus, getMockStatus } from './mocks/api'
+
+test.describe('Playlist Flow E2E', () => {
+  test.beforeEach(async ({ page }) => {
+    resetMockStatus()
+    await setupApiMocks(page)
+  })
+
+  test('navigates to playlists page and displays playlists', async ({ page }) => {
+    await page.goto('/playlists')
+
+    // Wait for playlists to load
+    await expect(page.getByText('default')).toBeVisible()
+    await expect(page.getByText('favorites')).toBeVisible()
+  })
+
+  test('can select and run a playlist', async ({ page }) => {
+    await page.goto('/playlists')
+
+    // Wait for playlists
+    await expect(page.getByText('default')).toBeVisible()
+
+    // Click playlist to select
+    await page.getByText('default').click()
+
+    // Wait for the playlist patterns to load
+    // The Play button should become enabled once patterns are loaded
+    await page.waitForTimeout(1000)
+
+    // Find and click run button by its title attribute
+    const runButton = page.locator('button[title="Run Playlist"]')
+    await expect(runButton).toBeVisible({ timeout: 5000 })
+    await runButton.click()
+
+    // Verify playlist is running
+    await page.waitForTimeout(500)
+    const status = getMockStatus()
+    expect(status.is_running).toBe(true)
+    expect(status.playlist_mode).toBe(true)
+    expect(status.playlist_name).toBe('default')
+  })
+
+  test('can navigate between browse and playlists', async ({ page }) => {
+    // Start on browse
+    await page.goto('/')
+    await expect(page.getByText('star.thr')).toBeVisible()
+
+    // Navigate to playlists via nav
+    await page.getByRole('link', { name: /playlists/i }).click()
+    await expect(page.getByText('default')).toBeVisible()
+
+    // Navigate back to browse
+    await page.getByRole('link', { name: /browse/i }).click()
+    await expect(page.getByText('star.thr')).toBeVisible()
+  })
+})

+ 35 - 0
frontend/e2e/sample.spec.ts

@@ -0,0 +1,35 @@
+import { test, expect } from '@playwright/test'
+import { setupApiMocks, resetMockStatus } from './mocks/api'
+
+test.describe('App Infrastructure', () => {
+  test.beforeEach(async ({ page }) => {
+    resetMockStatus()
+    await setupApiMocks(page)
+  })
+
+  test('app loads and renders header', async ({ page }) => {
+    await page.goto('/')
+
+    // Header should be visible with app name
+    await expect(page.getByText('Dune Weaver')).toBeVisible()
+  })
+
+  test('app renders bottom navigation', async ({ page }) => {
+    await page.goto('/')
+
+    // Bottom nav should have all navigation items
+    const nav = page.locator('nav')
+    await expect(nav).toBeVisible()
+  })
+
+  test('dark mode toggle works', async ({ page }) => {
+    await page.goto('/')
+
+    // Find and click theme toggle in menu
+    await page.getByRole('button', { name: /menu/i }).click()
+
+    // Look for dark/light mode option
+    const themeButton = page.getByText(/dark mode|light mode/i)
+    await expect(themeButton).toBeVisible()
+  })
+})

+ 47 - 0
frontend/e2e/table-control.spec.ts

@@ -0,0 +1,47 @@
+import { test, expect } from '@playwright/test'
+import { setupApiMocks, resetMockStatus } from './mocks/api'
+
+test.describe('Table Control E2E', () => {
+  test.beforeEach(async ({ page }) => {
+    resetMockStatus()
+    await setupApiMocks(page)
+
+    // Add route for send_home
+    await page.route('**/send_home', async route => {
+      await route.fulfill({ json: { success: true } })
+    })
+  })
+
+  test('displays control page with buttons', async ({ page }) => {
+    await page.goto('/table-control')
+
+    // Should show control buttons
+    await expect(page.getByRole('button', { name: /home/i })).toBeVisible()
+    await expect(page.getByRole('button', { name: /stop/i })).toBeVisible()
+  })
+
+  test('can trigger home action', async ({ page }) => {
+    await page.goto('/table-control')
+
+    // Find and click home button
+    const homeButton = page.getByRole('button', { name: /home/i })
+    await expect(homeButton).toBeVisible()
+
+    // Click should not throw error
+    await homeButton.click()
+
+    // Button should still be visible (action completed)
+    await expect(homeButton).toBeVisible()
+  })
+
+  test('navigation bar shows all pages', async ({ page }) => {
+    await page.goto('/table-control')
+
+    // All nav items should be visible
+    await expect(page.getByRole('link', { name: /browse/i })).toBeVisible()
+    await expect(page.getByRole('link', { name: /playlists/i })).toBeVisible()
+    await expect(page.getByRole('link', { name: /control/i })).toBeVisible()
+    await expect(page.getByRole('link', { name: /led/i })).toBeVisible()
+    await expect(page.getByRole('link', { name: /settings/i })).toBeVisible()
+  })
+})

+ 23 - 0
frontend/eslint.config.js

@@ -0,0 +1,23 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+  globalIgnores(['dist']),
+  {
+    files: ['**/*.{ts,tsx}'],
+    extends: [
+      js.configs.recommended,
+      tseslint.configs.recommended,
+      reactHooks.configs.flat.recommended,
+      reactRefresh.configs.vite,
+    ],
+    languageOptions: {
+      ecmaVersion: 2020,
+      globals: globals.browser,
+    },
+  },
+])

+ 66 - 0
frontend/index.html

@@ -0,0 +1,66 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
+    <meta name="description" content="Control your kinetic sand table" />
+
+    <!-- PWA Meta Tags -->
+    <meta name="theme-color" content="#0a0a0a" />
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+    <meta name="apple-mobile-web-app-title" content="Dune Weaver" />
+    <meta name="mobile-web-app-capable" content="yes" />
+
+    <!-- Favicons - will be updated dynamically if custom logo exists -->
+    <link rel="icon" type="image/x-icon" href="/static/favicon.ico" id="favicon-ico" />
+    <link rel="icon" type="image/png" sizes="128x128" href="/static/favicon-128x128.png" id="favicon-128" />
+    <link rel="icon" type="image/png" sizes="96x96" href="/static/favicon-96x96.png" id="favicon-96" />
+    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" id="favicon-32" />
+    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" id="favicon-16" />
+    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" id="apple-touch-icon" />
+    <link rel="manifest" href="/api/manifest.webmanifest" id="manifest" />
+
+    <title>Dune Weaver</title>
+
+    <!-- Check for custom favicon -->
+    <script>
+      // Get base URL for active table (supports multi-table connections)
+      (function() {
+        var baseUrl = '';
+        try {
+          var stored = localStorage.getItem('duneweaver_tables');
+          var activeId = localStorage.getItem('duneweaver_active_table');
+          if (stored && activeId) {
+            var data = JSON.parse(stored);
+            var active = (data.tables || []).find(function(t) { return t.id === activeId; });
+            if (active && !active.isCurrent && active.url && active.url !== window.location.origin) {
+              baseUrl = active.url.replace(/\/$/, '');
+            }
+          }
+        } catch (e) {}
+
+        fetch(baseUrl + '/api/settings')
+          .then(function(r) { return r.json(); })
+          .then(function(settings) {
+            if (settings.app && settings.app.custom_logo) {
+              // Use generated icons with proper padding (not the raw uploaded logo)
+              document.getElementById('favicon-ico').href = baseUrl + '/static/custom/favicon.ico';
+              document.getElementById('apple-touch-icon').href = baseUrl + '/static/custom/apple-touch-icon.png';
+            }
+            if (settings.app && settings.app.name) {
+              document.title = settings.app.name;
+              // Also update PWA title
+              var appTitleMeta = document.querySelector('meta[name="apple-mobile-web-app-title"]');
+              if (appTitleMeta) appTitleMeta.content = settings.app.name;
+            }
+          })
+          .catch(function() {});
+      })();
+    </script>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 15135 - 0
frontend/package-lock.json

@@ -0,0 +1,15135 @@
+{
+  "name": "frontend",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "frontend",
+      "version": "0.0.0",
+      "dependencies": {
+        "@dnd-kit/core": "^6.3.1",
+        "@dnd-kit/sortable": "^10.0.0",
+        "@dnd-kit/utilities": "^3.2.2",
+        "@radix-ui/react-accordion": "^1.2.12",
+        "@radix-ui/react-dialog": "^1.1.15",
+        "@radix-ui/react-label": "^2.1.8",
+        "@radix-ui/react-popover": "^1.1.15",
+        "@radix-ui/react-progress": "^1.1.8",
+        "@radix-ui/react-radio-group": "^1.3.8",
+        "@radix-ui/react-select": "^2.2.6",
+        "@radix-ui/react-separator": "^1.1.8",
+        "@radix-ui/react-slider": "^1.3.6",
+        "@radix-ui/react-slot": "^1.2.4",
+        "@radix-ui/react-switch": "^1.2.6",
+        "@radix-ui/react-tabs": "^1.1.13",
+        "@radix-ui/react-tooltip": "^1.2.8",
+        "@tailwindcss/postcss": "^4.1.18",
+        "@tanstack/react-query": "^5.90.16",
+        "motion": "^12.27.1",
+        "next-themes": "^0.4.6",
+        "react": "^19.2.0",
+        "react-color": "^2.19.3",
+        "react-colorful": "^5.6.1",
+        "react-dom": "^19.2.0",
+        "react-resizable-panels": "^4.4.0",
+        "react-router-dom": "^7.12.0",
+        "sonner": "^2.0.7",
+        "zustand": "^5.0.9"
+      },
+      "devDependencies": {
+        "@eslint/js": "^9.39.1",
+        "@playwright/test": "^1.58.0",
+        "@testing-library/jest-dom": "^6.9.1",
+        "@testing-library/react": "^16.3.2",
+        "@testing-library/user-event": "^14.6.1",
+        "@types/node": "^24.10.4",
+        "@types/react": "^19.2.5",
+        "@types/react-color": "^3.0.13",
+        "@types/react-dom": "^19.2.3",
+        "@vitejs/plugin-react": "^5.1.1",
+        "@vitest/coverage-v8": "^3.2.4",
+        "@vitest/ui": "^3.2.4",
+        "autoprefixer": "^10.4.23",
+        "class-variance-authority": "^0.7.1",
+        "clsx": "^2.1.1",
+        "eslint": "^9.39.1",
+        "eslint-plugin-react-hooks": "^7.0.1",
+        "eslint-plugin-react-refresh": "^0.4.24",
+        "globals": "^16.5.0",
+        "jsdom": "^27.0.1",
+        "lucide-react": "^0.562.0",
+        "msw": "^2.12.7",
+        "postcss": "^8.5.6",
+        "shadcn": "^3.7.0",
+        "tailwind-merge": "^3.4.0",
+        "tailwindcss": "^4.1.18",
+        "tailwindcss-animate": "^1.0.7",
+        "typescript": "~5.9.3",
+        "typescript-eslint": "^8.46.4",
+        "vite": "^7.2.4",
+        "vite-plugin-pwa": "^1.2.0",
+        "vitest": "^3.2.4"
+      }
+    },
+    "node_modules/@adobe/css-tools": {
+      "version": "4.4.4",
+      "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+      "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@alloc/quick-lru": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@ampproject/remapping": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+      "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@antfu/ni": {
+      "version": "25.0.0",
+      "resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz",
+      "integrity": "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansis": "^4.0.0",
+        "fzf": "^0.5.2",
+        "package-manager-detector": "^1.3.0",
+        "tinyexec": "^1.0.1"
+      },
+      "bin": {
+        "na": "bin/na.mjs",
+        "nci": "bin/nci.mjs",
+        "ni": "bin/ni.mjs",
+        "nlx": "bin/nlx.mjs",
+        "nr": "bin/nr.mjs",
+        "nun": "bin/nun.mjs",
+        "nup": "bin/nup.mjs"
+      }
+    },
+    "node_modules/@asamuzakjp/css-color": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz",
+      "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/css-calc": "^2.1.4",
+        "@csstools/css-color-parser": "^3.1.0",
+        "@csstools/css-parser-algorithms": "^3.0.5",
+        "@csstools/css-tokenizer": "^3.0.4",
+        "lru-cache": "^11.2.4"
+      }
+    },
+    "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+      "version": "11.2.4",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+      "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/@asamuzakjp/dom-selector": {
+      "version": "6.7.6",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz",
+      "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/nwsapi": "^2.3.9",
+        "bidi-js": "^1.0.3",
+        "css-tree": "^3.1.0",
+        "is-potential-custom-element-name": "^1.0.1",
+        "lru-cache": "^11.2.4"
+      }
+    },
+    "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+      "version": "11.2.4",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+      "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/@asamuzakjp/nwsapi": {
+      "version": "2.3.9",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+      "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
+      "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
+      "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
+      "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.27.1",
+        "@babel/generator": "^7.28.5",
+        "@babel/helper-compilation-targets": "^7.27.2",
+        "@babel/helper-module-transforms": "^7.28.3",
+        "@babel/helpers": "^7.28.4",
+        "@babel/parser": "^7.28.5",
+        "@babel/template": "^7.27.2",
+        "@babel/traverse": "^7.28.5",
+        "@babel/types": "^7.28.5",
+        "@jridgewell/remapping": "^2.3.5",
+        "convert-source-map": "^2.0.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.3",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
+      "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.6",
+        "@babel/types": "^7.28.6",
+        "@jridgewell/gen-mapping": "^0.3.12",
+        "@jridgewell/trace-mapping": "^0.3.28",
+        "jsesc": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-annotate-as-pure": {
+      "version": "7.27.3",
+      "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
+      "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.27.3"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+      "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.28.6",
+        "@babel/helper-validator-option": "^7.27.1",
+        "browserslist": "^4.24.0",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-create-class-features-plugin": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz",
+      "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.27.3",
+        "@babel/helper-member-expression-to-functions": "^7.28.5",
+        "@babel/helper-optimise-call-expression": "^7.27.1",
+        "@babel/helper-replace-supers": "^7.28.6",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+        "@babel/traverse": "^7.28.6",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-create-regexp-features-plugin": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz",
+      "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.27.3",
+        "regexpu-core": "^6.3.1",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-define-polyfill-provider": {
+      "version": "0.6.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz",
+      "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-compilation-targets": "^7.27.2",
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "debug": "^4.4.1",
+        "lodash.debounce": "^4.0.8",
+        "resolve": "^1.22.10"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+      }
+    },
+    "node_modules/@babel/helper-globals": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-member-expression-to-functions": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
+      "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.28.5",
+        "@babel/types": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+      "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+      "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.28.6",
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-optimise-call-expression": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
+      "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+      "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-remap-async-to-generator": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz",
+      "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.27.1",
+        "@babel/helper-wrap-function": "^7.27.1",
+        "@babel/traverse": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-replace-supers": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
+      "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-member-expression-to-functions": "^7.28.5",
+        "@babel/helper-optimise-call-expression": "^7.27.1",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
+      "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.27.1",
+        "@babel/types": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-wrap-function": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz",
+      "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/template": "^7.28.6",
+        "@babel/traverse": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
+      "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/template": "^7.27.2",
+        "@babel/types": "^7.28.4"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
+      "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.6"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz",
+      "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "@babel/traverse": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz",
+      "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz",
+      "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz",
+      "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+        "@babel/plugin-transform-optional-chaining": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.13.0"
+      }
+    },
+    "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz",
+      "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-proposal-private-property-in-object": {
+      "version": "7.21.0-placeholder-for-preset-env.2",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
+      "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-import-assertions": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz",
+      "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-import-attributes": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz",
+      "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-jsx": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
+      "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-typescript": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz",
+      "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-unicode-sets-regex": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
+      "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+        "@babel/helper-plugin-utils": "^7.18.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-arrow-functions": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz",
+      "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-async-generator-functions": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz",
+      "integrity": "sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/helper-remap-async-to-generator": "^7.27.1",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-async-to-generator": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz",
+      "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/helper-remap-async-to-generator": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-block-scoped-functions": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz",
+      "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-block-scoping": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz",
+      "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-class-properties": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz",
+      "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-class-features-plugin": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-class-static-block": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz",
+      "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-class-features-plugin": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.12.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-classes": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz",
+      "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.27.3",
+        "@babel/helper-compilation-targets": "^7.28.6",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/helper-replace-supers": "^7.28.6",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-computed-properties": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz",
+      "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/template": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-destructuring": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz",
+      "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "@babel/traverse": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-dotall-regex": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz",
+      "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-duplicate-keys": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz",
+      "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz",
+      "integrity": "sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-dynamic-import": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz",
+      "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-explicit-resource-management": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz",
+      "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/plugin-transform-destructuring": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-exponentiation-operator": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz",
+      "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-export-namespace-from": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz",
+      "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-for-of": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz",
+      "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-function-name": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz",
+      "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-compilation-targets": "^7.27.1",
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "@babel/traverse": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-json-strings": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz",
+      "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-literals": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz",
+      "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-logical-assignment-operators": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz",
+      "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-member-expression-literals": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz",
+      "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-modules-amd": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz",
+      "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-transforms": "^7.27.1",
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-modules-commonjs": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz",
+      "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-transforms": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-modules-systemjs": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz",
+      "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-transforms": "^7.28.3",
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "@babel/traverse": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-modules-umd": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz",
+      "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-transforms": "^7.27.1",
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz",
+      "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.27.1",
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-new-target": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz",
+      "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz",
+      "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-numeric-separator": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz",
+      "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-object-rest-spread": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz",
+      "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-compilation-targets": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/plugin-transform-destructuring": "^7.28.5",
+        "@babel/plugin-transform-parameters": "^7.27.7",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-object-super": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz",
+      "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "@babel/helper-replace-supers": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-optional-catch-binding": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz",
+      "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-optional-chaining": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz",
+      "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-parameters": {
+      "version": "7.27.7",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz",
+      "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-private-methods": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz",
+      "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-class-features-plugin": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-private-property-in-object": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz",
+      "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.27.3",
+        "@babel/helper-create-class-features-plugin": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-property-literals": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz",
+      "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-self": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+      "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-source": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+      "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-regenerator": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz",
+      "integrity": "sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-regexp-modifiers": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz",
+      "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-reserved-words": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz",
+      "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-shorthand-properties": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz",
+      "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-spread": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz",
+      "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-sticky-regex": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz",
+      "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-template-literals": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
+      "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-typeof-symbol": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz",
+      "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-typescript": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz",
+      "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.27.3",
+        "@babel/helper-create-class-features-plugin": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+        "@babel/plugin-syntax-typescript": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-unicode-escapes": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
+      "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-unicode-property-regex": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz",
+      "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-unicode-regex": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz",
+      "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.27.1",
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-unicode-sets-regex": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz",
+      "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/preset-env": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.6.tgz",
+      "integrity": "sha512-GaTI4nXDrs7l0qaJ6Rg06dtOXTBCG6TMDB44zbqofCIC4PqC7SEvmFFtpxzCDw9W5aJ7RKVshgXTLvLdBFV/qw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.28.6",
+        "@babel/helper-compilation-targets": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/helper-validator-option": "^7.27.1",
+        "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5",
+        "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1",
+        "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1",
+        "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1",
+        "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6",
+        "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
+        "@babel/plugin-syntax-import-assertions": "^7.28.6",
+        "@babel/plugin-syntax-import-attributes": "^7.28.6",
+        "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
+        "@babel/plugin-transform-arrow-functions": "^7.27.1",
+        "@babel/plugin-transform-async-generator-functions": "^7.28.6",
+        "@babel/plugin-transform-async-to-generator": "^7.28.6",
+        "@babel/plugin-transform-block-scoped-functions": "^7.27.1",
+        "@babel/plugin-transform-block-scoping": "^7.28.6",
+        "@babel/plugin-transform-class-properties": "^7.28.6",
+        "@babel/plugin-transform-class-static-block": "^7.28.6",
+        "@babel/plugin-transform-classes": "^7.28.6",
+        "@babel/plugin-transform-computed-properties": "^7.28.6",
+        "@babel/plugin-transform-destructuring": "^7.28.5",
+        "@babel/plugin-transform-dotall-regex": "^7.28.6",
+        "@babel/plugin-transform-duplicate-keys": "^7.27.1",
+        "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.28.6",
+        "@babel/plugin-transform-dynamic-import": "^7.27.1",
+        "@babel/plugin-transform-explicit-resource-management": "^7.28.6",
+        "@babel/plugin-transform-exponentiation-operator": "^7.28.6",
+        "@babel/plugin-transform-export-namespace-from": "^7.27.1",
+        "@babel/plugin-transform-for-of": "^7.27.1",
+        "@babel/plugin-transform-function-name": "^7.27.1",
+        "@babel/plugin-transform-json-strings": "^7.28.6",
+        "@babel/plugin-transform-literals": "^7.27.1",
+        "@babel/plugin-transform-logical-assignment-operators": "^7.28.6",
+        "@babel/plugin-transform-member-expression-literals": "^7.27.1",
+        "@babel/plugin-transform-modules-amd": "^7.27.1",
+        "@babel/plugin-transform-modules-commonjs": "^7.28.6",
+        "@babel/plugin-transform-modules-systemjs": "^7.28.5",
+        "@babel/plugin-transform-modules-umd": "^7.27.1",
+        "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1",
+        "@babel/plugin-transform-new-target": "^7.27.1",
+        "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
+        "@babel/plugin-transform-numeric-separator": "^7.28.6",
+        "@babel/plugin-transform-object-rest-spread": "^7.28.6",
+        "@babel/plugin-transform-object-super": "^7.27.1",
+        "@babel/plugin-transform-optional-catch-binding": "^7.28.6",
+        "@babel/plugin-transform-optional-chaining": "^7.28.6",
+        "@babel/plugin-transform-parameters": "^7.27.7",
+        "@babel/plugin-transform-private-methods": "^7.28.6",
+        "@babel/plugin-transform-private-property-in-object": "^7.28.6",
+        "@babel/plugin-transform-property-literals": "^7.27.1",
+        "@babel/plugin-transform-regenerator": "^7.28.6",
+        "@babel/plugin-transform-regexp-modifiers": "^7.28.6",
+        "@babel/plugin-transform-reserved-words": "^7.27.1",
+        "@babel/plugin-transform-shorthand-properties": "^7.27.1",
+        "@babel/plugin-transform-spread": "^7.28.6",
+        "@babel/plugin-transform-sticky-regex": "^7.27.1",
+        "@babel/plugin-transform-template-literals": "^7.27.1",
+        "@babel/plugin-transform-typeof-symbol": "^7.27.1",
+        "@babel/plugin-transform-unicode-escapes": "^7.27.1",
+        "@babel/plugin-transform-unicode-property-regex": "^7.28.6",
+        "@babel/plugin-transform-unicode-regex": "^7.27.1",
+        "@babel/plugin-transform-unicode-sets-regex": "^7.28.6",
+        "@babel/preset-modules": "0.1.6-no-external-plugins",
+        "babel-plugin-polyfill-corejs2": "^0.4.14",
+        "babel-plugin-polyfill-corejs3": "^0.13.0",
+        "babel-plugin-polyfill-regenerator": "^0.6.5",
+        "core-js-compat": "^3.43.0",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/preset-modules": {
+      "version": "0.1.6-no-external-plugins",
+      "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
+      "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.0.0",
+        "@babel/types": "^7.4.4",
+        "esutils": "^2.0.2"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0"
+      }
+    },
+    "node_modules/@babel/preset-typescript": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz",
+      "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "@babel/helper-validator-option": "^7.27.1",
+        "@babel/plugin-syntax-jsx": "^7.27.1",
+        "@babel/plugin-transform-modules-commonjs": "^7.27.1",
+        "@babel/plugin-transform-typescript": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+      "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+      "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.28.6",
+        "@babel/parser": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
+      "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.28.6",
+        "@babel/generator": "^7.28.6",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/parser": "^7.28.6",
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.28.6",
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
+      "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@bcoe/v8-coverage": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+      "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@csstools/color-helpers": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+      "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@csstools/css-calc": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+      "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^3.0.5",
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-color-parser": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+      "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/color-helpers": "^5.1.0",
+        "@csstools/css-calc": "^2.1.4"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^3.0.5",
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-parser-algorithms": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+      "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-syntax-patches-for-csstree": {
+      "version": "1.0.25",
+      "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz",
+      "integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@csstools/css-tokenizer": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+      "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@dnd-kit/accessibility": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+      "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/core": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+      "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@dnd-kit/accessibility": "^3.1.1",
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/sortable": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+      "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+      "license": "MIT",
+      "dependencies": {
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "@dnd-kit/core": "^6.3.0",
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/utilities": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+      "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dotenvx/dotenvx": {
+      "version": "1.51.4",
+      "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.4.tgz",
+      "integrity": "sha512-AoziS8lRQ3ew/lY5J4JSlzYSN9Fo0oiyMBY37L3Bwq4mOQJT5GSrdZYLFPt6pH1LApDI3ZJceNyx+rHRACZSeQ==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "commander": "^11.1.0",
+        "dotenv": "^17.2.1",
+        "eciesjs": "^0.4.10",
+        "execa": "^5.1.1",
+        "fdir": "^6.2.0",
+        "ignore": "^5.3.0",
+        "object-treeify": "1.1.33",
+        "picomatch": "^4.0.2",
+        "which": "^4.0.0"
+      },
+      "bin": {
+        "dotenvx": "src/cli/dotenvx.js"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/@dotenvx/dotenvx/node_modules/commander": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
+      "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@dotenvx/dotenvx/node_modules/execa": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+      "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cross-spawn": "^7.0.3",
+        "get-stream": "^6.0.0",
+        "human-signals": "^2.1.0",
+        "is-stream": "^2.0.0",
+        "merge-stream": "^2.0.0",
+        "npm-run-path": "^4.0.1",
+        "onetime": "^5.1.2",
+        "signal-exit": "^3.0.3",
+        "strip-final-newline": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/execa?sponsor=1"
+      }
+    },
+    "node_modules/@dotenvx/dotenvx/node_modules/get-stream": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+      "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@dotenvx/dotenvx/node_modules/human-signals": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+      "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=10.17.0"
+      }
+    },
+    "node_modules/@dotenvx/dotenvx/node_modules/is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@dotenvx/dotenvx/node_modules/isexe": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+      "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+      "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@dotenvx/dotenvx/node_modules/onetime": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+      "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mimic-fn": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+      "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/@dotenvx/dotenvx/node_modules/which": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+      "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^3.1.1"
+      },
+      "bin": {
+        "node-which": "bin/which.js"
+      },
+      "engines": {
+        "node": "^16.13.0 || >=18.0.0"
+      }
+    },
+    "node_modules/@ecies/ciphers": {
+      "version": "0.2.5",
+      "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.5.tgz",
+      "integrity": "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "bun": ">=1",
+        "deno": ">=2",
+        "node": ">=16"
+      },
+      "peerDependencies": {
+        "@noble/ciphers": "^1.0.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+      "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+      "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+      "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+      "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+      "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+      "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+      "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+      "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+      "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+      "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+      "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+      "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+      "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+      "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+      "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+      "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+      "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+      "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+      "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+      "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+      "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+      "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+      "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils": {
+      "version": "4.9.1",
+      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+      "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "eslint-visitor-keys": "^3.4.3"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint-community/regexpp": {
+      "version": "4.12.2",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+      "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@eslint/config-array": {
+      "version": "0.21.1",
+      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+      "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/object-schema": "^2.1.7",
+        "debug": "^4.3.1",
+        "minimatch": "^3.1.2"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/config-helpers": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+      "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/core": "^0.17.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/core": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+      "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@types/json-schema": "^7.0.15"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
+      "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^10.0.1",
+        "globals": "^14.0.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.1",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/globals": {
+      "version": "14.0.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+      "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@eslint/js": {
+      "version": "9.39.2",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
+      "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      }
+    },
+    "node_modules/@eslint/object-schema": {
+      "version": "2.1.7",
+      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+      "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/plugin-kit": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+      "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/core": "^0.17.0",
+        "levn": "^0.4.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@floating-ui/core": {
+      "version": "1.7.3",
+      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
+      "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.10"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.7.4",
+      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
+      "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/core": "^1.7.3",
+        "@floating-ui/utils": "^0.2.10"
+      }
+    },
+    "node_modules/@floating-ui/react-dom": {
+      "version": "2.1.6",
+      "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
+      "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/dom": "^1.7.4"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.10",
+      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+      "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+      "license": "MIT"
+    },
+    "node_modules/@hono/node-server": {
+      "version": "1.19.9",
+      "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
+      "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.14.1"
+      },
+      "peerDependencies": {
+        "hono": "^4"
+      }
+    },
+    "node_modules/@humanfs/core": {
+      "version": "0.19.1",
+      "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+      "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanfs/node": {
+      "version": "0.16.7",
+      "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+      "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@humanfs/core": "^0.19.1",
+        "@humanwhocodes/retry": "^0.4.0"
+      },
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.22"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@humanwhocodes/retry": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+      "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@icons/material": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
+      "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "*"
+      }
+    },
+    "node_modules/@inquirer/ansi": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
+      "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@inquirer/confirm": {
+      "version": "5.1.21",
+      "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz",
+      "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@inquirer/core": "^10.3.2",
+        "@inquirer/type": "^3.0.10"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@types/node": ">=18"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@inquirer/core": {
+      "version": "10.3.2",
+      "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz",
+      "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@inquirer/ansi": "^1.0.2",
+        "@inquirer/figures": "^1.0.15",
+        "@inquirer/type": "^3.0.10",
+        "cli-width": "^4.1.0",
+        "mute-stream": "^2.0.0",
+        "signal-exit": "^4.1.0",
+        "wrap-ansi": "^6.2.0",
+        "yoctocolors-cjs": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@types/node": ">=18"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@inquirer/figures": {
+      "version": "1.0.15",
+      "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz",
+      "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@inquirer/type": {
+      "version": "3.0.10",
+      "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz",
+      "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@types/node": ">=18"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@isaacs/balanced-match": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
+      "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/@isaacs/brace-expansion": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
+      "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@isaacs/balanced-match": "^4.0.1"
+      },
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/@isaacs/cliui": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+      "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^5.1.2",
+        "string-width-cjs": "npm:string-width@^4.2.0",
+        "strip-ansi": "^7.0.1",
+        "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+        "wrap-ansi": "^8.1.0",
+        "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
+      "version": "6.2.3",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+      "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+      "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@isaacs/cliui/node_modules/string-width": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+      "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "eastasianwidth": "^0.2.0",
+        "emoji-regex": "^9.2.2",
+        "strip-ansi": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+      "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^6.1.0",
+        "string-width": "^5.0.1",
+        "strip-ansi": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/@istanbuljs/schema": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+      "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/source-map": {
+      "version": "0.3.11",
+      "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
+      "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.25"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk": {
+      "version": "1.25.2",
+      "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
+      "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@hono/node-server": "^1.19.7",
+        "ajv": "^8.17.1",
+        "ajv-formats": "^3.0.1",
+        "content-type": "^1.0.5",
+        "cors": "^2.8.5",
+        "cross-spawn": "^7.0.5",
+        "eventsource": "^3.0.2",
+        "eventsource-parser": "^3.0.0",
+        "express": "^5.0.1",
+        "express-rate-limit": "^7.5.0",
+        "jose": "^6.1.1",
+        "json-schema-typed": "^8.0.2",
+        "pkce-challenge": "^5.0.0",
+        "raw-body": "^3.0.0",
+        "zod": "^3.25 || ^4.0",
+        "zod-to-json-schema": "^3.25.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@cfworker/json-schema": "^4.1.1",
+        "zod": "^3.25 || ^4.0"
+      },
+      "peerDependenciesMeta": {
+        "@cfworker/json-schema": {
+          "optional": true
+        },
+        "zod": {
+          "optional": false
+        }
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
+      "version": "8.17.1",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+      "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@mswjs/interceptors": {
+      "version": "0.40.0",
+      "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz",
+      "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@open-draft/deferred-promise": "^2.2.0",
+        "@open-draft/logger": "^0.3.0",
+        "@open-draft/until": "^2.0.0",
+        "is-node-process": "^1.2.0",
+        "outvariant": "^1.4.3",
+        "strict-event-emitter": "^0.5.1"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@noble/ciphers": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
+      "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^14.21.3 || >=16"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/@noble/curves": {
+      "version": "1.9.7",
+      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
+      "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@noble/hashes": "1.8.0"
+      },
+      "engines": {
+        "node": "^14.21.3 || >=16"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/@noble/hashes": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+      "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^14.21.3 || >=16"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@open-draft/deferred-promise": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
+      "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@open-draft/logger": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
+      "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-node-process": "^1.2.0",
+        "outvariant": "^1.4.0"
+      }
+    },
+    "node_modules/@open-draft/until": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
+      "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@pkgjs/parseargs": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+      "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@playwright/test": {
+      "version": "1.58.0",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
+      "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright": "1.58.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@polka/url": {
+      "version": "1.0.0-next.29",
+      "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+      "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@radix-ui/number": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
+      "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
+      "license": "MIT"
+    },
+    "node_modules/@radix-ui/primitive": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+      "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+      "license": "MIT"
+    },
+    "node_modules/@radix-ui/react-accordion": {
+      "version": "1.2.12",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
+      "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-collapsible": "1.1.12",
+        "@radix-ui/react-collection": "1.1.7",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-direction": "1.1.1",
+        "@radix-ui/react-id": "1.1.1",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-controllable-state": "1.2.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-arrow": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
+      "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-collapsible": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
+      "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-id": "1.1.1",
+        "@radix-ui/react-presence": "1.1.5",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-controllable-state": "1.2.2",
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-collection": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+      "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-slot": "1.2.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-compose-refs": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+      "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-context": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+      "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog": {
+      "version": "1.1.15",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
+      "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-dismissable-layer": "1.1.11",
+        "@radix-ui/react-focus-guards": "1.1.3",
+        "@radix-ui/react-focus-scope": "1.1.7",
+        "@radix-ui/react-id": "1.1.1",
+        "@radix-ui/react-portal": "1.1.9",
+        "@radix-ui/react-presence": "1.1.5",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-slot": "1.2.3",
+        "@radix-ui/react-use-controllable-state": "1.2.2",
+        "aria-hidden": "^1.2.4",
+        "react-remove-scroll": "^2.6.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-direction": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+      "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dismissable-layer": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
+      "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1",
+        "@radix-ui/react-use-escape-keydown": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-focus-guards": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+      "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-focus-scope": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+      "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-id": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+      "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-label": {
+      "version": "2.1.8",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
+      "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.4"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+      "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-slot": "1.2.4"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-popover": {
+      "version": "1.1.15",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
+      "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-dismissable-layer": "1.1.11",
+        "@radix-ui/react-focus-guards": "1.1.3",
+        "@radix-ui/react-focus-scope": "1.1.7",
+        "@radix-ui/react-id": "1.1.1",
+        "@radix-ui/react-popper": "1.2.8",
+        "@radix-ui/react-portal": "1.1.9",
+        "@radix-ui/react-presence": "1.1.5",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-slot": "1.2.3",
+        "@radix-ui/react-use-controllable-state": "1.2.2",
+        "aria-hidden": "^1.2.4",
+        "react-remove-scroll": "^2.6.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-popper": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
+      "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/react-dom": "^2.0.0",
+        "@radix-ui/react-arrow": "1.1.7",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1",
+        "@radix-ui/react-use-layout-effect": "1.1.1",
+        "@radix-ui/react-use-rect": "1.1.1",
+        "@radix-ui/react-use-size": "1.1.1",
+        "@radix-ui/rect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-portal": {
+      "version": "1.1.9",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+      "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-presence": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+      "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-primitive": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+      "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-slot": "1.2.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-progress": {
+      "version": "1.1.8",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz",
+      "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-context": "1.1.3",
+        "@radix-ui/react-primitive": "2.1.4"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz",
+      "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+      "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-slot": "1.2.4"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-radio-group": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
+      "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-direction": "1.1.1",
+        "@radix-ui/react-presence": "1.1.5",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-roving-focus": "1.1.11",
+        "@radix-ui/react-use-controllable-state": "1.2.2",
+        "@radix-ui/react-use-previous": "1.1.1",
+        "@radix-ui/react-use-size": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-roving-focus": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
+      "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-collection": "1.1.7",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-direction": "1.1.1",
+        "@radix-ui/react-id": "1.1.1",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1",
+        "@radix-ui/react-use-controllable-state": "1.2.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-select": {
+      "version": "2.2.6",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
+      "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/number": "1.1.1",
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-collection": "1.1.7",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-direction": "1.1.1",
+        "@radix-ui/react-dismissable-layer": "1.1.11",
+        "@radix-ui/react-focus-guards": "1.1.3",
+        "@radix-ui/react-focus-scope": "1.1.7",
+        "@radix-ui/react-id": "1.1.1",
+        "@radix-ui/react-popper": "1.2.8",
+        "@radix-ui/react-portal": "1.1.9",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-slot": "1.2.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1",
+        "@radix-ui/react-use-controllable-state": "1.2.2",
+        "@radix-ui/react-use-layout-effect": "1.1.1",
+        "@radix-ui/react-use-previous": "1.1.1",
+        "@radix-ui/react-visually-hidden": "1.2.3",
+        "aria-hidden": "^1.2.4",
+        "react-remove-scroll": "^2.6.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-separator": {
+      "version": "1.1.8",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
+      "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.4"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+      "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-slot": "1.2.4"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-slider": {
+      "version": "1.3.6",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
+      "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/number": "1.1.1",
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-collection": "1.1.7",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-direction": "1.1.1",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-controllable-state": "1.2.2",
+        "@radix-ui/react-use-layout-effect": "1.1.1",
+        "@radix-ui/react-use-previous": "1.1.1",
+        "@radix-ui/react-use-size": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-slot": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
+      "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-switch": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
+      "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-controllable-state": "1.2.2",
+        "@radix-ui/react-use-previous": "1.1.1",
+        "@radix-ui/react-use-size": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-tabs": {
+      "version": "1.1.13",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
+      "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-direction": "1.1.1",
+        "@radix-ui/react-id": "1.1.1",
+        "@radix-ui/react-presence": "1.1.5",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-roving-focus": "1.1.11",
+        "@radix-ui/react-use-controllable-state": "1.2.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-tooltip": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
+      "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-dismissable-layer": "1.1.11",
+        "@radix-ui/react-id": "1.1.1",
+        "@radix-ui/react-popper": "1.2.8",
+        "@radix-ui/react-portal": "1.1.9",
+        "@radix-ui/react-presence": "1.1.5",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-slot": "1.2.3",
+        "@radix-ui/react-use-controllable-state": "1.2.2",
+        "@radix-ui/react-visually-hidden": "1.2.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-callback-ref": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+      "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-controllable-state": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+      "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-effect-event": "0.0.2",
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-effect-event": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
+      "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-escape-keydown": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+      "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-callback-ref": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-layout-effect": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+      "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-previous": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
+      "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-rect": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
+      "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/rect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-size": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
+      "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-visually-hidden": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
+      "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/rect": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
+      "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
+      "license": "MIT"
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-beta.53",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
+      "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/plugin-node-resolve": {
+      "version": "15.3.1",
+      "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
+      "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rollup/pluginutils": "^5.0.1",
+        "@types/resolve": "1.20.2",
+        "deepmerge": "^4.2.2",
+        "is-module": "^1.0.0",
+        "resolve": "^1.22.1"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^2.78.0||^3.0.0||^4.0.0"
+      },
+      "peerDependenciesMeta": {
+        "rollup": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@rollup/plugin-terser": {
+      "version": "0.4.4",
+      "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz",
+      "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "serialize-javascript": "^6.0.1",
+        "smob": "^1.0.0",
+        "terser": "^5.17.4"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^2.0.0||^3.0.0||^4.0.0"
+      },
+      "peerDependenciesMeta": {
+        "rollup": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@rollup/pluginutils": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+      "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "estree-walker": "^2.0.2",
+        "picomatch": "^4.0.2"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+      },
+      "peerDependenciesMeta": {
+        "rollup": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
+      "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
+      "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
+      "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
+      "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
+      "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
+      "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
+      "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
+      "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
+      "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
+      "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
+      "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
+      "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
+      "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
+      "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
+      "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
+      "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
+      "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
+      "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
+      "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
+      "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
+      "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
+      "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
+      "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
+      "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
+      "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@sec-ant/readable-stream": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
+      "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@sindresorhus/merge-streams": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
+      "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@surma/rollup-plugin-off-main-thread": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
+      "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "ejs": "^3.1.6",
+        "json5": "^2.2.0",
+        "magic-string": "^0.25.0",
+        "string.prototype.matchall": "^4.0.6"
+      }
+    },
+    "node_modules/@surma/rollup-plugin-off-main-thread/node_modules/magic-string": {
+      "version": "0.25.9",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
+      "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "sourcemap-codec": "^1.4.8"
+      }
+    },
+    "node_modules/@tailwindcss/node": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
+      "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/remapping": "^2.3.4",
+        "enhanced-resolve": "^5.18.3",
+        "jiti": "^2.6.1",
+        "lightningcss": "1.30.2",
+        "magic-string": "^0.30.21",
+        "source-map-js": "^1.2.1",
+        "tailwindcss": "4.1.18"
+      }
+    },
+    "node_modules/@tailwindcss/oxide": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+      "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10"
+      },
+      "optionalDependencies": {
+        "@tailwindcss/oxide-android-arm64": "4.1.18",
+        "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+        "@tailwindcss/oxide-darwin-x64": "4.1.18",
+        "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+        "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+        "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+        "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+        "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+        "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+        "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+        "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+        "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-android-arm64": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+      "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-arm64": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
+      "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-x64": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
+      "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-freebsd-x64": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
+      "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
+      "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
+      "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
+      "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
+      "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
+      "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
+      "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+      "bundleDependencies": [
+        "@napi-rs/wasm-runtime",
+        "@emnapi/core",
+        "@emnapi/runtime",
+        "@tybys/wasm-util",
+        "@emnapi/wasi-threads",
+        "tslib"
+      ],
+      "cpu": [
+        "wasm32"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "^1.7.1",
+        "@emnapi/runtime": "^1.7.1",
+        "@emnapi/wasi-threads": "^1.1.0",
+        "@napi-rs/wasm-runtime": "^1.1.0",
+        "@tybys/wasm-util": "^0.10.1",
+        "tslib": "^2.4.0"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
+      "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
+      "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/postcss": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
+      "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==",
+      "license": "MIT",
+      "dependencies": {
+        "@alloc/quick-lru": "^5.2.0",
+        "@tailwindcss/node": "4.1.18",
+        "@tailwindcss/oxide": "4.1.18",
+        "postcss": "^8.4.41",
+        "tailwindcss": "4.1.18"
+      }
+    },
+    "node_modules/@tanstack/query-core": {
+      "version": "5.90.16",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz",
+      "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      }
+    },
+    "node_modules/@tanstack/react-query": {
+      "version": "5.90.16",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz",
+      "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@tanstack/query-core": "5.90.16"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      },
+      "peerDependencies": {
+        "react": "^18 || ^19"
+      }
+    },
+    "node_modules/@testing-library/dom": {
+      "version": "10.4.1",
+      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+      "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.10.4",
+        "@babel/runtime": "^7.12.5",
+        "@types/aria-query": "^5.0.1",
+        "aria-query": "5.3.0",
+        "dom-accessibility-api": "^0.5.9",
+        "lz-string": "^1.5.0",
+        "picocolors": "1.1.1",
+        "pretty-format": "^27.0.2"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@testing-library/jest-dom": {
+      "version": "6.9.1",
+      "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+      "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@adobe/css-tools": "^4.4.0",
+        "aria-query": "^5.0.0",
+        "css.escape": "^1.5.1",
+        "dom-accessibility-api": "^0.6.3",
+        "picocolors": "^1.1.1",
+        "redent": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=14",
+        "npm": ">=6",
+        "yarn": ">=1"
+      }
+    },
+    "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+      "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@testing-library/react": {
+      "version": "16.3.2",
+      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+      "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.12.5"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@testing-library/dom": "^10.0.0",
+        "@types/react": "^18.0.0 || ^19.0.0",
+        "@types/react-dom": "^18.0.0 || ^19.0.0",
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@testing-library/user-event": {
+      "version": "14.6.1",
+      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+      "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12",
+        "npm": ">=6"
+      },
+      "peerDependencies": {
+        "@testing-library/dom": ">=7.21.4"
+      }
+    },
+    "node_modules/@ts-morph/common": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
+      "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fast-glob": "^3.3.3",
+        "minimatch": "^10.0.1",
+        "path-browserify": "^1.0.1"
+      }
+    },
+    "node_modules/@ts-morph/common/node_modules/minimatch": {
+      "version": "10.1.1",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
+      "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "@isaacs/brace-expansion": "^5.0.0"
+      },
+      "engines": {
+        "node": "20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/@types/aria-query": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+      "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/@types/babel__core": {
+      "version": "7.20.5",
+      "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+      "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "@types/babel__generator": "*",
+        "@types/babel__template": "*",
+        "@types/babel__traverse": "*"
+      }
+    },
+    "node_modules/@types/babel__generator": {
+      "version": "7.27.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+      "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__template": {
+      "version": "7.4.4",
+      "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+      "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__traverse": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+      "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.2"
+      }
+    },
+    "node_modules/@types/chai": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/deep-eql": "*",
+        "assertion-error": "^2.0.1"
+      }
+    },
+    "node_modules/@types/deep-eql": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/json-schema": {
+      "version": "7.0.15",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "24.10.4",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
+      "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~7.16.0"
+      }
+    },
+    "node_modules/@types/react": {
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
+      "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-color": {
+      "version": "3.0.13",
+      "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.13.tgz",
+      "integrity": "sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/reactcss": "*"
+      },
+      "peerDependencies": {
+        "@types/react": "*"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+      "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+      "devOptional": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.2.0"
+      }
+    },
+    "node_modules/@types/reactcss": {
+      "version": "1.2.13",
+      "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.13.tgz",
+      "integrity": "sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*"
+      }
+    },
+    "node_modules/@types/resolve": {
+      "version": "1.20.2",
+      "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
+      "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/statuses": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
+      "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/validate-npm-package-name": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
+      "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@typescript-eslint/eslint-plugin": {
+      "version": "8.52.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz",
+      "integrity": "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/regexpp": "^4.12.2",
+        "@typescript-eslint/scope-manager": "8.52.0",
+        "@typescript-eslint/type-utils": "8.52.0",
+        "@typescript-eslint/utils": "8.52.0",
+        "@typescript-eslint/visitor-keys": "8.52.0",
+        "ignore": "^7.0.5",
+        "natural-compare": "^1.4.0",
+        "ts-api-utils": "^2.4.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "@typescript-eslint/parser": "^8.52.0",
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+      "version": "7.0.5",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+      "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/@typescript-eslint/parser": {
+      "version": "8.52.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz",
+      "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/scope-manager": "8.52.0",
+        "@typescript-eslint/types": "8.52.0",
+        "@typescript-eslint/typescript-estree": "8.52.0",
+        "@typescript-eslint/visitor-keys": "8.52.0",
+        "debug": "^4.4.3"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/project-service": {
+      "version": "8.52.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.52.0.tgz",
+      "integrity": "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/tsconfig-utils": "^8.52.0",
+        "@typescript-eslint/types": "^8.52.0",
+        "debug": "^4.4.3"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/scope-manager": {
+      "version": "8.52.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.52.0.tgz",
+      "integrity": "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.52.0",
+        "@typescript-eslint/visitor-keys": "8.52.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/tsconfig-utils": {
+      "version": "8.52.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.52.0.tgz",
+      "integrity": "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/type-utils": {
+      "version": "8.52.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.52.0.tgz",
+      "integrity": "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.52.0",
+        "@typescript-eslint/typescript-estree": "8.52.0",
+        "@typescript-eslint/utils": "8.52.0",
+        "debug": "^4.4.3",
+        "ts-api-utils": "^2.4.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/types": {
+      "version": "8.52.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.52.0.tgz",
+      "integrity": "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree": {
+      "version": "8.52.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.52.0.tgz",
+      "integrity": "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/project-service": "8.52.0",
+        "@typescript-eslint/tsconfig-utils": "8.52.0",
+        "@typescript-eslint/types": "8.52.0",
+        "@typescript-eslint/visitor-keys": "8.52.0",
+        "debug": "^4.4.3",
+        "minimatch": "^9.0.5",
+        "semver": "^7.7.3",
+        "tinyglobby": "^0.2.15",
+        "ts-api-utils": "^2.4.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+      "version": "7.7.3",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+      "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@typescript-eslint/utils": {
+      "version": "8.52.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.52.0.tgz",
+      "integrity": "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.9.1",
+        "@typescript-eslint/scope-manager": "8.52.0",
+        "@typescript-eslint/types": "8.52.0",
+        "@typescript-eslint/typescript-estree": "8.52.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/visitor-keys": {
+      "version": "8.52.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.52.0.tgz",
+      "integrity": "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.52.0",
+        "eslint-visitor-keys": "^4.2.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
+      "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.28.5",
+        "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+        "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+        "@rolldown/pluginutils": "1.0.0-beta.53",
+        "@types/babel__core": "^7.20.5",
+        "react-refresh": "^0.18.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+      }
+    },
+    "node_modules/@vitest/coverage-v8": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
+      "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@ampproject/remapping": "^2.3.0",
+        "@bcoe/v8-coverage": "^1.0.2",
+        "ast-v8-to-istanbul": "^0.3.3",
+        "debug": "^4.4.1",
+        "istanbul-lib-coverage": "^3.2.2",
+        "istanbul-lib-report": "^3.0.1",
+        "istanbul-lib-source-maps": "^5.0.6",
+        "istanbul-reports": "^3.1.7",
+        "magic-string": "^0.30.17",
+        "magicast": "^0.3.5",
+        "std-env": "^3.9.0",
+        "test-exclude": "^7.0.1",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@vitest/browser": "3.2.4",
+        "vitest": "3.2.4"
+      },
+      "peerDependenciesMeta": {
+        "@vitest/browser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/expect": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+      "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/spy": "3.2.4",
+        "@vitest/utils": "3.2.4",
+        "chai": "^5.2.0",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/mocker": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+      "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "3.2.4",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.17"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/mocker/node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/@vitest/pretty-format": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+      "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/runner": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+      "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "3.2.4",
+        "pathe": "^2.0.3",
+        "strip-literal": "^3.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+      "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "3.2.4",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+      "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyspy": "^4.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/ui": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz",
+      "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "3.2.4",
+        "fflate": "^0.8.2",
+        "flatted": "^3.3.3",
+        "pathe": "^2.0.3",
+        "sirv": "^3.0.1",
+        "tinyglobby": "^0.2.14",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "vitest": "3.2.4"
+      }
+    },
+    "node_modules/@vitest/utils": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+      "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "3.2.4",
+        "loupe": "^3.1.4",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "8.15.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+      "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "7.1.4",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ajv-formats": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+      "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "ajv": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/ajv-formats/node_modules/ajv": {
+      "version": "8.17.1",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+      "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ajv-formats/node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/ansi-regex": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+      "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/ansis": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
+      "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true,
+      "license": "Python-2.0"
+    },
+    "node_modules/aria-hidden": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+      "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/aria-query": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+      "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "dequal": "^2.0.3"
+      }
+    },
+    "node_modules/array-buffer-byte-length": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+      "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "is-array-buffer": "^3.0.5"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/arraybuffer.prototype.slice": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+      "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "array-buffer-byte-length": "^1.0.1",
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.5",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "is-array-buffer": "^3.0.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/ast-types": {
+      "version": "0.16.1",
+      "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
+      "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/ast-v8-to-istanbul": {
+      "version": "0.3.10",
+      "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
+      "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "^0.3.31",
+        "estree-walker": "^3.0.3",
+        "js-tokens": "^9.0.1"
+      }
+    },
+    "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+      "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/async": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+      "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/async-function": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+      "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/at-least-node": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+      "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">= 4.0.0"
+      }
+    },
+    "node_modules/autoprefixer": {
+      "version": "10.4.23",
+      "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
+      "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "browserslist": "^4.28.1",
+        "caniuse-lite": "^1.0.30001760",
+        "fraction.js": "^5.3.4",
+        "picocolors": "^1.1.1",
+        "postcss-value-parser": "^4.2.0"
+      },
+      "bin": {
+        "autoprefixer": "bin/autoprefixer"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/available-typed-arrays": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+      "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "possible-typed-array-names": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/babel-plugin-polyfill-corejs2": {
+      "version": "0.4.14",
+      "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
+      "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.27.7",
+        "@babel/helper-define-polyfill-provider": "^0.6.5",
+        "semver": "^6.3.1"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+      }
+    },
+    "node_modules/babel-plugin-polyfill-corejs3": {
+      "version": "0.13.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz",
+      "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-define-polyfill-provider": "^0.6.5",
+        "core-js-compat": "^3.43.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+      }
+    },
+    "node_modules/babel-plugin-polyfill-regenerator": {
+      "version": "0.6.5",
+      "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz",
+      "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-define-polyfill-provider": "^0.6.5"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.9.13",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
+      "integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.js"
+      }
+    },
+    "node_modules/bidi-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+      "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "require-from-string": "^2.0.2"
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+      "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^1.0.5",
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.0",
+        "iconv-lite": "^0.7.0",
+        "on-finished": "^2.4.1",
+        "qs": "^6.14.1",
+        "raw-body": "^3.0.1",
+        "type-is": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.1",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+      "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "baseline-browser-mapping": "^2.9.0",
+        "caniuse-lite": "^1.0.30001759",
+        "electron-to-chromium": "^1.5.263",
+        "node-releases": "^2.0.27",
+        "update-browserslist-db": "^1.2.0"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/bundle-name": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
+      "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "run-applescript": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/cac": {
+      "version": "6.7.14",
+      "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+      "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/call-bind": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+      "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.0",
+        "es-define-property": "^1.0.0",
+        "get-intrinsic": "^1.2.4",
+        "set-function-length": "^1.2.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001763",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
+      "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/chai": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+      "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "assertion-error": "^2.0.1",
+        "check-error": "^2.1.1",
+        "deep-eql": "^5.0.1",
+        "loupe": "^3.1.0",
+        "pathval": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/check-error": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+      "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      }
+    },
+    "node_modules/class-variance-authority": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+      "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "clsx": "^2.1.1"
+      },
+      "funding": {
+        "url": "https://polar.sh/cva"
+      }
+    },
+    "node_modules/cli-cursor": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
+      "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "restore-cursor": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/cli-spinners": {
+      "version": "2.9.2",
+      "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
+      "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/cli-width": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+      "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+      "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.1",
+        "wrap-ansi": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/cliui/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/cliui/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cliui/node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/cliui/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/cliui/node_modules/wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/clsx": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+      "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/code-block-writer": {
+      "version": "13.0.3",
+      "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
+      "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/commander": {
+      "version": "14.0.2",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
+      "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/common-tags": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz",
+      "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/content-disposition": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
+      "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cookie": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/core-js-compat": {
+      "version": "3.47.0",
+      "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz",
+      "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "browserslist": "^4.28.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
+    "node_modules/cors": {
+      "version": "2.8.5",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+      "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/cosmiconfig": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
+      "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "env-paths": "^2.2.1",
+        "import-fresh": "^3.3.0",
+        "js-yaml": "^4.1.0",
+        "parse-json": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/d-fischer"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.9.5"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/crypto-random-string": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
+      "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/css-tree": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
+      "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mdn-data": "2.12.2",
+        "source-map-js": "^1.0.1"
+      },
+      "engines": {
+        "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+      }
+    },
+    "node_modules/css.escape": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+      "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "cssesc": "bin/cssesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/cssstyle": {
+      "version": "5.3.7",
+      "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
+      "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/css-color": "^4.1.1",
+        "@csstools/css-syntax-patches-for-csstree": "^1.0.21",
+        "css-tree": "^3.1.0",
+        "lru-cache": "^11.2.4"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/cssstyle/node_modules/lru-cache": {
+      "version": "11.2.4",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+      "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/data-uri-to-buffer": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
+      "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/data-urls": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz",
+      "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-mimetype": "^5.0.0",
+        "whatwg-url": "^15.1.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/data-urls/node_modules/tr46": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+      "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/data-urls/node_modules/webidl-conversions": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+      "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/data-urls/node_modules/whatwg-mimetype": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+      "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/data-urls/node_modules/whatwg-url": {
+      "version": "15.1.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
+      "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "^6.0.0",
+        "webidl-conversions": "^8.0.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/data-view-buffer": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+      "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "es-errors": "^1.3.0",
+        "is-data-view": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/data-view-byte-length": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+      "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "es-errors": "^1.3.0",
+        "is-data-view": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/inspect-js"
+      }
+    },
+    "node_modules/data-view-byte-offset": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+      "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "is-data-view": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decimal.js": {
+      "version": "10.6.0",
+      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/dedent": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
+      "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "babel-plugin-macros": "^3.1.0"
+      },
+      "peerDependenciesMeta": {
+        "babel-plugin-macros": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/deep-eql": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+      "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/deepmerge": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+      "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/default-browser": {
+      "version": "5.4.0",
+      "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz",
+      "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "bundle-name": "^4.1.0",
+        "default-browser-id": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/default-browser-id": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
+      "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/define-data-property": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/define-lazy-prop": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
+      "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/define-properties": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+      "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.0.1",
+        "has-property-descriptors": "^1.0.0",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/dequal": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/detect-node-es": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+      "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+      "license": "MIT"
+    },
+    "node_modules/diff": {
+      "version": "8.0.3",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
+      "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
+    "node_modules/dom-accessibility-api": {
+      "version": "0.5.16",
+      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+      "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/dotenv": {
+      "version": "17.2.3",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
+      "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/eastasianwidth": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+      "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/eciesjs": {
+      "version": "0.4.16",
+      "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.16.tgz",
+      "integrity": "sha512-dS5cbA9rA2VR4Ybuvhg6jvdmp46ubLn3E+px8cG/35aEDNclrqoCjg6mt0HYZ/M+OoESS3jSkCrqk1kWAEhWAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@ecies/ciphers": "^0.2.4",
+        "@noble/ciphers": "^1.3.0",
+        "@noble/curves": "^1.9.7",
+        "@noble/hashes": "^1.8.0"
+      },
+      "engines": {
+        "bun": ">=1",
+        "deno": ">=2",
+        "node": ">=16"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/ejs": {
+      "version": "3.1.10",
+      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+      "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jake": "^10.8.5"
+      },
+      "bin": {
+        "ejs": "bin/cli.js"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.267",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
+      "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/emoji-regex": {
+      "version": "10.6.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+      "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/enhanced-resolve": {
+      "version": "5.18.4",
+      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
+      "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.4",
+        "tapable": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/entities": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/env-paths": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+      "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/error-ex": {
+      "version": "1.3.4",
+      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
+      "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-arrayish": "^0.2.1"
+      }
+    },
+    "node_modules/es-abstract": {
+      "version": "1.24.1",
+      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
+      "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "array-buffer-byte-length": "^1.0.2",
+        "arraybuffer.prototype.slice": "^1.0.4",
+        "available-typed-arrays": "^1.0.7",
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "data-view-buffer": "^1.0.2",
+        "data-view-byte-length": "^1.0.2",
+        "data-view-byte-offset": "^1.0.1",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "es-set-tostringtag": "^2.1.0",
+        "es-to-primitive": "^1.3.0",
+        "function.prototype.name": "^1.1.8",
+        "get-intrinsic": "^1.3.0",
+        "get-proto": "^1.0.1",
+        "get-symbol-description": "^1.1.0",
+        "globalthis": "^1.0.4",
+        "gopd": "^1.2.0",
+        "has-property-descriptors": "^1.0.2",
+        "has-proto": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "internal-slot": "^1.1.0",
+        "is-array-buffer": "^3.0.5",
+        "is-callable": "^1.2.7",
+        "is-data-view": "^1.0.2",
+        "is-negative-zero": "^2.0.3",
+        "is-regex": "^1.2.1",
+        "is-set": "^2.0.3",
+        "is-shared-array-buffer": "^1.0.4",
+        "is-string": "^1.1.1",
+        "is-typed-array": "^1.1.15",
+        "is-weakref": "^1.1.1",
+        "math-intrinsics": "^1.1.0",
+        "object-inspect": "^1.13.4",
+        "object-keys": "^1.1.1",
+        "object.assign": "^4.1.7",
+        "own-keys": "^1.0.1",
+        "regexp.prototype.flags": "^1.5.4",
+        "safe-array-concat": "^1.1.3",
+        "safe-push-apply": "^1.0.0",
+        "safe-regex-test": "^1.1.0",
+        "set-proto": "^1.0.0",
+        "stop-iteration-iterator": "^1.1.0",
+        "string.prototype.trim": "^1.2.10",
+        "string.prototype.trimend": "^1.0.9",
+        "string.prototype.trimstart": "^1.0.8",
+        "typed-array-buffer": "^1.0.3",
+        "typed-array-byte-length": "^1.0.3",
+        "typed-array-byte-offset": "^1.0.4",
+        "typed-array-length": "^1.0.7",
+        "unbox-primitive": "^1.1.0",
+        "which-typed-array": "^1.1.19"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-module-lexer": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+      "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-to-primitive": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
+      "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-callable": "^1.2.7",
+        "is-date-object": "^1.0.5",
+        "is-symbol": "^1.0.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+      "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.27.2",
+        "@esbuild/android-arm": "0.27.2",
+        "@esbuild/android-arm64": "0.27.2",
+        "@esbuild/android-x64": "0.27.2",
+        "@esbuild/darwin-arm64": "0.27.2",
+        "@esbuild/darwin-x64": "0.27.2",
+        "@esbuild/freebsd-arm64": "0.27.2",
+        "@esbuild/freebsd-x64": "0.27.2",
+        "@esbuild/linux-arm": "0.27.2",
+        "@esbuild/linux-arm64": "0.27.2",
+        "@esbuild/linux-ia32": "0.27.2",
+        "@esbuild/linux-loong64": "0.27.2",
+        "@esbuild/linux-mips64el": "0.27.2",
+        "@esbuild/linux-ppc64": "0.27.2",
+        "@esbuild/linux-riscv64": "0.27.2",
+        "@esbuild/linux-s390x": "0.27.2",
+        "@esbuild/linux-x64": "0.27.2",
+        "@esbuild/netbsd-arm64": "0.27.2",
+        "@esbuild/netbsd-x64": "0.27.2",
+        "@esbuild/openbsd-arm64": "0.27.2",
+        "@esbuild/openbsd-x64": "0.27.2",
+        "@esbuild/openharmony-arm64": "0.27.2",
+        "@esbuild/sunos-x64": "0.27.2",
+        "@esbuild/win32-arm64": "0.27.2",
+        "@esbuild/win32-ia32": "0.27.2",
+        "@esbuild/win32-x64": "0.27.2"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "9.39.2",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
+      "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.8.0",
+        "@eslint-community/regexpp": "^4.12.1",
+        "@eslint/config-array": "^0.21.1",
+        "@eslint/config-helpers": "^0.4.2",
+        "@eslint/core": "^0.17.0",
+        "@eslint/eslintrc": "^3.3.1",
+        "@eslint/js": "9.39.2",
+        "@eslint/plugin-kit": "^0.4.1",
+        "@humanfs/node": "^0.16.6",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@humanwhocodes/retry": "^0.4.2",
+        "@types/estree": "^1.0.6",
+        "ajv": "^6.12.4",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.6",
+        "debug": "^4.3.2",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^8.4.0",
+        "eslint-visitor-keys": "^4.2.1",
+        "espree": "^10.4.0",
+        "esquery": "^1.5.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^8.0.0",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      },
+      "peerDependencies": {
+        "jiti": "*"
+      },
+      "peerDependenciesMeta": {
+        "jiti": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-plugin-react-hooks": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
+      "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.24.4",
+        "@babel/parser": "^7.24.4",
+        "hermes-parser": "^0.25.1",
+        "zod": "^3.25.0 || ^4.0.0",
+        "zod-validation-error": "^3.5.0 || ^4.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+      }
+    },
+    "node_modules/eslint-plugin-react-refresh": {
+      "version": "0.4.26",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
+      "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "eslint": ">=8.40"
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "8.4.0",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+      "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+      "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/espree": {
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+      "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "acorn": "^8.15.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^4.2.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/esprima": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "bin": {
+        "esparse": "bin/esparse.js",
+        "esvalidate": "bin/esvalidate.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+      "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/eventsource": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+      "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "eventsource-parser": "^3.0.1"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/eventsource-parser": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
+      "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/execa": {
+      "version": "9.6.1",
+      "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
+      "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@sindresorhus/merge-streams": "^4.0.0",
+        "cross-spawn": "^7.0.6",
+        "figures": "^6.1.0",
+        "get-stream": "^9.0.0",
+        "human-signals": "^8.0.1",
+        "is-plain-obj": "^4.1.0",
+        "is-stream": "^4.0.1",
+        "npm-run-path": "^6.0.0",
+        "pretty-ms": "^9.2.0",
+        "signal-exit": "^4.1.0",
+        "strip-final-newline": "^4.0.0",
+        "yoctocolors": "^2.1.1"
+      },
+      "engines": {
+        "node": "^18.19.0 || >=20.5.0"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/execa?sponsor=1"
+      }
+    },
+    "node_modules/expect-type": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+      "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/express": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.1",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/express-rate-limit": {
+      "version": "7.5.1",
+      "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+      "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/express-rate-limit"
+      },
+      "peerDependencies": {
+        "express": ">= 4.11"
+      }
+    },
+    "node_modules/express/node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fast-glob": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+      "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.8"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fast-glob/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fast-uri": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+      "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/fastq": {
+      "version": "1.20.1",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+      "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fetch-blob": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
+      "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "paypal",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "node-domexception": "^1.0.0",
+        "web-streams-polyfill": "^3.0.3"
+      },
+      "engines": {
+        "node": "^12.20 || >= 14.13"
+      }
+    },
+    "node_modules/fflate": {
+      "version": "0.8.2",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+      "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/figures": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
+      "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-unicode-supported": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/file-entry-cache": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+      "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "flat-cache": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
+    "node_modules/filelist": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
+      "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "minimatch": "^5.0.1"
+      }
+    },
+    "node_modules/filelist/node_modules/brace-expansion": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/filelist/node_modules/minimatch": {
+      "version": "5.1.6",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+      "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+      "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "flatted": "^3.2.9",
+        "keyv": "^4.5.4"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+      "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/for-each": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+      "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-callable": "^1.2.7"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/foreground-child": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+      "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "cross-spawn": "^7.0.6",
+        "signal-exit": "^4.0.1"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/formdata-polyfill": {
+      "version": "4.0.10",
+      "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+      "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fetch-blob": "^3.1.2"
+      },
+      "engines": {
+        "node": ">=12.20.0"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fraction.js": {
+      "version": "5.3.4",
+      "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+      "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/rawify"
+      }
+    },
+    "node_modules/framer-motion": {
+      "version": "12.27.1",
+      "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.27.1.tgz",
+      "integrity": "sha512-cEAqO69kcZt3gL0TGua8WTgRQfv4J57nqt1zxHtLKwYhAwA0x9kDS/JbMa1hJbwkGY74AGJKvZ9pX/IqWZtZWQ==",
+      "license": "MIT",
+      "dependencies": {
+        "motion-dom": "^12.27.1",
+        "motion-utils": "^12.24.10",
+        "tslib": "^2.4.0"
+      },
+      "peerDependencies": {
+        "@emotion/is-prop-valid": "*",
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/is-prop-valid": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        },
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/fs-extra": {
+      "version": "11.3.3",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz",
+      "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=14.14"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/function.prototype.name": {
+      "version": "1.1.8",
+      "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz",
+      "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "define-properties": "^1.2.1",
+        "functions-have-names": "^1.2.3",
+        "hasown": "^2.0.2",
+        "is-callable": "^1.2.7"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/functions-have-names": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+      "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/fuzzysort": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz",
+      "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fzf": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz",
+      "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==",
+      "dev": true,
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/generator-function": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+      "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
+    "node_modules/get-east-asian-width": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
+      "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-nonce": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+      "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/get-own-enumerable-keys": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz",
+      "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/get-own-enumerable-property-symbols": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
+      "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/get-stream": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
+      "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@sec-ant/readable-stream": "^0.4.1",
+        "is-stream": "^4.0.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/get-symbol-description": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+      "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/glob": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
+      "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "foreground-child": "^3.3.1",
+        "jackspeak": "^4.1.1",
+        "minimatch": "^10.1.1",
+        "minipass": "^7.1.2",
+        "package-json-from-dist": "^1.0.0",
+        "path-scurry": "^2.0.0"
+      },
+      "bin": {
+        "glob": "dist/esm/bin.mjs"
+      },
+      "engines": {
+        "node": "20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/glob/node_modules/minimatch": {
+      "version": "10.1.1",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
+      "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "@isaacs/brace-expansion": "^5.0.0"
+      },
+      "engines": {
+        "node": "20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/globals": {
+      "version": "16.5.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
+      "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/globalthis": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+      "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-properties": "^1.2.1",
+        "gopd": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "license": "ISC"
+    },
+    "node_modules/graphql": {
+      "version": "16.12.0",
+      "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
+      "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
+      }
+    },
+    "node_modules/has-bigints": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+      "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/has-property-descriptors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-proto": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+      "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/headers-polyfill": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz",
+      "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/hermes-estree": {
+      "version": "0.25.1",
+      "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+      "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/hermes-parser": {
+      "version": "0.25.1",
+      "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+      "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hermes-estree": "0.25.1"
+      }
+    },
+    "node_modules/hono": {
+      "version": "4.11.4",
+      "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
+      "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=16.9.0"
+      }
+    },
+    "node_modules/html-encoding-sniffer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+      "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-encoding": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/html-escaper": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+      "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/http-proxy-agent": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+      "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.2",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/human-signals": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
+      "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+      "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/idb": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+      "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/ignore": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+      "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/import-fresh": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+      "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/indent-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/internal-slot": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+      "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "hasown": "^2.0.2",
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-array-buffer": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+      "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "get-intrinsic": "^1.2.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-arrayish": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+      "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/is-async-function": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+      "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "async-function": "^1.0.0",
+        "call-bound": "^1.0.3",
+        "get-proto": "^1.0.1",
+        "has-tostringtag": "^1.0.2",
+        "safe-regex-test": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-bigint": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+      "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-bigints": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-boolean-object": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+      "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-callable": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+      "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-data-view": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+      "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "get-intrinsic": "^1.2.6",
+        "is-typed-array": "^1.1.13"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-date-object": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+      "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-docker": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+      "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "is-docker": "cli.js"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-finalizationregistry": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+      "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-generator-function": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+      "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.4",
+        "generator-function": "^2.0.0",
+        "get-proto": "^1.0.1",
+        "has-tostringtag": "^1.0.2",
+        "safe-regex-test": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-in-ssh": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz",
+      "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-inside-container": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
+      "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-docker": "^3.0.0"
+      },
+      "bin": {
+        "is-inside-container": "cli.js"
+      },
+      "engines": {
+        "node": ">=14.16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-interactive": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
+      "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-map": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+      "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-module": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
+      "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/is-negative-zero": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+      "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-node-process": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
+      "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-number-object": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+      "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-obj": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz",
+      "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-plain-obj": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+      "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-potential-custom-element-name": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/is-regex": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+      "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "gopd": "^1.2.0",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-regexp": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz",
+      "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-set": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+      "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-shared-array-buffer": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+      "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-stream": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
+      "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-string": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+      "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-symbol": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+      "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "has-symbols": "^1.1.0",
+        "safe-regex-test": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-typed-array": {
+      "version": "1.1.15",
+      "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+      "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "which-typed-array": "^1.1.16"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-unicode-supported": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
+      "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-weakmap": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+      "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-weakref": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+      "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-weakset": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+      "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "get-intrinsic": "^1.2.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-wsl": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
+      "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-inside-container": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+      "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/istanbul-lib-coverage": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+      "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/istanbul-lib-report": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+      "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "istanbul-lib-coverage": "^3.0.0",
+        "make-dir": "^4.0.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/istanbul-lib-source-maps": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
+      "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "^0.3.23",
+        "debug": "^4.1.1",
+        "istanbul-lib-coverage": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/istanbul-reports": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+      "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "html-escaper": "^2.0.0",
+        "istanbul-lib-report": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/jackspeak": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
+      "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "@isaacs/cliui": "^8.0.2"
+      },
+      "engines": {
+        "node": "20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/jake": {
+      "version": "10.9.4",
+      "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+      "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "async": "^3.2.6",
+        "filelist": "^1.0.4",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "jake": "bin/cli.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/jiti": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+      "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+      "license": "MIT",
+      "bin": {
+        "jiti": "lib/jiti-cli.mjs"
+      }
+    },
+    "node_modules/jose": {
+      "version": "6.1.3",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
+      "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "license": "MIT"
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+      "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/jsdom": {
+      "version": "27.0.1",
+      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz",
+      "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/dom-selector": "^6.7.2",
+        "cssstyle": "^5.3.1",
+        "data-urls": "^6.0.0",
+        "decimal.js": "^10.6.0",
+        "html-encoding-sniffer": "^4.0.0",
+        "http-proxy-agent": "^7.0.2",
+        "https-proxy-agent": "^7.0.6",
+        "is-potential-custom-element-name": "^1.0.1",
+        "parse5": "^8.0.0",
+        "rrweb-cssom": "^0.8.0",
+        "saxes": "^6.0.0",
+        "symbol-tree": "^3.2.4",
+        "tough-cookie": "^6.0.0",
+        "w3c-xmlserializer": "^5.0.0",
+        "webidl-conversions": "^8.0.0",
+        "whatwg-encoding": "^3.1.1",
+        "whatwg-mimetype": "^4.0.0",
+        "whatwg-url": "^15.1.0",
+        "ws": "^8.18.3",
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "peerDependencies": {
+        "canvas": "^3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "canvas": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/jsdom/node_modules/tr46": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+      "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/jsdom/node_modules/webidl-conversions": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+      "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/jsdom/node_modules/whatwg-url": {
+      "version": "15.1.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
+      "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "^6.0.0",
+        "webidl-conversions": "^8.0.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json-parse-even-better-errors": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+      "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json-schema": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
+      "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
+      "dev": true,
+      "license": "(AFL-2.1 OR BSD-3-Clause)"
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json-schema-typed": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+      "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+      "dev": true,
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/jsonfile": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
+      "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "universalify": "^2.0.0"
+      },
+      "optionalDependencies": {
+        "graceful-fs": "^4.1.6"
+      }
+    },
+    "node_modules/jsonpointer": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
+      "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/keyv": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+      "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "json-buffer": "3.0.1"
+      }
+    },
+    "node_modules/kleur": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+      "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/leven": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+      "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+      "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.30.2",
+        "lightningcss-darwin-arm64": "1.30.2",
+        "lightningcss-darwin-x64": "1.30.2",
+        "lightningcss-freebsd-x64": "1.30.2",
+        "lightningcss-linux-arm-gnueabihf": "1.30.2",
+        "lightningcss-linux-arm64-gnu": "1.30.2",
+        "lightningcss-linux-arm64-musl": "1.30.2",
+        "lightningcss-linux-x64-gnu": "1.30.2",
+        "lightningcss-linux-x64-musl": "1.30.2",
+        "lightningcss-win32-arm64-msvc": "1.30.2",
+        "lightningcss-win32-x64-msvc": "1.30.2"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+      "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+      "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+      "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+      "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+      "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+      "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+      "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+      "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+      "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+      "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+      "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lines-and-columns": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.17.22",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
+      "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.debounce": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+      "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lodash.sortby": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
+      "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/log-symbols": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
+      "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "chalk": "^5.3.0",
+        "is-unicode-supported": "^1.3.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/log-symbols/node_modules/chalk": {
+      "version": "5.6.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+      "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^12.17.0 || ^14.13 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/log-symbols/node_modules/is-unicode-supported": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
+      "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/loupe": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+      "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/lucide-react": {
+      "version": "0.562.0",
+      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
+      "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
+      "dev": true,
+      "license": "ISC",
+      "peerDependencies": {
+        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/lz-string": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+      "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "bin": {
+        "lz-string": "bin/bin.js"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/magicast": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
+      "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.25.4",
+        "@babel/types": "^7.25.4",
+        "source-map-js": "^1.2.0"
+      }
+    },
+    "node_modules/make-dir": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+      "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "semver": "^7.5.3"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/make-dir/node_modules/semver": {
+      "version": "7.7.3",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+      "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/material-colors": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz",
+      "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==",
+      "license": "ISC"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mdn-data": {
+      "version": "2.12.2",
+      "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
+      "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
+      "dev": true,
+      "license": "CC0-1.0"
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/merge-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+      "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/micromatch/node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/mimic-fn": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+      "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/mimic-function": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
+      "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/min-indent": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+      "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/minipass": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+      "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      }
+    },
+    "node_modules/motion": {
+      "version": "12.27.1",
+      "resolved": "https://registry.npmjs.org/motion/-/motion-12.27.1.tgz",
+      "integrity": "sha512-FAZTPDm1LccBdWSL46WLnEdTSHmdVx+fdWK8f61qBQn67MmFefXLXlrwy94rK2DDsd9A50Gj8H+LYCgQ/cQlFg==",
+      "license": "MIT",
+      "dependencies": {
+        "framer-motion": "^12.27.1",
+        "tslib": "^2.4.0"
+      },
+      "peerDependencies": {
+        "@emotion/is-prop-valid": "*",
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/is-prop-valid": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        },
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/motion-dom": {
+      "version": "12.27.1",
+      "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.27.1.tgz",
+      "integrity": "sha512-V/53DA2nBqKl9O2PMJleSUb/G0dsMMeZplZwgIQf5+X0bxIu7Q1cTv6DrjvTTGYRm3+7Y5wMlRZ1wT61boU/bQ==",
+      "license": "MIT",
+      "dependencies": {
+        "motion-utils": "^12.24.10"
+      }
+    },
+    "node_modules/motion-utils": {
+      "version": "12.24.10",
+      "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
+      "integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==",
+      "license": "MIT"
+    },
+    "node_modules/mrmime": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+      "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/msw": {
+      "version": "2.12.7",
+      "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.7.tgz",
+      "integrity": "sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "@inquirer/confirm": "^5.0.0",
+        "@mswjs/interceptors": "^0.40.0",
+        "@open-draft/deferred-promise": "^2.2.0",
+        "@types/statuses": "^2.0.6",
+        "cookie": "^1.0.2",
+        "graphql": "^16.12.0",
+        "headers-polyfill": "^4.0.2",
+        "is-node-process": "^1.2.0",
+        "outvariant": "^1.4.3",
+        "path-to-regexp": "^6.3.0",
+        "picocolors": "^1.1.1",
+        "rettime": "^0.7.0",
+        "statuses": "^2.0.2",
+        "strict-event-emitter": "^0.5.1",
+        "tough-cookie": "^6.0.0",
+        "type-fest": "^5.2.0",
+        "until-async": "^3.0.2",
+        "yargs": "^17.7.2"
+      },
+      "bin": {
+        "msw": "cli/index.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mswjs"
+      },
+      "peerDependencies": {
+        "typescript": ">= 4.8.x"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/mute-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
+      "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": "^18.17.0 || >=20.5.0"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/next-themes": {
+      "version": "0.4.6",
+      "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
+      "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+      "deprecated": "Use your platform's native DOMException instead",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "github",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.5.0"
+      }
+    },
+    "node_modules/node-fetch": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
+      "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "data-uri-to-buffer": "^4.0.0",
+        "fetch-blob": "^3.1.4",
+        "formdata-polyfill": "^4.0.10"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/node-fetch"
+      }
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.27",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+      "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/npm-run-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
+      "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^4.0.0",
+        "unicorn-magic": "^0.3.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/npm-run-path/node_modules/path-key": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+      "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/object-treeify": {
+      "version": "1.1.33",
+      "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz",
+      "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/object.assign": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+      "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.0.0",
+        "has-symbols": "^1.1.0",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/onetime": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
+      "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mimic-function": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/open": {
+      "version": "11.0.0",
+      "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
+      "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "default-browser": "^5.4.0",
+        "define-lazy-prop": "^3.0.0",
+        "is-in-ssh": "^1.0.0",
+        "is-inside-container": "^1.0.0",
+        "powershell-utils": "^0.1.0",
+        "wsl-utils": "^0.3.0"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/optionator": {
+      "version": "0.9.4",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+      "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.5"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/ora": {
+      "version": "8.2.0",
+      "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz",
+      "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "chalk": "^5.3.0",
+        "cli-cursor": "^5.0.0",
+        "cli-spinners": "^2.9.2",
+        "is-interactive": "^2.0.0",
+        "is-unicode-supported": "^2.0.0",
+        "log-symbols": "^6.0.0",
+        "stdin-discarder": "^0.2.2",
+        "string-width": "^7.2.0",
+        "strip-ansi": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/ora/node_modules/chalk": {
+      "version": "5.6.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+      "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^12.17.0 || ^14.13 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/outvariant": {
+      "version": "1.4.3",
+      "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
+      "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/own-keys": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+      "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "get-intrinsic": "^1.2.6",
+        "object-keys": "^1.1.1",
+        "safe-push-apply": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "p-limit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/package-json-from-dist": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+      "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+      "dev": true,
+      "license": "BlueOak-1.0.0"
+    },
+    "node_modules/package-manager-detector": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
+      "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "callsites": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/parse-json": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+      "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.0.0",
+        "error-ex": "^1.3.1",
+        "json-parse-even-better-errors": "^2.3.0",
+        "lines-and-columns": "^1.1.6"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/parse-ms": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
+      "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/parse5": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+      "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^6.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/path-scurry": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
+      "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "lru-cache": "^11.0.0",
+        "minipass": "^7.1.2"
+      },
+      "engines": {
+        "node": "20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/path-scurry/node_modules/lru-cache": {
+      "version": "11.2.4",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+      "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+      "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pathval": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+      "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14.16"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pkce-challenge": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+      "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.58.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
+      "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.58.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.58.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
+      "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/playwright/node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/possible-typed-array-names": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+      "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.6",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postcss-selector-parser": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
+      "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postcss-value-parser": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/powershell-utils": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
+      "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/pretty-bytes": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
+      "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/pretty-format": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+      "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1",
+        "ansi-styles": "^5.0.0",
+        "react-is": "^17.0.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/pretty-format/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/pretty-format/node_modules/ansi-styles": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/pretty-format/node_modules/react-is": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+      "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/pretty-ms": {
+      "version": "9.3.0",
+      "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
+      "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "parse-ms": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/prompts": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+      "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "kleur": "^3.0.3",
+        "sisteransi": "^1.0.5"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/prompts/node_modules/kleur": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+      "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.14.1",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+      "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/randombytes": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+      "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "^5.1.0"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.7.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/react": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
+      "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-color": {
+      "version": "2.19.3",
+      "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
+      "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==",
+      "license": "MIT",
+      "dependencies": {
+        "@icons/material": "^0.2.4",
+        "lodash": "^4.17.15",
+        "lodash-es": "^4.17.15",
+        "material-colors": "^1.2.1",
+        "prop-types": "^15.5.10",
+        "reactcss": "^1.2.0",
+        "tinycolor2": "^1.4.1"
+      },
+      "peerDependencies": {
+        "react": "*"
+      }
+    },
+    "node_modules/react-colorful": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
+      "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
+      "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.3"
+      }
+    },
+    "node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+      "license": "MIT"
+    },
+    "node_modules/react-refresh": {
+      "version": "0.18.0",
+      "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+      "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-remove-scroll": {
+      "version": "2.7.2",
+      "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+      "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
+      "license": "MIT",
+      "dependencies": {
+        "react-remove-scroll-bar": "^2.3.7",
+        "react-style-singleton": "^2.2.3",
+        "tslib": "^2.1.0",
+        "use-callback-ref": "^1.3.3",
+        "use-sidecar": "^1.1.3"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-remove-scroll-bar": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+      "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+      "license": "MIT",
+      "dependencies": {
+        "react-style-singleton": "^2.2.2",
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-resizable-panels": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.4.0.tgz",
+      "integrity": "sha512-vGH1rIhyDOL4RSWYTx3eatjDohDFIRxJCAXUOaeL9HyamptUnUezqndjMtBo9hQeaq1CIP0NBbc7ZV3lBtlgxA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/react-router": {
+      "version": "7.12.0",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
+      "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
+      "license": "MIT",
+      "dependencies": {
+        "cookie": "^1.0.1",
+        "set-cookie-parser": "^2.6.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "7.12.0",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
+      "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
+      "license": "MIT",
+      "dependencies": {
+        "react-router": "7.12.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      }
+    },
+    "node_modules/react-style-singleton": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+      "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+      "license": "MIT",
+      "dependencies": {
+        "get-nonce": "^1.0.0",
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/reactcss": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
+      "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash": "^4.0.1"
+      }
+    },
+    "node_modules/recast": {
+      "version": "0.23.11",
+      "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
+      "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ast-types": "^0.16.1",
+        "esprima": "~4.0.0",
+        "source-map": "~0.6.1",
+        "tiny-invariant": "^1.3.3",
+        "tslib": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/redent": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+      "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "indent-string": "^4.0.0",
+        "strip-indent": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/reflect.getprototypeof": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+      "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.9",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.0.0",
+        "get-intrinsic": "^1.2.7",
+        "get-proto": "^1.0.1",
+        "which-builtin-type": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/regenerate": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
+      "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/regenerate-unicode-properties": {
+      "version": "10.2.2",
+      "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz",
+      "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "regenerate": "^1.4.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/regexp.prototype.flags": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+      "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-errors": "^1.3.0",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "set-function-name": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/regexpu-core": {
+      "version": "6.4.0",
+      "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz",
+      "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "regenerate": "^1.4.2",
+        "regenerate-unicode-properties": "^10.2.2",
+        "regjsgen": "^0.8.0",
+        "regjsparser": "^0.13.0",
+        "unicode-match-property-ecmascript": "^2.0.0",
+        "unicode-match-property-value-ecmascript": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/regjsgen": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz",
+      "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/regjsparser": {
+      "version": "0.13.0",
+      "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz",
+      "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "jsesc": "~3.1.0"
+      },
+      "bin": {
+        "regjsparser": "bin/parser"
+      }
+    },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.22.11",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+      "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-core-module": "^2.16.1",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/restore-cursor": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
+      "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "onetime": "^7.0.0",
+        "signal-exit": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/rettime": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz",
+      "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/reusify": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.55.1",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
+      "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.55.1",
+        "@rollup/rollup-android-arm64": "4.55.1",
+        "@rollup/rollup-darwin-arm64": "4.55.1",
+        "@rollup/rollup-darwin-x64": "4.55.1",
+        "@rollup/rollup-freebsd-arm64": "4.55.1",
+        "@rollup/rollup-freebsd-x64": "4.55.1",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
+        "@rollup/rollup-linux-arm-musleabihf": "4.55.1",
+        "@rollup/rollup-linux-arm64-gnu": "4.55.1",
+        "@rollup/rollup-linux-arm64-musl": "4.55.1",
+        "@rollup/rollup-linux-loong64-gnu": "4.55.1",
+        "@rollup/rollup-linux-loong64-musl": "4.55.1",
+        "@rollup/rollup-linux-ppc64-gnu": "4.55.1",
+        "@rollup/rollup-linux-ppc64-musl": "4.55.1",
+        "@rollup/rollup-linux-riscv64-gnu": "4.55.1",
+        "@rollup/rollup-linux-riscv64-musl": "4.55.1",
+        "@rollup/rollup-linux-s390x-gnu": "4.55.1",
+        "@rollup/rollup-linux-x64-gnu": "4.55.1",
+        "@rollup/rollup-linux-x64-musl": "4.55.1",
+        "@rollup/rollup-openbsd-x64": "4.55.1",
+        "@rollup/rollup-openharmony-arm64": "4.55.1",
+        "@rollup/rollup-win32-arm64-msvc": "4.55.1",
+        "@rollup/rollup-win32-ia32-msvc": "4.55.1",
+        "@rollup/rollup-win32-x64-gnu": "4.55.1",
+        "@rollup/rollup-win32-x64-msvc": "4.55.1",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/router/node_modules/path-to-regexp": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+      "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/rrweb-cssom": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+      "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/run-applescript": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
+      "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/safe-array-concat": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
+      "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.2",
+        "get-intrinsic": "^1.2.6",
+        "has-symbols": "^1.1.0",
+        "isarray": "^2.0.5"
+      },
+      "engines": {
+        "node": ">=0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safe-push-apply": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+      "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "isarray": "^2.0.5"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/safe-regex-test": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+      "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "is-regex": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/saxes": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+      "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "xmlchars": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=v12.22.7"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.1",
+        "mime-types": "^3.0.2",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/serialize-javascript": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
+      "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "randombytes": "^2.1.0"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/set-cookie-parser": {
+      "version": "2.7.2",
+      "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+      "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+      "license": "MIT"
+    },
+    "node_modules/set-function-length": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2",
+        "get-intrinsic": "^1.2.4",
+        "gopd": "^1.0.1",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/set-function-name": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+      "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "functions-have-names": "^1.2.3",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/set-proto": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+      "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/shadcn": {
+      "version": "3.7.0",
+      "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-3.7.0.tgz",
+      "integrity": "sha512-zOXNAIFclguSYmmoibyXyKiYA6qjEJtXDSvloAMziSREW9Q0R/dLqBUYdb81lOejmZkDYuZApGabbMLH7G8qvQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@antfu/ni": "^25.0.0",
+        "@babel/core": "^7.28.0",
+        "@babel/parser": "^7.28.0",
+        "@babel/plugin-transform-typescript": "^7.28.0",
+        "@babel/preset-typescript": "^7.27.1",
+        "@dotenvx/dotenvx": "^1.48.4",
+        "@modelcontextprotocol/sdk": "^1.17.2",
+        "@types/validate-npm-package-name": "^4.0.2",
+        "browserslist": "^4.26.2",
+        "commander": "^14.0.0",
+        "cosmiconfig": "^9.0.0",
+        "dedent": "^1.6.0",
+        "deepmerge": "^4.3.1",
+        "diff": "^8.0.2",
+        "execa": "^9.6.0",
+        "fast-glob": "^3.3.3",
+        "fs-extra": "^11.3.1",
+        "fuzzysort": "^3.1.0",
+        "https-proxy-agent": "^7.0.6",
+        "kleur": "^4.1.5",
+        "msw": "^2.10.4",
+        "node-fetch": "^3.3.2",
+        "open": "^11.0.0",
+        "ora": "^8.2.0",
+        "postcss": "^8.5.6",
+        "postcss-selector-parser": "^7.1.0",
+        "prompts": "^2.4.2",
+        "recast": "^0.23.11",
+        "stringify-object": "^5.0.0",
+        "ts-morph": "^26.0.0",
+        "tsconfig-paths": "^4.2.0",
+        "validate-npm-package-name": "^7.0.1",
+        "zod": "^3.24.1",
+        "zod-to-json-schema": "^3.24.6"
+      },
+      "bin": {
+        "shadcn": "dist/index.js"
+      }
+    },
+    "node_modules/shadcn/node_modules/zod": {
+      "version": "3.25.76",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+      "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/signal-exit": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+      "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/sirv": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+      "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@polka/url": "^1.0.0-next.24",
+        "mrmime": "^2.0.0",
+        "totalist": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/sisteransi": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+      "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/smob": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz",
+      "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/sonner": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
+      "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+        "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/source-map-support": {
+      "version": "0.5.21",
+      "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+      "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "source-map": "^0.6.0"
+      }
+    },
+    "node_modules/sourcemap-codec": {
+      "version": "1.4.8",
+      "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+      "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
+      "deprecated": "Please use @jridgewell/sourcemap-codec instead",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/std-env": {
+      "version": "3.10.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+      "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/stdin-discarder": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
+      "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/stop-iteration-iterator": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+      "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "internal-slot": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/strict-event-emitter": {
+      "version": "0.5.1",
+      "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
+      "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/string-width": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+      "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^10.3.0",
+        "get-east-asian-width": "^1.0.0",
+        "strip-ansi": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/string-width-cjs": {
+      "name": "string-width",
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-width-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-width-cjs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/string-width-cjs/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string.prototype.matchall": {
+      "version": "4.0.12",
+      "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
+      "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.6",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.0.0",
+        "get-intrinsic": "^1.2.6",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "internal-slot": "^1.1.0",
+        "regexp.prototype.flags": "^1.5.3",
+        "set-function-name": "^2.0.2",
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.trim": {
+      "version": "1.2.10",
+      "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
+      "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.2",
+        "define-data-property": "^1.1.4",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.5",
+        "es-object-atoms": "^1.0.0",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.trimend": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz",
+      "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.2",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.trimstart": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+      "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/stringify-object": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz",
+      "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "get-own-enumerable-keys": "^1.0.0",
+        "is-obj": "^3.0.0",
+        "is-regexp": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=14.16"
+      },
+      "funding": {
+        "url": "https://github.com/yeoman/stringify-object?sponsor=1"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+      "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+      }
+    },
+    "node_modules/strip-ansi-cjs": {
+      "name": "strip-ansi",
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-bom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+      "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/strip-comments": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
+      "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/strip-final-newline": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
+      "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/strip-indent": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+      "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "min-indent": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/strip-literal": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+      "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^9.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/strip-literal/node_modules/js-tokens": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+      "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/symbol-tree": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+      "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tagged-tag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+      "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/tailwind-merge": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
+      "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/dcastil"
+      }
+    },
+    "node_modules/tailwindcss": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
+      "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
+      "license": "MIT"
+    },
+    "node_modules/tailwindcss-animate": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
+      "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "tailwindcss": ">=3.0.0 || insiders"
+      }
+    },
+    "node_modules/tapable": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+      "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      }
+    },
+    "node_modules/temp-dir": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
+      "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/tempy": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz",
+      "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-stream": "^2.0.0",
+        "temp-dir": "^2.0.0",
+        "type-fest": "^0.16.0",
+        "unique-string": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/tempy/node_modules/is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/tempy/node_modules/type-fest": {
+      "version": "0.16.0",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
+      "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==",
+      "dev": true,
+      "license": "(MIT OR CC0-1.0)",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/terser": {
+      "version": "5.46.0",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
+      "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "@jridgewell/source-map": "^0.3.3",
+        "acorn": "^8.15.0",
+        "commander": "^2.20.0",
+        "source-map-support": "~0.5.20"
+      },
+      "bin": {
+        "terser": "bin/terser"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/terser/node_modules/commander": {
+      "version": "2.20.3",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+      "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/test-exclude": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
+      "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "@istanbuljs/schema": "^0.1.2",
+        "glob": "^10.4.1",
+        "minimatch": "^9.0.4"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/test-exclude/node_modules/brace-expansion": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/test-exclude/node_modules/glob": {
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+      "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "foreground-child": "^3.1.0",
+        "jackspeak": "^3.1.2",
+        "minimatch": "^9.0.4",
+        "minipass": "^7.1.2",
+        "package-json-from-dist": "^1.0.0",
+        "path-scurry": "^1.11.1"
+      },
+      "bin": {
+        "glob": "dist/esm/bin.mjs"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/test-exclude/node_modules/jackspeak": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+      "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "@isaacs/cliui": "^8.0.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      },
+      "optionalDependencies": {
+        "@pkgjs/parseargs": "^0.11.0"
+      }
+    },
+    "node_modules/test-exclude/node_modules/lru-cache": {
+      "version": "10.4.3",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+      "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/test-exclude/node_modules/minimatch": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/test-exclude/node_modules/path-scurry": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+      "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "lru-cache": "^10.2.0",
+        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/tiny-invariant": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+      "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinybench": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinycolor2": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
+      "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
+      "license": "MIT"
+    },
+    "node_modules/tinyexec": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+      "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.15",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tinypool": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+      "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      }
+    },
+    "node_modules/tinyrainbow": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+      "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tinyspy": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+      "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tldts": {
+      "version": "7.0.19",
+      "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",
+      "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tldts-core": "^7.0.19"
+      },
+      "bin": {
+        "tldts": "bin/cli.js"
+      }
+    },
+    "node_modules/tldts-core": {
+      "version": "7.0.19",
+      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz",
+      "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/totalist": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+      "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/tough-cookie": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
+      "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tldts": "^7.0.5"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
+      "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/ts-api-utils": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
+      "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.12"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4"
+      }
+    },
+    "node_modules/ts-morph": {
+      "version": "26.0.0",
+      "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz",
+      "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@ts-morph/common": "~0.27.0",
+        "code-block-writer": "^13.0.3"
+      }
+    },
+    "node_modules/tsconfig-paths": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
+      "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "json5": "^2.2.2",
+        "minimist": "^1.2.6",
+        "strip-bom": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/type-fest": {
+      "version": "5.4.1",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.1.tgz",
+      "integrity": "sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ==",
+      "dev": true,
+      "license": "(MIT OR CC0-1.0)",
+      "dependencies": {
+        "tagged-tag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+      "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^1.0.5",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/typed-array-buffer": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+      "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "es-errors": "^1.3.0",
+        "is-typed-array": "^1.1.14"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/typed-array-byte-length": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+      "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "for-each": "^0.3.3",
+        "gopd": "^1.2.0",
+        "has-proto": "^1.2.0",
+        "is-typed-array": "^1.1.14"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/typed-array-byte-offset": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
+      "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "available-typed-arrays": "^1.0.7",
+        "call-bind": "^1.0.8",
+        "for-each": "^0.3.3",
+        "gopd": "^1.2.0",
+        "has-proto": "^1.2.0",
+        "is-typed-array": "^1.1.15",
+        "reflect.getprototypeof": "^1.0.9"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/typed-array-length": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz",
+      "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "for-each": "^0.3.3",
+        "gopd": "^1.0.1",
+        "is-typed-array": "^1.1.13",
+        "possible-typed-array-names": "^1.0.0",
+        "reflect.getprototypeof": "^1.0.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/typescript-eslint": {
+      "version": "8.52.0",
+      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.52.0.tgz",
+      "integrity": "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/eslint-plugin": "8.52.0",
+        "@typescript-eslint/parser": "8.52.0",
+        "@typescript-eslint/typescript-estree": "8.52.0",
+        "@typescript-eslint/utils": "8.52.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/unbox-primitive": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+      "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-bigints": "^1.0.2",
+        "has-symbols": "^1.1.0",
+        "which-boxed-primitive": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "7.16.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+      "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/unicode-canonical-property-names-ecmascript": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
+      "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/unicode-match-property-ecmascript": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+      "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "unicode-canonical-property-names-ecmascript": "^2.0.0",
+        "unicode-property-aliases-ecmascript": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/unicode-match-property-value-ecmascript": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz",
+      "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/unicode-property-aliases-ecmascript": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz",
+      "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/unicorn-magic": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
+      "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/unique-string": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
+      "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "crypto-random-string": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/universalify": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+      "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10.0.0"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/until-async": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz",
+      "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/kettanaito"
+      }
+    },
+    "node_modules/upath": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
+      "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4",
+        "yarn": "*"
+      }
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/use-callback-ref": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+      "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/use-sidecar": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+      "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+      "license": "MIT",
+      "dependencies": {
+        "detect-node-es": "^1.1.0",
+        "tslib": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/validate-npm-package-name": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
+      "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": "^20.17.0 || >=22.9.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vite": {
+      "version": "7.3.1",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+      "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.27.0",
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3",
+        "postcss": "^8.5.6",
+        "rollup": "^4.43.0",
+        "tinyglobby": "^0.2.15"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "lightningcss": "^1.21.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-node": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+      "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cac": "^6.7.14",
+        "debug": "^4.4.1",
+        "es-module-lexer": "^1.7.0",
+        "pathe": "^2.0.3",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+      },
+      "bin": {
+        "vite-node": "vite-node.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/vite-plugin-pwa": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz",
+      "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.3.6",
+        "pretty-bytes": "^6.1.1",
+        "tinyglobby": "^0.2.10",
+        "workbox-build": "^7.4.0",
+        "workbox-window": "^7.4.0"
+      },
+      "engines": {
+        "node": ">=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vite-pwa/assets-generator": "^1.0.0",
+        "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
+        "workbox-build": "^7.4.0",
+        "workbox-window": "^7.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@vite-pwa/assets-generator": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vitest": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+      "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/expect": "3.2.4",
+        "@vitest/mocker": "3.2.4",
+        "@vitest/pretty-format": "^3.2.4",
+        "@vitest/runner": "3.2.4",
+        "@vitest/snapshot": "3.2.4",
+        "@vitest/spy": "3.2.4",
+        "@vitest/utils": "3.2.4",
+        "chai": "^5.2.0",
+        "debug": "^4.4.1",
+        "expect-type": "^1.2.1",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.2",
+        "std-env": "^3.9.0",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^0.3.2",
+        "tinyglobby": "^0.2.14",
+        "tinypool": "^1.1.1",
+        "tinyrainbow": "^2.0.0",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+        "vite-node": "3.2.4",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@types/debug": "^4.1.12",
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "@vitest/browser": "3.2.4",
+        "@vitest/ui": "3.2.4",
+        "happy-dom": "*",
+        "jsdom": "*"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@types/debug": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vitest/node_modules/tinyexec": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+      "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/w3c-xmlserializer": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+      "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/web-streams-polyfill": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+      "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
+      "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
+      "dev": true,
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/whatwg-encoding": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+      "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+      "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "iconv-lite": "0.6.3"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/whatwg-encoding/node_modules/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+      "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/whatwg-url": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
+      "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lodash.sortby": "^4.7.0",
+        "tr46": "^1.0.1",
+        "webidl-conversions": "^4.0.2"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/which-boxed-primitive": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+      "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-bigint": "^1.1.0",
+        "is-boolean-object": "^1.2.1",
+        "is-number-object": "^1.1.1",
+        "is-string": "^1.1.1",
+        "is-symbol": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/which-builtin-type": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+      "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "function.prototype.name": "^1.1.6",
+        "has-tostringtag": "^1.0.2",
+        "is-async-function": "^2.0.0",
+        "is-date-object": "^1.1.0",
+        "is-finalizationregistry": "^1.1.0",
+        "is-generator-function": "^1.0.10",
+        "is-regex": "^1.2.1",
+        "is-weakref": "^1.0.2",
+        "isarray": "^2.0.5",
+        "which-boxed-primitive": "^1.1.0",
+        "which-collection": "^1.0.2",
+        "which-typed-array": "^1.1.16"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/which-collection": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+      "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-map": "^2.0.3",
+        "is-set": "^2.0.3",
+        "is-weakmap": "^2.0.2",
+        "is-weakset": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/which-typed-array": {
+      "version": "1.1.20",
+      "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+      "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "available-typed-arrays": "^1.0.7",
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "for-each": "^0.3.5",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/why-is-node-running": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      },
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/word-wrap": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/workbox-background-sync": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz",
+      "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "idb": "^7.0.1",
+        "workbox-core": "7.4.0"
+      }
+    },
+    "node_modules/workbox-broadcast-update": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz",
+      "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "workbox-core": "7.4.0"
+      }
+    },
+    "node_modules/workbox-build": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz",
+      "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@apideck/better-ajv-errors": "^0.3.1",
+        "@babel/core": "^7.24.4",
+        "@babel/preset-env": "^7.11.0",
+        "@babel/runtime": "^7.11.2",
+        "@rollup/plugin-babel": "^5.2.0",
+        "@rollup/plugin-node-resolve": "^15.2.3",
+        "@rollup/plugin-replace": "^2.4.1",
+        "@rollup/plugin-terser": "^0.4.3",
+        "@surma/rollup-plugin-off-main-thread": "^2.2.3",
+        "ajv": "^8.6.0",
+        "common-tags": "^1.8.0",
+        "fast-json-stable-stringify": "^2.1.0",
+        "fs-extra": "^9.0.1",
+        "glob": "^11.0.1",
+        "lodash": "^4.17.20",
+        "pretty-bytes": "^5.3.0",
+        "rollup": "^2.79.2",
+        "source-map": "^0.8.0-beta.0",
+        "stringify-object": "^3.3.0",
+        "strip-comments": "^2.0.1",
+        "tempy": "^0.6.0",
+        "upath": "^1.2.0",
+        "workbox-background-sync": "7.4.0",
+        "workbox-broadcast-update": "7.4.0",
+        "workbox-cacheable-response": "7.4.0",
+        "workbox-core": "7.4.0",
+        "workbox-expiration": "7.4.0",
+        "workbox-google-analytics": "7.4.0",
+        "workbox-navigation-preload": "7.4.0",
+        "workbox-precaching": "7.4.0",
+        "workbox-range-requests": "7.4.0",
+        "workbox-recipes": "7.4.0",
+        "workbox-routing": "7.4.0",
+        "workbox-strategies": "7.4.0",
+        "workbox-streams": "7.4.0",
+        "workbox-sw": "7.4.0",
+        "workbox-window": "7.4.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": {
+      "version": "0.3.6",
+      "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz",
+      "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "json-schema": "^0.4.0",
+        "jsonpointer": "^5.0.0",
+        "leven": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "ajv": ">=8"
+      }
+    },
+    "node_modules/workbox-build/node_modules/@rollup/plugin-babel": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
+      "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.10.4",
+        "@rollup/pluginutils": "^3.1.0"
+      },
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0",
+        "@types/babel__core": "^7.1.9",
+        "rollup": "^1.20.0||^2.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/babel__core": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/workbox-build/node_modules/@rollup/plugin-replace": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz",
+      "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rollup/pluginutils": "^3.1.0",
+        "magic-string": "^0.25.7"
+      },
+      "peerDependencies": {
+        "rollup": "^1.20.0 || ^2.0.0"
+      }
+    },
+    "node_modules/workbox-build/node_modules/@rollup/pluginutils": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
+      "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "0.0.39",
+        "estree-walker": "^1.0.1",
+        "picomatch": "^2.2.2"
+      },
+      "engines": {
+        "node": ">= 8.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^1.20.0||^2.0.0"
+      }
+    },
+    "node_modules/workbox-build/node_modules/@types/estree": {
+      "version": "0.0.39",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
+      "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/workbox-build/node_modules/ajv": {
+      "version": "8.17.1",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+      "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/workbox-build/node_modules/estree-walker": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
+      "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/workbox-build/node_modules/fs-extra": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+      "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "at-least-node": "^1.0.0",
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/workbox-build/node_modules/is-obj": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
+      "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/workbox-build/node_modules/is-regexp": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
+      "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/workbox-build/node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/workbox-build/node_modules/magic-string": {
+      "version": "0.25.9",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
+      "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "sourcemap-codec": "^1.4.8"
+      }
+    },
+    "node_modules/workbox-build/node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/workbox-build/node_modules/pretty-bytes": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
+      "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/workbox-build/node_modules/rollup": {
+      "version": "2.79.2",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
+      "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/workbox-build/node_modules/source-map": {
+      "version": "0.8.0-beta.0",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
+      "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==",
+      "deprecated": "The work that was done in this beta branch won't be included in future versions",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "whatwg-url": "^7.0.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/workbox-build/node_modules/stringify-object": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
+      "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "get-own-enumerable-property-symbols": "^3.0.0",
+        "is-obj": "^1.0.1",
+        "is-regexp": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/workbox-cacheable-response": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz",
+      "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "workbox-core": "7.4.0"
+      }
+    },
+    "node_modules/workbox-core": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
+      "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/workbox-expiration": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz",
+      "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "idb": "^7.0.1",
+        "workbox-core": "7.4.0"
+      }
+    },
+    "node_modules/workbox-google-analytics": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz",
+      "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "workbox-background-sync": "7.4.0",
+        "workbox-core": "7.4.0",
+        "workbox-routing": "7.4.0",
+        "workbox-strategies": "7.4.0"
+      }
+    },
+    "node_modules/workbox-navigation-preload": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz",
+      "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "workbox-core": "7.4.0"
+      }
+    },
+    "node_modules/workbox-precaching": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz",
+      "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "workbox-core": "7.4.0",
+        "workbox-routing": "7.4.0",
+        "workbox-strategies": "7.4.0"
+      }
+    },
+    "node_modules/workbox-range-requests": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz",
+      "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "workbox-core": "7.4.0"
+      }
+    },
+    "node_modules/workbox-recipes": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz",
+      "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "workbox-cacheable-response": "7.4.0",
+        "workbox-core": "7.4.0",
+        "workbox-expiration": "7.4.0",
+        "workbox-precaching": "7.4.0",
+        "workbox-routing": "7.4.0",
+        "workbox-strategies": "7.4.0"
+      }
+    },
+    "node_modules/workbox-routing": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz",
+      "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "workbox-core": "7.4.0"
+      }
+    },
+    "node_modules/workbox-strategies": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz",
+      "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "workbox-core": "7.4.0"
+      }
+    },
+    "node_modules/workbox-streams": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz",
+      "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "workbox-core": "7.4.0",
+        "workbox-routing": "7.4.0"
+      }
+    },
+    "node_modules/workbox-sw": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz",
+      "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/workbox-window": {
+      "version": "7.4.0",
+      "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz",
+      "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/trusted-types": "^2.0.2",
+        "workbox-core": "7.4.0"
+      }
+    },
+    "node_modules/wrap-ansi": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+      "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi-cjs": {
+      "name": "wrap-ansi",
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/wrap-ansi/node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/ws": {
+      "version": "8.19.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+      "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/wsl-utils": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
+      "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-wsl": "^3.1.0",
+        "powershell-utils": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/xml-name-validator": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+      "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/xmlchars": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/yargs": {
+      "version": "17.7.2",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+      "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cliui": "^8.0.1",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.3",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^21.1.1"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "21.1.1",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+      "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/yargs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/yargs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/yargs/node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/yargs/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/yoctocolors": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
+      "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/yoctocolors-cjs": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz",
+      "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/zod": {
+      "version": "4.3.5",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
+      "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    },
+    "node_modules/zod-to-json-schema": {
+      "version": "3.25.1",
+      "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
+      "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
+      "dev": true,
+      "license": "ISC",
+      "peerDependencies": {
+        "zod": "^3.25 || ^4"
+      }
+    },
+    "node_modules/zod-validation-error": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+      "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "peerDependencies": {
+        "zod": "^3.25.0 || ^4.0.0"
+      }
+    },
+    "node_modules/zustand": {
+      "version": "5.0.9",
+      "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
+      "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.20.0"
+      },
+      "peerDependencies": {
+        "@types/react": ">=18.0.0",
+        "immer": ">=9.0.6",
+        "react": ">=18.0.0",
+        "use-sync-external-store": ">=1.2.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "immer": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        },
+        "use-sync-external-store": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 82 - 0
frontend/package.json

@@ -0,0 +1,82 @@
+{
+  "name": "frontend",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite --host",
+    "build": "tsc -b && vite build",
+    "lint": "eslint .",
+    "preview": "vite preview --host",
+    "test": "vitest run",
+    "test:watch": "vitest",
+    "test:ui": "vitest --ui",
+    "test:coverage": "vitest run --coverage",
+    "test:e2e": "playwright test",
+    "test:e2e:ui": "playwright test --ui"
+  },
+  "dependencies": {
+    "@dnd-kit/core": "^6.3.1",
+    "@dnd-kit/sortable": "^10.0.0",
+    "@dnd-kit/utilities": "^3.2.2",
+    "@radix-ui/react-accordion": "^1.2.12",
+    "@radix-ui/react-dialog": "^1.1.15",
+    "@radix-ui/react-label": "^2.1.8",
+    "@radix-ui/react-popover": "^1.1.15",
+    "@radix-ui/react-progress": "^1.1.8",
+    "@radix-ui/react-radio-group": "^1.3.8",
+    "@radix-ui/react-select": "^2.2.6",
+    "@radix-ui/react-separator": "^1.1.8",
+    "@radix-ui/react-slider": "^1.3.6",
+    "@radix-ui/react-slot": "^1.2.4",
+    "@radix-ui/react-switch": "^1.2.6",
+    "@radix-ui/react-tabs": "^1.1.13",
+    "@radix-ui/react-tooltip": "^1.2.8",
+    "@tailwindcss/postcss": "^4.1.18",
+    "@tanstack/react-query": "^5.90.16",
+    "motion": "^12.27.1",
+    "next-themes": "^0.4.6",
+    "react": "^19.2.0",
+    "react-color": "^2.19.3",
+    "react-colorful": "^5.6.1",
+    "react-dom": "^19.2.0",
+    "react-resizable-panels": "^4.4.0",
+    "react-router-dom": "^7.12.0",
+    "sonner": "^2.0.7",
+    "zustand": "^5.0.9"
+  },
+  "devDependencies": {
+    "@eslint/js": "^9.39.1",
+    "@playwright/test": "^1.58.0",
+    "@testing-library/jest-dom": "^6.9.1",
+    "@testing-library/react": "^16.3.2",
+    "@testing-library/user-event": "^14.6.1",
+    "@types/node": "^24.10.4",
+    "@types/react": "^19.2.5",
+    "@types/react-color": "^3.0.13",
+    "@types/react-dom": "^19.2.3",
+    "@vitejs/plugin-react": "^5.1.1",
+    "@vitest/coverage-v8": "^3.2.4",
+    "@vitest/ui": "^3.2.4",
+    "autoprefixer": "^10.4.23",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "eslint": "^9.39.1",
+    "eslint-plugin-react-hooks": "^7.0.1",
+    "eslint-plugin-react-refresh": "^0.4.24",
+    "globals": "^16.5.0",
+    "jsdom": "^27.0.1",
+    "lucide-react": "^0.562.0",
+    "msw": "^2.12.7",
+    "postcss": "^8.5.6",
+    "shadcn": "^3.7.0",
+    "tailwind-merge": "^3.4.0",
+    "tailwindcss": "^4.1.18",
+    "tailwindcss-animate": "^1.0.7",
+    "typescript": "~5.9.3",
+    "typescript-eslint": "^8.46.4",
+    "vite": "^7.2.4",
+    "vite-plugin-pwa": "^1.2.0",
+    "vitest": "^3.2.4"
+  }
+}

+ 28 - 0
frontend/playwright.config.ts

@@ -0,0 +1,28 @@
+import { defineConfig, devices } from '@playwright/test'
+
+// Use a dedicated port for E2E tests to avoid conflicts with other dev servers
+const E2E_PORT = 5174
+
+export default defineConfig({
+  testDir: './e2e',
+  fullyParallel: true,
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 2 : 0,
+  workers: process.env.CI ? 1 : undefined,
+  reporter: 'html',
+  use: {
+    baseURL: `http://localhost:${E2E_PORT}`,
+    trace: 'on-first-retry',
+  },
+  projects: [
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'] },
+    },
+  ],
+  webServer: {
+    command: `npm run dev -- --port ${E2E_PORT}`,
+    url: `http://localhost:${E2E_PORT}`,
+    reuseExistingServer: !process.env.CI,
+  },
+})

+ 5 - 0
frontend/postcss.config.js

@@ -0,0 +1,5 @@
+export default {
+  plugins: {
+    '@tailwindcss/postcss': {},
+  },
+}

+ 28 - 0
frontend/src/App.tsx

@@ -0,0 +1,28 @@
+import { Routes, Route } from 'react-router-dom'
+import { Layout } from '@/components/layout/Layout'
+import { BrowsePage } from '@/pages/BrowsePage'
+import { PlaylistsPage } from '@/pages/PlaylistsPage'
+import { TableControlPage } from '@/pages/TableControlPage'
+import { LEDPage } from '@/pages/LEDPage'
+import { SettingsPage } from '@/pages/SettingsPage'
+import { Toaster } from '@/components/ui/sonner'
+import { TableProvider } from '@/contexts/TableContext'
+
+function App() {
+  return (
+    <TableProvider>
+      <Routes>
+        <Route path="/" element={<Layout />}>
+          <Route index element={<BrowsePage />} />
+          <Route path="playlists" element={<PlaylistsPage />} />
+          <Route path="table-control" element={<TableControlPage />} />
+          <Route path="led" element={<LEDPage />} />
+          <Route path="settings" element={<SettingsPage />} />
+        </Route>
+      </Routes>
+      <Toaster position="top-center" richColors closeButton />
+    </TableProvider>
+  )
+}
+
+export default App

+ 79 - 0
frontend/src/__tests__/components/NowPlayingBar.test.tsx

@@ -0,0 +1,79 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders } from '../../test/utils'
+import { NowPlayingBar } from '../../components/NowPlayingBar'
+
+describe('NowPlayingBar', () => {
+  const defaultProps = {
+    isVisible: true,
+    onClose: vi.fn(),
+  }
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  describe('Visibility', () => {
+    it('renders when visible', () => {
+      const { container } = renderWithProviders(<NowPlayingBar {...defaultProps} />)
+      // Component should render (even if empty initially)
+      expect(container).toBeTruthy()
+    })
+
+    it('does not render content when isVisible is false', () => {
+      const { container } = renderWithProviders(
+        <NowPlayingBar {...defaultProps} isVisible={false} />
+      )
+      // When not visible, should return null
+      expect(container.firstChild).toBeNull()
+    })
+
+    it('calls onClose callback', () => {
+      const onClose = vi.fn()
+      renderWithProviders(<NowPlayingBar {...defaultProps} onClose={onClose} />)
+      // onClose is passed correctly
+      expect(onClose).not.toHaveBeenCalled()
+    })
+  })
+
+  describe('Props Handling', () => {
+    it('accepts logsDrawerHeight prop', () => {
+      expect(() => {
+        renderWithProviders(
+          <NowPlayingBar {...defaultProps} logsDrawerHeight={300} />
+        )
+      }).not.toThrow()
+    })
+
+    it('accepts openExpanded prop', () => {
+      expect(() => {
+        renderWithProviders(
+          <NowPlayingBar {...defaultProps} openExpanded={true} />
+        )
+      }).not.toThrow()
+    })
+
+    it('accepts isLogsOpen prop', () => {
+      expect(() => {
+        renderWithProviders(
+          <NowPlayingBar {...defaultProps} isLogsOpen={true} />
+        )
+      }).not.toThrow()
+    })
+  })
+
+  describe('Component Structure', () => {
+    it('renders without crashing with all props', () => {
+      expect(() => {
+        renderWithProviders(
+          <NowPlayingBar
+            isVisible={true}
+            onClose={() => {}}
+            isLogsOpen={false}
+            logsDrawerHeight={256}
+            openExpanded={false}
+          />
+        )
+      }).not.toThrow()
+    })
+  })
+})

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

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

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

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

+ 257 - 0
frontend/src/__tests__/integration/playlistFlow.test.tsx

@@ -0,0 +1,257 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
+import { mockData, apiCallLog, resetApiCallLog } from '../../test/mocks/handlers'
+import { PlaylistsPage } from '../../pages/PlaylistsPage'
+
+describe('Playlist Flow Integration', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    resetApiCallLog()
+    // Clear localStorage to start fresh
+    localStorage.clear()
+  })
+
+  describe('View and Select Playlist Flow', () => {
+    it('displays playlist list from API', async () => {
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+        expect(screen.getByText('favorites')).toBeInTheDocument()
+        expect(screen.getByText('geometric')).toBeInTheDocument()
+      })
+    })
+
+    it('displays page title and count', async () => {
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Playlists')).toBeInTheDocument()
+        expect(screen.getByText(/3 playlists/i)).toBeInTheDocument()
+      })
+    })
+
+    it('clicking playlist selects it and loads patterns', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Click on default playlist
+      await user.click(screen.getByText('default'))
+
+      // Should show playlist content with pattern count
+      await waitFor(() => {
+        // default playlist has 2 patterns
+        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
+      })
+    })
+  })
+
+  describe('Run Playlist Flow', () => {
+    it('runs existing playlist and verifies API call', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Click playlist to select it
+      await user.click(screen.getByText('default'))
+
+      // Wait for patterns to load
+      await waitFor(() => {
+        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
+      })
+
+      // Find the play button (circular button with play_arrow icon)
+      // It's a button that contains a play_arrow material icon
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.querySelector('.material-icons')?.textContent === 'play_arrow'
+      )
+      expect(playButton).toBeTruthy()
+
+      await user.click(playButton!)
+
+      // Verify API call
+      await waitFor(() => {
+        const runCall = apiCallLog.find(c => c.endpoint === '/run_playlist')
+        expect(runCall).toBeDefined()
+        expect(runCall?.body).toMatchObject({
+          playlist_name: 'default'
+        })
+      })
+
+      // Verify state updated
+      expect(mockData.status.is_running).toBe(true)
+      expect(mockData.status.playlist_mode).toBe(true)
+      expect(mockData.status.playlist_name).toBe('default')
+    })
+
+    it('populates queue from playlist files when running', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // default playlist has: ['patterns/star.thr', 'patterns/spiral.thr']
+      const initialPlaylist = mockData.playlists['default']
+      expect(initialPlaylist.length).toBe(2)
+
+      // Click and run
+      await user.click(screen.getByText('default'))
+
+      await waitFor(() => {
+        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
+      })
+
+      // Find play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.querySelector('.material-icons')?.textContent === 'play_arrow'
+      )
+      await user.click(playButton!)
+
+      // Verify queue was set correctly
+      await waitFor(() => {
+        expect(mockData.status.current_file).toBe('patterns/star.thr')
+        expect(mockData.status.queue).toContain('patterns/spiral.thr')
+      })
+    })
+  })
+
+  describe('Create Playlist Flow', () => {
+    it('creates new playlist via dialog', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      // Wait for page to load
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Click create button (the + icon button in sidebar header)
+      const buttons = screen.getAllByRole('button')
+      const addButton = buttons.find(btn => {
+        const icon = btn.querySelector('.material-icons-outlined')
+        return icon?.textContent === 'add'
+      })
+
+      expect(addButton).toBeTruthy()
+      await user.click(addButton!)
+
+      // Fill in dialog
+      await waitFor(() => {
+        expect(screen.getByRole('dialog')).toBeInTheDocument()
+      })
+
+      const nameInput = screen.getByPlaceholderText(/favorites.*morning.*patterns/i)
+      await user.type(nameInput, 'my-test-playlist')
+
+      // Submit by clicking Create Playlist button
+      const submitButton = screen.getByRole('button', { name: /create playlist/i })
+      await user.click(submitButton)
+
+      // Verify API call
+      await waitFor(() => {
+        const createCall = apiCallLog.find(c => c.endpoint === '/create_playlist')
+        expect(createCall).toBeDefined()
+        expect(createCall?.body).toMatchObject({
+          playlist_name: 'my-test-playlist'
+        })
+      })
+
+      // Verify mockData was updated
+      expect(mockData.playlists['my-test-playlist']).toBeDefined()
+    })
+  })
+
+  describe('Delete Playlist Flow', () => {
+    it('deletes playlist after confirmation', async () => {
+      const user = userEvent.setup()
+
+      // Add a test playlist to delete
+      mockData.playlists['to-delete'] = ['patterns/star.thr']
+
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('to-delete')).toBeInTheDocument()
+      })
+
+      // Find and hover over the playlist item to reveal delete button
+      const playlistItem = screen.getByText('to-delete')
+
+      // The delete button is a sibling of the text
+      const parentDiv = playlistItem.closest('[class*="group"]')
+      expect(parentDiv).toBeTruthy()
+
+      // Find delete button within the same row (uses Trash2 lucide icon with text-destructive class)
+      const deleteButtons = parentDiv!.querySelectorAll('button')
+      const deleteButton = Array.from(deleteButtons).find(btn =>
+        btn.classList.contains('text-destructive') || btn.className.includes('text-destructive')
+      )
+
+      expect(deleteButton).toBeTruthy()
+
+      // Mock window.confirm
+      const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+      await user.click(deleteButton!)
+
+      // Verify confirm was called
+      expect(confirmSpy).toHaveBeenCalled()
+
+      // Verify API call
+      await waitFor(() => {
+        const deleteCall = apiCallLog.find(c => c.endpoint === '/delete_playlist')
+        expect(deleteCall).toBeDefined()
+      })
+
+      // Verify mockData was updated
+      expect(mockData.playlists['to-delete']).toBeUndefined()
+
+      confirmSpy.mockRestore()
+    })
+  })
+
+  describe('Playlist State Verification', () => {
+    it('verifies run_playlist API call format', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Run playlist
+      await user.click(screen.getByText('default'))
+
+      await waitFor(() => {
+        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
+      })
+
+      // Find play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.querySelector('.material-icons')?.textContent === 'play_arrow'
+      )
+      await user.click(playButton!)
+
+      // Verify complete API call structure
+      await waitFor(() => {
+        const runCall = apiCallLog.find(c => c.endpoint === '/run_playlist')
+        expect(runCall).toBeDefined()
+        expect(runCall?.method).toBe('POST')
+        expect(runCall?.timestamp).toBeDefined()
+        expect(runCall?.body).toHaveProperty('playlist_name')
+      })
+    })
+  })
+})

+ 176 - 0
frontend/src/__tests__/pages/BrowsePage.test.tsx

@@ -0,0 +1,176 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
+import { server } from '../../test/mocks/server'
+import { http, HttpResponse } from 'msw'
+import { BrowsePage } from '../../pages/BrowsePage'
+
+describe('BrowsePage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  describe('Pattern Listing', () => {
+    it('renders pattern list from API', async () => {
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
+        expect(screen.getByText('wave.thr')).toBeInTheDocument()
+      })
+    })
+
+    it('displays page title', async () => {
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Browse Patterns')).toBeInTheDocument()
+      })
+    })
+
+    it('handles empty pattern list', async () => {
+      server.use(
+        http.get('/list_theta_rho_files_with_metadata', () => {
+          return HttpResponse.json([])
+        })
+      )
+
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText(/no patterns found/i)).toBeInTheDocument()
+      })
+    })
+
+    it('handles API error gracefully', async () => {
+      server.use(
+        http.get('/list_theta_rho_files_with_metadata', () => {
+          return HttpResponse.error()
+        })
+      )
+
+      renderWithProviders(<BrowsePage />)
+
+      // Should not crash - page should still render
+      await waitFor(() => {
+        expect(screen.getByText('Browse Patterns')).toBeInTheDocument()
+      })
+    })
+  })
+
+  describe('Pattern Selection', () => {
+    it('clicking pattern opens detail sheet', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Click on a pattern
+      await user.click(screen.getByText('star.thr'))
+
+      // Sheet should open with pattern name in title
+      await waitFor(() => {
+        // The Sheet title contains the pattern name
+        const titles = screen.getAllByText('star.thr')
+        expect(titles.length).toBeGreaterThan(1) // One in card, one in sheet title
+      })
+    })
+  })
+
+  describe('Search and Filter', () => {
+    it('search filters patterns by name', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Find and type in search input
+      const searchInput = screen.getByPlaceholderText(/search/i)
+      await user.type(searchInput, 'star')
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+        expect(screen.queryByText('spiral.thr')).not.toBeInTheDocument()
+      })
+    })
+
+    it('clearing search shows all patterns', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      const searchInput = screen.getByPlaceholderText(/search/i)
+      await user.type(searchInput, 'star')
+
+      await waitFor(() => {
+        expect(screen.queryByText('spiral.thr')).not.toBeInTheDocument()
+      })
+
+      await user.clear(searchInput)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
+      })
+    })
+
+    it('no results message shows clear filters button', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      const searchInput = screen.getByPlaceholderText(/search/i)
+      await user.type(searchInput, 'nonexistentpattern')
+
+      await waitFor(() => {
+        expect(screen.getByText(/no patterns found/i)).toBeInTheDocument()
+        expect(screen.getByText(/clear filters/i)).toBeInTheDocument()
+      })
+    })
+  })
+
+  describe('Pattern Actions', () => {
+    it('clicking pattern opens sheet with pattern details', async () => {
+      const user = userEvent.setup()
+
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Click pattern to open detail sheet
+      await user.click(screen.getByText('star.thr'))
+
+      // Sheet should open - the pattern name appears twice (once in list, once in sheet title)
+      await waitFor(() => {
+        const patternNames = screen.getAllByText('star.thr')
+        expect(patternNames.length).toBeGreaterThan(1)
+      })
+    })
+
+    it('pattern cards are clickable', async () => {
+      const user = userEvent.setup()
+
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Pattern cards should be clickable
+      const patternCard = screen.getByText('star.thr')
+      await expect(user.click(patternCard)).resolves.not.toThrow()
+    })
+  })
+})

+ 222 - 0
frontend/src/__tests__/pages/PlaylistsPage.test.tsx

@@ -0,0 +1,222 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
+import { server } from '../../test/mocks/server'
+import { http, HttpResponse } from 'msw'
+import { PlaylistsPage } from '../../pages/PlaylistsPage'
+
+describe('PlaylistsPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  describe('Playlist Listing', () => {
+    it('renders playlist names from API', async () => {
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+        expect(screen.getByText('favorites')).toBeInTheDocument()
+        expect(screen.getByText('geometric')).toBeInTheDocument()
+      })
+    })
+
+    it('displays page title and description', async () => {
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Playlists')).toBeInTheDocument()
+        expect(screen.getByText(/create and manage pattern playlists/i)).toBeInTheDocument()
+      })
+    })
+
+    it('shows playlist count', async () => {
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('3 playlists')).toBeInTheDocument()
+      })
+    })
+
+    it('handles empty playlist list', async () => {
+      server.use(
+        http.get('/list_all_playlists', () => {
+          return HttpResponse.json([])
+        })
+      )
+
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText(/no playlists yet/i)).toBeInTheDocument()
+      })
+    })
+  })
+
+  describe('Playlist Selection', () => {
+    it('clicking playlist loads its patterns', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Click on playlist
+      await user.click(screen.getByText('default'))
+
+      // Should show patterns from that playlist
+      // The patterns are displayed in the content area
+      await waitFor(() => {
+        // Playlist 'default' contains star.thr and spiral.thr based on mock data
+        expect(screen.getByText(/star/i)).toBeInTheDocument()
+      })
+    })
+  })
+
+  describe('Playlist CRUD', () => {
+    it('create button opens modal', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Find the add button (plus icon in sidebar header)
+      const addButtons = screen.getAllByRole('button')
+      const addButton = addButtons.find(btn => btn.querySelector('.material-icons-outlined')?.textContent?.includes('add'))
+
+      if (addButton) {
+        await user.click(addButton)
+
+        // Dialog should open
+        await waitFor(() => {
+          expect(screen.getByRole('dialog')).toBeInTheDocument()
+        })
+      }
+    })
+
+    it('create playlist calls API', async () => {
+      const user = userEvent.setup()
+      let createCalled = false
+      let createdName = ''
+
+      server.use(
+        http.post('/create_playlist', async ({ request }) => {
+          const body = await request.json() as { name?: string; playlist_name?: string }
+          createCalled = true
+          createdName = body.name || body.playlist_name || ''
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Find and click add button
+      const addButtons = screen.getAllByRole('button')
+      const addButton = addButtons.find(btn => btn.querySelector('.material-icons-outlined')?.textContent?.includes('add'))
+
+      if (addButton) {
+        await user.click(addButton)
+
+        // Wait for dialog and enter name
+        await waitFor(() => {
+          expect(screen.getByRole('dialog')).toBeInTheDocument()
+        })
+
+        const input = screen.getByRole('textbox')
+        await user.type(input, 'my-new-playlist')
+
+        // Click create button
+        const createButton = screen.getByRole('button', { name: /create/i })
+        await user.click(createButton)
+
+        await waitFor(() => {
+          expect(createCalled).toBe(true)
+          expect(createdName).toBe('my-new-playlist')
+        })
+      }
+    })
+
+    it('edit button opens rename modal', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Find edit button for a playlist (hover actions)
+      const editButtons = screen.getAllByRole('button')
+      const editButton = editButtons.find(btn =>
+        btn.querySelector('.material-icons-outlined')?.textContent?.includes('edit')
+      )
+
+      if (editButton) {
+        await user.click(editButton)
+
+        await waitFor(() => {
+          expect(screen.getByRole('dialog')).toBeInTheDocument()
+        })
+      }
+    })
+
+    it('delete buttons are present for each playlist', async () => {
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('geometric')).toBeInTheDocument()
+      })
+
+      // Find delete buttons (Trash2 icon) - each playlist item should have one
+      const deleteButtons = screen.getAllByRole('button').filter(btn =>
+        btn.classList.contains('text-destructive') || btn.className.includes('text-destructive')
+      )
+
+      // Should have at least one delete button (for each playlist)
+      expect(deleteButtons.length).toBeGreaterThan(0)
+    })
+  })
+
+  describe('Playlist Execution', () => {
+    it('run playlist button triggers API', async () => {
+      const user = userEvent.setup()
+      let runCalled = false
+      let playlistName = ''
+
+      server.use(
+        http.post('/run_playlist', async ({ request }) => {
+          const body = await request.json() as { playlist_name: string }
+          runCalled = true
+          playlistName = body.playlist_name
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Select a playlist first
+      await user.click(screen.getByText('default'))
+
+      // Wait for content to load and find run/play button
+      await waitFor(async () => {
+        const runButton = screen.getByRole('button', { name: /play|run|start/i })
+        expect(runButton).toBeInTheDocument()
+        await user.click(runButton)
+      })
+
+      await waitFor(() => {
+        expect(runCalled).toBe(true)
+        expect(playlistName).toBe('default')
+      })
+    })
+  })
+})

+ 269 - 0
frontend/src/__tests__/pages/TableControlPage.test.tsx

@@ -0,0 +1,269 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
+import { server } from '../../test/mocks/server'
+import { http, HttpResponse } from 'msw'
+import { TableControlPage } from '../../pages/TableControlPage'
+
+describe('TableControlPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  describe('Rendering', () => {
+    it('renders page title and description', async () => {
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Table Control')).toBeInTheDocument()
+        expect(screen.getByText(/manual controls for your sand table/i)).toBeInTheDocument()
+      })
+    })
+
+    it('renders primary action buttons', async () => {
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        // Home, Stop, Reset buttons should be visible
+        expect(screen.getByText('Home')).toBeInTheDocument()
+        expect(screen.getByText('Stop')).toBeInTheDocument()
+        expect(screen.getByText('Reset')).toBeInTheDocument()
+      })
+    })
+
+    it('renders position control buttons', async () => {
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        // Center and Perimeter buttons
+        expect(screen.getByText('Center')).toBeInTheDocument()
+        expect(screen.getByText('Perimeter')).toBeInTheDocument()
+      })
+    })
+
+    it('renders speed control section', async () => {
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Speed')).toBeInTheDocument()
+        expect(screen.getByPlaceholderText(/mm\/s/i)).toBeInTheDocument()
+      })
+    })
+  })
+
+  describe('Homing Control', () => {
+    it('home button calls send_home API', async () => {
+      const user = userEvent.setup()
+      let homeCalled = false
+
+      server.use(
+        http.post('/send_home', () => {
+          homeCalled = true
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Home')).toBeInTheDocument()
+      })
+
+      const homeButton = screen.getByText('Home').closest('button')!
+      await user.click(homeButton)
+
+      await waitFor(() => {
+        expect(homeCalled).toBe(true)
+      })
+    })
+  })
+
+  describe('Stop Control', () => {
+    it('stop button calls stop_execution API', async () => {
+      const user = userEvent.setup()
+      let stopCalled = false
+
+      server.use(
+        http.post('/stop_execution', () => {
+          stopCalled = true
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Stop')).toBeInTheDocument()
+      })
+
+      const stopButton = screen.getByText('Stop').closest('button')!
+      await user.click(stopButton)
+
+      await waitFor(() => {
+        expect(stopCalled).toBe(true)
+      })
+    })
+  })
+
+  describe('Reset Control', () => {
+    it('reset button is clickable', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Reset')).toBeInTheDocument()
+      })
+
+      const resetButton = screen.getByText('Reset').closest('button')!
+      expect(resetButton).toBeEnabled()
+
+      // Click should not throw
+      await expect(user.click(resetButton)).resolves.not.toThrow()
+    })
+
+    it('reset button triggers dialog trigger', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Reset')).toBeInTheDocument()
+      })
+
+      // The Reset button is a DialogTrigger - check its aria attributes
+      const resetButton = screen.getByText('Reset').closest('button')!
+      expect(resetButton).toHaveAttribute('aria-haspopup', 'dialog')
+
+      await user.click(resetButton)
+
+      // After clicking, aria-expanded should change
+      await waitFor(() => {
+        // The button should have triggered the dialog
+        // Note: Radix Dialog renders to a portal, may need to check document.body
+        const dialog = document.querySelector('[role="dialog"]')
+        if (dialog) {
+          expect(dialog).toBeInTheDocument()
+        }
+      })
+    })
+  })
+
+  describe('Movement Controls', () => {
+    it('move to center button calls API', async () => {
+      const user = userEvent.setup()
+      let moveCalled = false
+
+      server.use(
+        http.post('/move_to_center', () => {
+          moveCalled = true
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Center')).toBeInTheDocument()
+      })
+
+      const centerButton = screen.getByText('Center').closest('button')!
+      await user.click(centerButton)
+
+      await waitFor(() => {
+        expect(moveCalled).toBe(true)
+      })
+    })
+
+    it('move to perimeter button calls API', async () => {
+      const user = userEvent.setup()
+      let moveCalled = false
+
+      server.use(
+        http.post('/move_to_perimeter', () => {
+          moveCalled = true
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Perimeter')).toBeInTheDocument()
+      })
+
+      const perimeterButton = screen.getByText('Perimeter').closest('button')!
+      await user.click(perimeterButton)
+
+      await waitFor(() => {
+        expect(moveCalled).toBe(true)
+      })
+    })
+  })
+
+  describe('Speed Control', () => {
+    it('speed input submits to API on Set click', async () => {
+      const user = userEvent.setup()
+      let speedSet: number | null = null
+
+      server.use(
+        http.post('/set_speed', async ({ request }) => {
+          const body = await request.json() as { speed: number }
+          speedSet = body.speed
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByPlaceholderText(/mm\/s/i)).toBeInTheDocument()
+      })
+
+      const speedInput = screen.getByPlaceholderText(/mm\/s/i)
+      await user.type(speedInput, '250')
+
+      // Find the Set button - it's near the speed input
+      const speedCard = speedInput.closest('.p-6')
+      const setButton = speedCard?.querySelector('button')
+      expect(setButton).toBeTruthy()
+      await user.click(setButton!)
+
+      await waitFor(() => {
+        expect(speedSet).toBe(250)
+      })
+    })
+
+    it('speed input submits on Enter key', async () => {
+      const user = userEvent.setup()
+      let speedSet: number | null = null
+
+      server.use(
+        http.post('/set_speed', async ({ request }) => {
+          const body = await request.json() as { speed: number }
+          speedSet = body.speed
+          return HttpResponse.json({ success: true })
+        })
+      )
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByPlaceholderText(/mm\/s/i)).toBeInTheDocument()
+      })
+
+      const speedInput = screen.getByPlaceholderText(/mm\/s/i)
+      await user.type(speedInput, '300{Enter}')
+
+      await waitFor(() => {
+        expect(speedSet).toBe(300)
+      })
+    })
+
+    it('shows speed badge with current speed', async () => {
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        // The speed badge shows "-- mm/s" when no speed is set
+        expect(screen.getByText(/mm\/s/)).toBeInTheDocument()
+      })
+    })
+  })
+})

+ 20 - 0
frontend/src/__tests__/sample.test.tsx

@@ -0,0 +1,20 @@
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect } from 'vitest'
+
+// Simple component for testing infrastructure
+function SampleComponent({ message }: { message: string }) {
+  return <div data-testid="sample">{message}</div>
+}
+
+describe('Test Infrastructure', () => {
+  it('renders component with React Testing Library', () => {
+    render(<SampleComponent message="Hello Test" />)
+    expect(screen.getByTestId('sample')).toHaveTextContent('Hello Test')
+  })
+
+  it('has jest-dom matchers available', () => {
+    render(<SampleComponent message="Visible" />)
+    expect(screen.getByTestId('sample')).toBeInTheDocument()
+    expect(screen.getByTestId('sample')).toBeVisible()
+  })
+})

+ 1470 - 0
frontend/src/components/NowPlayingBar.tsx

@@ -0,0 +1,1470 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Progress } from '@/components/ui/progress'
+import { Input } from '@/components/ui/input'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { apiClient } from '@/lib/apiClient'
+import {
+  DndContext,
+  closestCenter,
+  KeyboardSensor,
+  PointerSensor,
+  useSensor,
+  useSensors,
+} from '@dnd-kit/core'
+import type { DragEndEvent } from '@dnd-kit/core'
+import {
+  SortableContext,
+  sortableKeyboardCoordinates,
+  useSortable,
+  verticalListSortingStrategy,
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+
+type Coordinate = [number, number]
+
+interface PlaybackStatus {
+  current_file: string | null
+  is_paused: boolean
+  manual_pause: boolean
+  scheduled_pause: boolean
+  is_running: boolean
+  progress: {
+    current: number
+    total: number
+    remaining_time: number | null
+    elapsed_time: number
+    percentage: number
+    last_completed_time?: {
+      actual_time_seconds: number
+      actual_time_formatted: string
+      timestamp: string
+    }
+  } | null
+  playlist: {
+    current_index: number
+    total_files: number
+    mode: string
+    next_file: string | null
+    files: string[]
+    name: string | null
+  } | null
+  speed: number
+  pause_time_remaining: number
+  original_pause_time: number | null
+  connection_status: boolean
+  current_theta: number
+  current_rho: number
+}
+
+function formatTime(seconds: number): string {
+  if (!seconds || seconds < 0) return '--:--'
+  const mins = Math.floor(seconds / 60)
+  const secs = Math.floor(seconds % 60)
+  return `${mins}:${secs.toString().padStart(2, '0')}`
+}
+
+function formatPatternName(path: string | null): string {
+  if (!path) return 'Unknown'
+  // Extract filename without extension and path
+  const name = path.split('/').pop()?.replace('.thr', '') || path
+  return name
+}
+
+// Sortable queue item component for drag-and-drop (upcoming patterns only)
+interface SortableQueueItemProps {
+  id: string
+  file: string
+  index: number
+  previewUrl: string | null
+  isFirst: boolean
+  isLast: boolean
+  onMoveToTop: () => void
+  onMoveToBottom: () => void
+  requestPreview: (file: string) => void
+}
+
+function SortableQueueItem({
+  id,
+  file,
+  index,
+  previewUrl,
+  isFirst,
+  isLast,
+  onMoveToTop,
+  onMoveToBottom,
+  requestPreview,
+}: SortableQueueItemProps) {
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({ id })
+
+  const previewContainerRef = useRef<HTMLDivElement>(null)
+  const hasRequestedRef = useRef(false)
+
+  // Lazy load preview when item becomes visible
+  useEffect(() => {
+    if (!previewContainerRef.current || previewUrl || hasRequestedRef.current) return
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        entries.forEach((entry) => {
+          if (entry.isIntersecting && !hasRequestedRef.current) {
+            hasRequestedRef.current = true
+            requestPreview(file)
+            observer.disconnect()
+          }
+        })
+      },
+      { rootMargin: '50px' }
+    )
+
+    observer.observe(previewContainerRef.current)
+
+    return () => observer.disconnect()
+  }, [file, previewUrl, requestPreview])
+
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+    opacity: isDragging ? 0.5 : 1,
+    zIndex: isDragging ? 1000 : 'auto',
+  }
+
+  return (
+    <div
+      ref={setNodeRef}
+      style={style}
+      className={`group flex items-center gap-2 p-2 rounded-lg transition-colors hover:bg-muted/50 ${isDragging ? 'shadow-lg bg-background' : ''}`}
+    >
+      {/* Drag handle */}
+      <div
+        {...attributes}
+        {...listeners}
+        className="w-6 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing touch-none"
+      >
+        <span className="material-icons-outlined text-muted-foreground text-sm">drag_indicator</span>
+      </div>
+
+      {/* Preview thumbnail */}
+      <div ref={previewContainerRef} className="w-28 h-28 rounded-full overflow-hidden bg-muted border shrink-0">
+        {previewUrl ? (
+          <img
+            src={previewUrl}
+            alt=""
+            loading="lazy"
+            className="w-full h-full object-cover pattern-preview"
+          />
+        ) : (
+          <div className="w-full h-full flex items-center justify-center">
+            <span className="material-icons-outlined text-muted-foreground text-4xl">image</span>
+          </div>
+        )}
+      </div>
+
+      {/* Pattern name */}
+      <div className="flex-1 min-w-0">
+        <p className="text-sm truncate">{formatPatternName(file)}</p>
+        <p className="text-xs text-muted-foreground">#{index + 1}</p>
+      </div>
+
+      {/* Move to top/bottom buttons - always visible on mobile, hover on desktop */}
+      <div className="flex flex-col gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity shrink-0">
+        <button
+          onClick={onMoveToTop}
+          disabled={isFirst}
+          className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
+          title="Move to top"
+        >
+          <span className="material-icons-outlined text-sm">vertical_align_top</span>
+        </button>
+        <button
+          onClick={onMoveToBottom}
+          disabled={isLast}
+          className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
+          title="Move to bottom"
+        >
+          <span className="material-icons-outlined text-sm">vertical_align_bottom</span>
+        </button>
+      </div>
+    </div>
+  )
+}
+
+interface NowPlayingBarProps {
+  isLogsOpen?: boolean
+  logsDrawerHeight?: number
+  isVisible: boolean
+  openExpanded?: boolean
+  onClose: () => void
+}
+
+export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVisible, openExpanded = false, onClose }: NowPlayingBarProps) {
+  const [status, setStatus] = useState<PlaybackStatus | null>(null)
+  const [previewUrl, setPreviewUrl] = useState<string | null>(null)
+  const wsRef = useRef<WebSocket | null>(null)
+
+  // Expanded state for slide-up view
+  const [isExpanded, setIsExpanded] = useState(false)
+
+  // Swipe gesture handling
+  const touchStartY = useRef<number | null>(null)
+  const barRef = useRef<HTMLDivElement>(null)
+
+  const handleTouchStart = (e: React.TouchEvent) => {
+    touchStartY.current = e.touches[0].clientY
+  }
+  const handleTouchEnd = (e: React.TouchEvent) => {
+    if (touchStartY.current === null) return
+    const touchEndY = e.changedTouches[0].clientY
+    const deltaY = touchEndY - touchStartY.current
+
+    if (deltaY > 50) {
+      // Swipe down
+      if (isExpanded) {
+        setIsExpanded(false) // Collapse to mini
+      } else {
+        onClose() // Hide the bar
+      }
+    } else if (deltaY < -50 && isPlaying) {
+      // Swipe up - expand (only if playing)
+      setIsExpanded(true)
+    }
+    touchStartY.current = null
+  }
+
+  // Prevent background scroll when Now Playing bar is visible
+  useEffect(() => {
+    if (isVisible) {
+      // Lock body scroll when bar is visible on mobile
+      document.body.style.overflow = 'hidden'
+      return () => {
+        document.body.style.overflow = ''
+      }
+    }
+  }, [isVisible])
+
+  // Use native event listener for touchmove to prevent background scroll on the bar itself
+  useEffect(() => {
+    const bar = barRef.current
+    if (!bar) return
+
+    const handleTouchMove = (e: TouchEvent) => {
+      // Only prevent default if not scrolling inside a scrollable element
+      const target = e.target as HTMLElement
+      const scrollableParent = target.closest('[data-scrollable]')
+      if (!scrollableParent) {
+        e.preventDefault()
+      }
+    }
+
+    bar.addEventListener('touchmove', handleTouchMove, { passive: false })
+    return () => {
+      bar.removeEventListener('touchmove', handleTouchMove)
+    }
+  }, [])
+
+  // Open in expanded mode when openExpanded prop changes to true
+  useEffect(() => {
+    if (openExpanded && isVisible) {
+      setIsExpanded(true)
+    }
+  }, [openExpanded, isVisible])
+
+  // Listen for playback-started event from Layout (more reliable than prop)
+  useEffect(() => {
+    const handlePlaybackStarted = () => {
+      setIsExpanded(true)
+    }
+    window.addEventListener('playback-started', handlePlaybackStarted)
+    return () => window.removeEventListener('playback-started', handlePlaybackStarted)
+  }, [])
+
+  // Auto-collapse when nothing is playing (with delay to avoid race condition)
+  const isPlaying = status?.is_running || status?.is_paused
+  const collapseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  useEffect(() => {
+    // Clear any pending collapse
+    if (collapseTimeoutRef.current) {
+      clearTimeout(collapseTimeoutRef.current)
+      collapseTimeoutRef.current = null
+    }
+
+    if (!isPlaying && isExpanded) {
+      // Delay collapse to avoid race condition with playback-started
+      collapseTimeoutRef.current = setTimeout(() => {
+        setIsExpanded(false)
+      }, 500)
+    }
+
+    return () => {
+      if (collapseTimeoutRef.current) {
+        clearTimeout(collapseTimeoutRef.current)
+      }
+    }
+  }, [isPlaying, isExpanded])
+
+  const [coordinates, setCoordinates] = useState<Coordinate[]>([])
+  const canvasRef = useRef<HTMLCanvasElement>(null)
+  const offscreenCanvasRef = useRef<HTMLCanvasElement | null>(null)
+  const lastDrawnIndexRef = useRef<number>(-1)
+  const lastFileRef = useRef<string | null>(null)
+  const lastThemeRef = useRef<boolean | null>(null)
+
+  // Smooth animation refs
+  const animationFrameRef = useRef<number | null>(null)
+  const lastProgressRef = useRef<number>(0)
+  const lastProgressTimeRef = useRef<number>(0)
+  const smoothProgressRef = useRef<number>(0)
+
+  // Connect to status WebSocket (reconnects when table changes)
+  useEffect(() => {
+    let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
+    let shouldReconnect = true
+
+    const connectWebSocket = () => {
+      if (!shouldReconnect) return
+
+      // Don't interrupt an existing connection that's still connecting
+      if (wsRef.current) {
+        if (wsRef.current.readyState === WebSocket.CONNECTING) {
+          return // Already connecting, wait for it
+        }
+        if (wsRef.current.readyState === WebSocket.OPEN) {
+          wsRef.current.close()
+        }
+        wsRef.current = null
+      }
+
+      const wsUrl = apiClient.getWebSocketUrl('/ws/status')
+      const ws = new WebSocket(wsUrl)
+      // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
+      wsRef.current = ws
+
+      ws.onopen = () => {
+        if (!shouldReconnect) {
+          // Component unmounted while connecting - close the WebSocket now
+          ws.close()
+        }
+      }
+
+      ws.onmessage = (event) => {
+        if (!shouldReconnect) return
+        try {
+          const message = JSON.parse(event.data)
+          if (message.type === 'status_update' && message.data) {
+            setStatus(message.data)
+          }
+        } catch {
+          // Ignore parse errors
+        }
+      }
+
+      ws.onclose = () => {
+        if (!shouldReconnect) return
+        reconnectTimeout = setTimeout(connectWebSocket, 3000)
+      }
+    }
+
+    connectWebSocket()
+
+    // Reconnect when base URL changes (table switch)
+    const unsubscribe = apiClient.onBaseUrlChange(() => {
+      if (reconnectTimeout) {
+        clearTimeout(reconnectTimeout)
+        reconnectTimeout = null
+      }
+      // connectWebSocket handles closing existing connection safely
+      connectWebSocket()
+    })
+
+    return () => {
+      shouldReconnect = false
+      unsubscribe()
+      if (reconnectTimeout) {
+        clearTimeout(reconnectTimeout)
+      }
+      if (wsRef.current) {
+        // Only close if already OPEN - CONNECTING WebSockets will close in onopen
+        if (wsRef.current.readyState === WebSocket.OPEN) {
+          wsRef.current.close()
+        }
+        wsRef.current = null
+      }
+    }
+  }, [])
+
+  // Fetch preview images for current and next patterns
+  const [nextPreviewUrl, setNextPreviewUrl] = useState<string | null>(null)
+  const lastFetchedFilesRef = useRef<string>('')
+
+  useEffect(() => {
+    // Don't fetch if not visible
+    if (!isVisible) return
+
+    const currentFile = status?.current_file
+    const nextFile = status?.playlist?.next_file
+
+    // Build list of files to fetch
+    const filesToFetch = [currentFile, nextFile].filter(Boolean) as string[]
+    const fetchKey = filesToFetch.join('|')
+
+    // Skip if we already fetched these exact files
+    if (fetchKey === lastFetchedFilesRef.current) return
+    lastFetchedFilesRef.current = fetchKey
+
+    if (filesToFetch.length > 0) {
+      apiClient.post<Record<string, { image_data?: string }>>('/preview_thr_batch', { file_names: filesToFetch })
+        .then((data) => {
+          if (currentFile && data[currentFile]?.image_data) {
+            setPreviewUrl(data[currentFile].image_data)
+          } else {
+            setPreviewUrl(null)
+          }
+          if (nextFile && data[nextFile]?.image_data) {
+            setNextPreviewUrl(data[nextFile].image_data)
+          } else {
+            setNextPreviewUrl(null)
+          }
+        })
+        .catch(() => {
+          setPreviewUrl(null)
+          setNextPreviewUrl(null)
+        })
+    } else {
+      setPreviewUrl(null)
+      setNextPreviewUrl(null)
+    }
+  }, [isVisible, status?.current_file, status?.playlist?.next_file])
+
+  // Canvas drawing functions for real-time preview
+  const polarToCartesian = useCallback((theta: number, rho: number, size: number) => {
+    const centerX = size / 2
+    const centerY = size / 2
+    const radius = (size / 2) * 0.9 * rho
+    const x = centerX + radius * Math.cos(theta)
+    const y = centerY + radius * Math.sin(theta)
+    return { x, y }
+  }, [])
+
+  const getThemeColors = useCallback(() => {
+    const isDark = document.documentElement.classList.contains('dark')
+    return {
+      isDark,
+      bgOuter: isDark ? '#1a1a1a' : '#f5f5f5',
+      bgInner: isDark ? '#262626' : '#ffffff',
+      borderColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(128, 128, 128, 0.3)',
+      lineColor: isDark ? '#e5e5e5' : '#333333',
+      markerBorder: isDark ? '#333333' : '#ffffff',
+    }
+  }, [])
+
+  const initOffscreenCanvas = useCallback((size: number, coords: Coordinate[]) => {
+    const colors = getThemeColors()
+
+    if (!offscreenCanvasRef.current) {
+      offscreenCanvasRef.current = document.createElement('canvas')
+    }
+
+    const offscreen = offscreenCanvasRef.current
+    offscreen.width = size
+    offscreen.height = size
+
+    const ctx = offscreen.getContext('2d')
+    if (!ctx) return
+
+    ctx.fillStyle = colors.bgOuter
+    ctx.fillRect(0, 0, size, size)
+
+    ctx.beginPath()
+    ctx.arc(size / 2, size / 2, (size / 2) * 0.95, 0, Math.PI * 2)
+    ctx.fillStyle = colors.bgInner
+    ctx.fill()
+    ctx.strokeStyle = colors.borderColor
+    ctx.lineWidth = 1
+    ctx.stroke()
+
+    ctx.strokeStyle = colors.lineColor
+    ctx.lineWidth = 1.5
+    ctx.lineCap = 'round'
+    ctx.lineJoin = 'round'
+
+    if (coords.length > 0) {
+      const firstPoint = polarToCartesian(coords[0][0], coords[0][1], size)
+      ctx.beginPath()
+      ctx.moveTo(firstPoint.x, firstPoint.y)
+      ctx.stroke()
+    }
+
+    lastDrawnIndexRef.current = 0
+    lastThemeRef.current = colors.isDark
+  }, [getThemeColors, polarToCartesian])
+
+  const drawPattern = useCallback((ctx: CanvasRenderingContext2D, coords: Coordinate[], smoothIndex: number, forceRedraw = false) => {
+    const canvas = ctx.canvas
+    const size = canvas.width
+    const colors = getThemeColors()
+
+    // Apply 16 coordinate offset for physical latency
+    const adjustedSmoothIndex = Math.max(0, smoothIndex - 16)
+    const adjustedIndex = Math.floor(adjustedSmoothIndex)
+
+    const needsReinit = forceRedraw ||
+      !offscreenCanvasRef.current ||
+      lastThemeRef.current !== colors.isDark ||
+      adjustedIndex < lastDrawnIndexRef.current
+
+    if (needsReinit) {
+      initOffscreenCanvas(size, coords)
+    }
+
+    const offscreen = offscreenCanvasRef.current
+    if (!offscreen) return
+
+    const offCtx = offscreen.getContext('2d')
+    if (!offCtx) return
+
+    if (coords.length > 0 && adjustedIndex > lastDrawnIndexRef.current) {
+      offCtx.strokeStyle = colors.lineColor
+      offCtx.lineWidth = 1.5
+      offCtx.lineCap = 'round'
+      offCtx.lineJoin = 'round'
+
+      offCtx.beginPath()
+      const startPoint = polarToCartesian(
+        coords[lastDrawnIndexRef.current][0],
+        coords[lastDrawnIndexRef.current][1],
+        size
+      )
+      offCtx.moveTo(startPoint.x, startPoint.y)
+
+      for (let i = lastDrawnIndexRef.current + 1; i <= adjustedIndex && i < coords.length; i++) {
+        const point = polarToCartesian(coords[i][0], coords[i][1], size)
+        offCtx.lineTo(point.x, point.y)
+      }
+      offCtx.stroke()
+
+      lastDrawnIndexRef.current = adjustedIndex
+    }
+
+    ctx.drawImage(offscreen, 0, 0)
+
+    // Draw current position marker with smooth interpolation between coordinates
+    if (coords.length > 0 && adjustedIndex < coords.length - 1) {
+      const fraction = adjustedSmoothIndex - adjustedIndex
+      const currentCoord = coords[adjustedIndex]
+      const nextCoord = coords[Math.min(adjustedIndex + 1, coords.length - 1)]
+
+      // Interpolate theta and rho
+      const interpTheta = currentCoord[0] + (nextCoord[0] - currentCoord[0]) * fraction
+      const interpRho = currentCoord[1] + (nextCoord[1] - currentCoord[1]) * fraction
+
+      const currentPoint = polarToCartesian(interpTheta, interpRho, size)
+      ctx.beginPath()
+      ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2)
+      ctx.fillStyle = '#0b80ee'
+      ctx.fill()
+      ctx.strokeStyle = colors.markerBorder
+      ctx.lineWidth = 2
+      ctx.stroke()
+    } else if (coords.length > 0 && adjustedIndex < coords.length) {
+      // At the last coordinate, just draw without interpolation
+      const currentPoint = polarToCartesian(coords[adjustedIndex][0], coords[adjustedIndex][1], size)
+      ctx.beginPath()
+      ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2)
+      ctx.fillStyle = '#0b80ee'
+      ctx.fill()
+      ctx.strokeStyle = colors.markerBorder
+      ctx.lineWidth = 2
+      ctx.stroke()
+    }
+  }, [getThemeColors, initOffscreenCanvas, polarToCartesian])
+
+  // Fetch coordinates when file changes or fullscreen opens
+  useEffect(() => {
+    const currentFile = status?.current_file
+    if (!currentFile) return
+
+    // Only fetch if file changed or we don't have coordinates yet
+    const needsFetch = currentFile !== lastFileRef.current || coordinates.length === 0
+
+    if (!needsFetch) return
+
+    lastFileRef.current = currentFile
+    lastDrawnIndexRef.current = -1
+
+    apiClient.post<{ coordinates?: Coordinate[] }>('/get_theta_rho_coordinates', { file_name: currentFile })
+      .then((data) => {
+        if (data.coordinates && Array.isArray(data.coordinates)) {
+          setCoordinates(data.coordinates)
+        }
+      })
+      .catch((err) => {
+        console.error('Failed to fetch coordinates:', err)
+        setCoordinates([])
+      })
+  }, [status?.current_file, coordinates.length])
+
+  // Get target index from progress percentage
+  const getTargetIndex = useCallback((coords: Coordinate[]): number => {
+    if (coords.length === 0) return 0
+    const progressPercent = status?.progress?.percentage || 0
+    return (progressPercent / 100) * coords.length
+  }, [status?.progress?.percentage])
+
+  // Track progress updates for smooth interpolation
+  useEffect(() => {
+    const currentProgress = status?.progress?.percentage || 0
+    if (currentProgress !== lastProgressRef.current) {
+      lastProgressRef.current = currentProgress
+      lastProgressTimeRef.current = performance.now()
+    }
+  }, [status?.progress?.percentage])
+
+  // Smooth animation loop
+  useEffect(() => {
+    if (!isExpanded || coordinates.length === 0) return
+
+    const isPaused = status?.is_paused || false
+    const coordsPerSecond = 4.2
+
+    const animate = () => {
+      if (!canvasRef.current) return
+
+      const ctx = canvasRef.current.getContext('2d')
+      if (!ctx) return
+
+      const targetIndex = getTargetIndex(coordinates)
+      const now = performance.now()
+      const timeSinceUpdate = (now - lastProgressTimeRef.current) / 1000
+
+      let smoothIndex: number
+      if (isPaused) {
+        // When paused, just use the target index directly
+        smoothIndex = targetIndex
+      } else {
+        // Interpolate: start from where we were at last update, advance based on time
+        const baseIndex = (lastProgressRef.current / 100) * coordinates.length
+        smoothIndex = baseIndex + (timeSinceUpdate * coordsPerSecond)
+        // Don't overshoot the target too much
+        smoothIndex = Math.min(smoothIndex, targetIndex + 2)
+      }
+
+      smoothProgressRef.current = smoothIndex
+      drawPattern(ctx, coordinates, smoothIndex)
+
+      animationFrameRef.current = requestAnimationFrame(animate)
+    }
+
+    // Initial draw with force redraw
+    const timer = setTimeout(() => {
+      if (!canvasRef.current) return
+      const ctx = canvasRef.current.getContext('2d')
+      if (!ctx) return
+
+      lastDrawnIndexRef.current = -1
+      offscreenCanvasRef.current = null
+      smoothProgressRef.current = getTargetIndex(coordinates)
+      lastProgressTimeRef.current = performance.now()
+
+      drawPattern(ctx, coordinates, smoothProgressRef.current, true)
+
+      // Start animation loop
+      animationFrameRef.current = requestAnimationFrame(animate)
+    }, 50)
+
+    return () => {
+      clearTimeout(timer)
+      if (animationFrameRef.current) {
+        cancelAnimationFrame(animationFrameRef.current)
+      }
+    }
+  }, [isExpanded, coordinates, status?.is_paused, drawPattern, getTargetIndex])
+
+  const handlePause = async () => {
+    try {
+      const endpoint = status?.is_paused ? '/resume_execution' : '/pause_execution'
+      await apiClient.post(endpoint)
+      toast.success(status?.is_paused ? 'Resumed' : 'Paused')
+    } catch {
+      toast.error('Failed to toggle pause')
+    }
+  }
+
+  const handleStop = async () => {
+    try {
+      await apiClient.post('/stop_execution')
+      toast.success('Stopped')
+    } catch {
+      // Normal stop failed, try force stop
+      try {
+        await apiClient.post('/force_stop')
+        toast.success('Force stopped')
+      } catch {
+        toast.error('Failed to stop')
+      }
+    }
+  }
+
+  const handleSkip = async () => {
+    try {
+      await apiClient.post('/skip_pattern')
+      toast.success('Skipping to next pattern')
+    } catch {
+      toast.error('Failed to skip')
+    }
+  }
+
+  const [speedInput, setSpeedInput] = useState('')
+  const [showQueue, setShowQueue] = useState(false)
+  const [queuePreviews, setQueuePreviews] = useState<Record<string, string>>({})
+
+  // Queue dialog swipe-to-dismiss
+  const queueTouchStartY = useRef<number | null>(null)
+  const queueDialogRef = useRef<HTMLDivElement>(null)
+
+  const handleQueueTouchStart = (e: React.TouchEvent) => {
+    queueTouchStartY.current = e.touches[0].clientY
+  }
+
+  const handleQueueTouchEnd = (e: React.TouchEvent) => {
+    if (queueTouchStartY.current === null) return
+    const touchEndY = e.changedTouches[0].clientY
+    const deltaY = touchEndY - queueTouchStartY.current
+
+    // Swipe down to dismiss (only if at top of scroll or large swipe)
+    if (deltaY > 80) {
+      const scrollContainer = queueDialogRef.current?.querySelector('[data-scrollable]') as HTMLElement
+      const isAtTop = !scrollContainer || scrollContainer.scrollTop <= 0
+      if (isAtTop) {
+        setShowQueue(false)
+      }
+    }
+    queueTouchStartY.current = null
+  }
+
+  // Optimistic queue state for smooth drag-and-drop
+  const [optimisticQueue, setOptimisticQueue] = useState<string[] | null>(null)
+  const optimisticTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  // Sync optimistic queue with server state after a delay
+  // This allows the optimistic update to "stick" while the server catches up
+  useEffect(() => {
+    if (optimisticQueue && status?.playlist?.files) {
+      // Clear any pending timeout
+      if (optimisticTimeoutRef.current) {
+        clearTimeout(optimisticTimeoutRef.current)
+      }
+      // After server confirms (via WebSocket), clear optimistic state
+      // We check if server state matches our optimistic state
+      const serverOrder = status.playlist.files.join(',')
+      const optimisticOrder = optimisticQueue.join(',')
+      if (serverOrder === optimisticOrder) {
+        // Server caught up, clear optimistic state
+        setOptimisticQueue(null)
+      } else {
+        // Give server time to catch up, then accept server state
+        optimisticTimeoutRef.current = setTimeout(() => {
+          setOptimisticQueue(null)
+        }, 2000)
+      }
+    }
+    return () => {
+      if (optimisticTimeoutRef.current) {
+        clearTimeout(optimisticTimeoutRef.current)
+      }
+    }
+  }, [status?.playlist?.files, optimisticQueue])
+
+  // Use optimistic queue if available, otherwise use server state
+  const displayQueue = optimisticQueue || status?.playlist?.files || []
+
+  // Drag and drop sensors
+  const sensors = useSensors(
+    useSensor(PointerSensor, {
+      activationConstraint: {
+        distance: 8, // Require 8px movement before starting drag
+      },
+    }),
+    useSensor(KeyboardSensor, {
+      coordinateGetter: sortableKeyboardCoordinates,
+    })
+  )
+
+  const handleSpeedSubmit = async () => {
+    const speed = parseInt(speedInput)
+    if (isNaN(speed) || speed < 100 || speed > 6000) {
+      toast.error('Speed must be between 100 and 6000 mm/s')
+      return
+    }
+    try {
+      await apiClient.post('/set_speed', { speed })
+      setSpeedInput('')
+      toast.success(`Speed set to ${speed} mm/s`)
+    } catch {
+      toast.error('Failed to set speed')
+    }
+  }
+
+  // Track which files we've already requested previews for
+  const requestedPreviewsRef = useRef<Set<string>>(new Set())
+  const pendingQueuePreviewsRef = useRef<Set<string>>(new Set())
+  const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  // Batched queue preview fetching - collects requests and fetches in batches
+  const requestQueuePreview = useCallback((file: string) => {
+    // Skip if already loaded or pending
+    if (queuePreviews[file] || requestedPreviewsRef.current.has(file) || pendingQueuePreviewsRef.current.has(file)) return
+
+    pendingQueuePreviewsRef.current.add(file)
+
+    // Debounce batch fetch
+    if (batchTimeoutRef.current) clearTimeout(batchTimeoutRef.current)
+    batchTimeoutRef.current = setTimeout(async () => {
+      const filesToFetch = Array.from(pendingQueuePreviewsRef.current)
+      pendingQueuePreviewsRef.current.clear()
+      if (filesToFetch.length === 0) return
+
+      // Mark as requested
+      filesToFetch.forEach(f => requestedPreviewsRef.current.add(f))
+
+      try {
+        const data = await apiClient.post<Record<string, { image_data?: string }>>('/preview_thr_batch', { file_names: filesToFetch })
+        const newPreviews: Record<string, string> = {}
+        for (const [file, result] of Object.entries(data)) {
+          if (result.image_data) {
+            newPreviews[file] = result.image_data
+          }
+        }
+        if (Object.keys(newPreviews).length > 0) {
+          setQueuePreviews(prev => ({ ...prev, ...newPreviews }))
+        }
+      } catch (err) {
+        console.error('Failed to fetch queue previews:', err)
+      }
+    }, 100)
+  }, [queuePreviews])
+
+  // Helper to reorder array (move item from one index to another)
+  const reorderArray = (arr: string[], fromIndex: number, toIndex: number): string[] => {
+    const result = [...arr]
+    const [removed] = result.splice(fromIndex, 1)
+    result.splice(toIndex, 0, removed)
+    return result
+  }
+
+  // Handle drag end for reordering queue
+  // Since playlist now contains only main patterns, indices map directly
+  const handleDragEnd = async (event: DragEndEvent) => {
+    const { active, over } = event
+
+    if (!over || active.id === over.id || !status?.playlist?.files) return
+
+    // Extract indices from IDs
+    const fromIndex = parseInt(active.id.toString().replace('queue-item-', ''))
+    const toIndex = parseInt(over.id.toString().replace('queue-item-', ''))
+
+    if (isNaN(fromIndex) || isNaN(toIndex)) return
+
+    const currentIndex = status.playlist.current_index
+
+    // Can't move patterns that have already played
+    if (fromIndex < currentIndex) {
+      toast.error("Can't move completed pattern")
+      return
+    }
+    if (toIndex < currentIndex) {
+      toast.error("Can't move to completed position")
+      return
+    }
+
+    // Optimistically update the queue immediately
+    const currentQueue = optimisticQueue || status.playlist.files
+    const newQueue = reorderArray(currentQueue, fromIndex, toIndex)
+    setOptimisticQueue(newQueue)
+
+    try {
+      await apiClient.post('/reorder_playlist', {
+        from_index: fromIndex,
+        to_index: toIndex
+      })
+    } catch {
+      // Revert optimistic update on failure
+      setOptimisticQueue(null)
+      toast.error('Failed to reorder')
+    }
+  }
+
+  // Helper to move queue item to a specific position
+  const moveToPosition = async (fromIndex: number, toIndex: number) => {
+    if (fromIndex === toIndex || !status?.playlist?.files) return
+
+    // Optimistically update the queue immediately
+    const currentQueue = optimisticQueue || status.playlist.files
+    const newQueue = reorderArray(currentQueue, fromIndex, toIndex)
+    setOptimisticQueue(newQueue)
+
+    try {
+      await apiClient.post('/reorder_playlist', {
+        from_index: fromIndex,
+        to_index: toIndex
+      })
+    } catch {
+      // Revert optimistic update on failure
+      setOptimisticQueue(null)
+      toast.error('Failed to reorder')
+    }
+  }
+
+  // Don't render if not visible
+  if (!isVisible) {
+    return null
+  }
+
+  const patternName = formatPatternName(status?.current_file ?? null)
+  const progressPercent = status?.progress?.percentage || 0
+  const tqdmRemainingTime = status?.progress?.remaining_time || 0
+  const elapsedTime = status?.progress?.elapsed_time || 0
+
+  // Use historical time if available, otherwise fall back to tqdm estimate
+  const historicalTime = status?.progress?.last_completed_time?.actual_time_seconds
+  const remainingTime = historicalTime
+    ? Math.max(0, historicalTime - elapsedTime)
+    : tqdmRemainingTime
+  const usingHistoricalEta = !!historicalTime
+
+  // Detect waiting state between patterns
+  const isWaiting = (status?.pause_time_remaining ?? 0) > 0
+  const waitTimeRemaining = status?.pause_time_remaining ?? 0
+  const originalWaitTime = status?.original_pause_time ?? 0
+  const waitProgress = originalWaitTime > 0 ? ((originalWaitTime - waitTimeRemaining) / originalWaitTime) * 100 : 0
+
+  return (
+    <>
+      {/* Backdrop when expanded */}
+      {isExpanded && (
+        <div
+          className="fixed inset-0 bg-black/30 z-30"
+          onClick={() => setIsExpanded(false)}
+        />
+      )}
+
+      {/* Now Playing Bar - slides up to full height on mobile, 50vh on desktop when expanded */}
+      <div
+        ref={barRef}
+        className="fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300"
+        style={{
+          bottom: isLogsOpen
+            ? `calc(${logsDrawerHeight}px + 4rem + env(safe-area-inset-bottom, 0px))`
+            : 'calc(4rem + env(safe-area-inset-bottom, 0px))'
+        }}
+        data-now-playing-bar={isExpanded ? 'expanded' : 'collapsed'}
+        onTouchStart={handleTouchStart}
+        onTouchEnd={handleTouchEnd}
+      >
+        {/* Max-width container to match page layout */}
+        <div className="h-full max-w-5xl mx-auto relative">
+          {/* Swipe indicator - only on mobile */}
+          <div className="md:hidden flex justify-center pt-2 pb-1">
+            <div className="w-10 h-1 bg-muted-foreground/30 rounded-full" />
+          </div>
+
+          {/* Header with action buttons - add safe area when expanded for Dynamic Island */}
+          <div className={`absolute right-3 sm:right-4 flex items-center gap-1 z-10 ${isExpanded ? 'top-3 mt-safe' : 'top-3'}`}>
+          {/* Queue button - mobile only, when playlist exists */}
+          {isPlaying && status?.playlist && (
+            <Button
+              variant="ghost"
+              size="icon"
+              className="md:hidden h-8 w-8"
+              onClick={() => setShowQueue(true)}
+              title="View queue"
+            >
+              <span className="material-icons-outlined text-lg">queue_music</span>
+            </Button>
+          )}
+          {isPlaying && (
+            <Button
+              variant="ghost"
+              size="icon"
+              className="h-8 w-8"
+              onClick={() => setIsExpanded(!isExpanded)}
+              title={isExpanded ? 'Collapse' : 'Expand'}
+            >
+              <span className="material-icons-outlined text-lg">
+                {isExpanded ? 'expand_more' : 'expand_less'}
+              </span>
+            </Button>
+          )}
+          <Button
+            variant="ghost"
+            size="icon"
+            className="h-8 w-8"
+            onClick={onClose}
+            title="Close"
+          >
+            <span className="material-icons-outlined text-lg">close</span>
+          </Button>
+        </div>
+
+        {/* Content container */}
+        <div className="h-full flex flex-col">
+          {/* Collapsed view - Mini Bar */}
+          {!isExpanded && (
+            <div className="flex-1 flex flex-col">
+              {/* Main row with preview and controls */}
+              <div className="flex-1 flex items-center gap-6 px-6 py-4">
+                {/* Current Pattern Preview - Rounded (click to expand) */}
+                <div
+                  className="w-48 h-48 rounded-full overflow-hidden bg-muted shrink-0 border-2 cursor-pointer hover:border-primary transition-colors"
+                  onClick={() => isPlaying && setIsExpanded(true)}
+                  title={isPlaying ? 'Click to expand' : undefined}
+                >
+                  {previewUrl && isPlaying ? (
+                    <img
+                      src={previewUrl}
+                      alt={patternName}
+                      className="w-full h-full object-cover pattern-preview"
+                    />
+                  ) : (
+                    <div className="w-full h-full flex items-center justify-center">
+                      <span className="material-icons-outlined text-muted-foreground text-4xl">
+                        {isPlaying ? 'image' : 'hourglass_empty'}
+                      </span>
+                    </div>
+                  )}
+                </div>
+
+                {/* Main Content Area */}
+                {isPlaying && status ? (
+                  <>
+                    <div className="flex-1 min-w-0 flex flex-col justify-center gap-2 py-2">
+                      {/* Title Row */}
+                      <div className="flex items-center gap-3 pr-12 md:pr-16">
+                        <div className="flex-1 min-w-0">
+                          {isWaiting ? (
+                            <>
+                              <p className="text-sm md:text-base font-semibold text-muted-foreground">
+                                Waiting for next pattern...
+                              </p>
+                              {status.playlist?.next_file && (
+                                <p className="text-xs text-muted-foreground">
+                                  Up next: {formatPatternName(status.playlist.next_file)}
+                                </p>
+                              )}
+                            </>
+                          ) : (
+                            <>
+                              <p className="text-sm md:text-base font-semibold truncate">
+                                {patternName}
+                              </p>
+                              {status.playlist && (
+                                <p className="text-xs text-muted-foreground">
+                                  Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
+                                </p>
+                              )}
+                            </>
+                          )}
+                        </div>
+                      </div>
+
+                      {/* Progress Bar - Desktop only (inline, above controls) */}
+                      {isWaiting ? (
+                        <div className="hidden md:flex items-center gap-3">
+                          <span className="material-icons-outlined text-muted-foreground text-lg">hourglass_top</span>
+                          <Progress value={waitProgress} className="h-2 flex-1" />
+                          <span className="text-sm text-muted-foreground font-mono">{formatTime(waitTimeRemaining)}</span>
+                        </div>
+                      ) : (
+                        <div className="hidden md:flex items-center gap-3">
+                          <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
+                          <Progress value={progressPercent} className="h-2 flex-1" />
+                          <span
+                            className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1.5 shrink-0 ${usingHistoricalEta ? 'w-24' : 'w-14'}`}
+                            title={usingHistoricalEta ? 'ETA based on last completed run' : 'Estimated time remaining'}
+                          >
+                            {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
+                            -{formatTime(remainingTime)}
+                          </span>
+                        </div>
+                      )}
+
+                      {/* Playback Controls - Centered */}
+                      <div className="flex items-center justify-center gap-3">
+                        <Button
+                          variant="secondary"
+                          size="icon"
+                          className="h-10 w-10 rounded-full"
+                          onClick={handleStop}
+                          title="Stop"
+                        >
+                          <span className="material-icons">stop</span>
+                        </Button>
+                        <Button
+                          variant="default"
+                          size="icon"
+                          className="h-12 w-12 rounded-full"
+                          onClick={handlePause}
+                        >
+                          <span className="material-icons text-xl">
+                            {status.is_paused ? 'play_arrow' : 'pause'}
+                          </span>
+                        </Button>
+                        {status.playlist && (
+                          <Button
+                            variant="secondary"
+                            size="icon"
+                            className="h-10 w-10 rounded-full"
+                            onClick={handleSkip}
+                            title="Skip to next"
+                          >
+                            <span className="material-icons">skip_next</span>
+                          </Button>
+                        )}
+                      </div>
+
+                      {/* Speed Control */}
+                      <div className="flex items-center justify-center gap-2">
+                        <span className="text-sm text-muted-foreground">Speed:</span>
+                        <Input
+                          type="number"
+                          placeholder={String(status.speed)}
+                          value={speedInput}
+                          onChange={(e) => setSpeedInput(e.target.value)}
+                          onKeyDown={(e) => e.key === 'Enter' && handleSpeedSubmit()}
+                          className="h-7 w-20 text-sm px-2"
+                        />
+                        <span className="text-sm text-muted-foreground">mm/s</span>
+                      </div>
+                    </div>
+
+                    {/* Next Pattern Preview - hidden on mobile */}
+                    {status.playlist?.next_file && (
+                      <div
+                        className="hidden md:flex shrink-0 flex-col items-center gap-1 mr-16 cursor-pointer hover:opacity-80 transition-opacity"
+                        onClick={() => setShowQueue(true)}
+                        title="View queue"
+                      >
+                        <p className="text-xs text-muted-foreground font-medium flex items-center gap-1">
+                          Up Next
+                          <span className="material-icons-outlined text-xs">queue_music</span>
+                        </p>
+                        <div className="w-24 h-24 rounded-full overflow-hidden bg-muted border-2">
+                          {nextPreviewUrl ? (
+                            <img
+                              src={nextPreviewUrl}
+                              alt="Next pattern"
+                              className="w-full h-full object-cover pattern-preview"
+                            />
+                          ) : (
+                            <div className="w-full h-full flex items-center justify-center">
+                              <span className="material-icons-outlined text-muted-foreground text-2xl">image</span>
+                            </div>
+                          )}
+                        </div>
+                        <p className="text-xs text-muted-foreground text-center max-w-24 truncate">
+                          {formatPatternName(status.playlist.next_file)}
+                        </p>
+                      </div>
+                    )}
+                  </>
+                ) : (
+                  <div className="flex-1 flex items-center">
+                    <p className="text-lg text-muted-foreground">Not playing</p>
+                  </div>
+                )}
+              </div>
+
+              {/* Progress Bar - Mobile only (full width at bottom) */}
+              {isPlaying && status && (
+                isWaiting ? (
+                  <div className="flex md:hidden items-center gap-3 px-6 pb-16">
+                    <span className="material-icons-outlined text-muted-foreground text-lg">hourglass_top</span>
+                    <Progress value={waitProgress} className="h-2 flex-1" />
+                    <span className="text-sm text-muted-foreground font-mono">{formatTime(waitTimeRemaining)}</span>
+                  </div>
+                ) : (
+                  <div className="flex md:hidden items-center gap-3 px-6 pb-16">
+                    <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
+                    <Progress value={progressPercent} className="h-2 flex-1" />
+                    <span className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1.5 shrink-0 ${usingHistoricalEta ? 'w-24' : 'w-14'}`}>
+                      {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
+                      -{formatTime(remainingTime)}
+                    </span>
+                  </div>
+                )
+              )}
+            </div>
+          )}
+
+          {/* Expanded view - Real-time canvas preview */}
+          {isExpanded && isPlaying && (
+            <div className="flex-1 flex flex-col md:items-center md:justify-center px-4 py-4 md:py-8 pt-safe overflow-hidden">
+              <div className="w-full max-w-5xl mx-auto flex flex-col md:flex-row md:items-center gap-3 md:gap-6">
+                {/* Canvas - full width on mobile (click to collapse) */}
+                <div
+                  className="flex-1 flex items-center justify-center cursor-pointer"
+                  onClick={() => setIsExpanded(false)}
+                  title="Click to collapse"
+                >
+                  <canvas
+                    ref={canvasRef}
+                    width={600}
+                    height={600}
+                    className="rounded-full border-2 hover:border-primary transition-colors w-[40vh] h-[40vh] max-w-[300px] max-h-[300px] md:w-[42vh] md:h-[42vh] md:max-w-[500px] md:max-h-[500px]"
+                  />
+                </div>
+
+                {/* Controls */}
+                <div className="md:w-80 shrink-0 flex flex-col justify-start md:justify-center gap-2 md:gap-4">
+                {/* Pattern Info */}
+                <div className="flex items-center justify-center gap-3">
+                  {/* Current pattern preview */}
+                  <div className="w-10 h-10 md:w-12 md:h-12 rounded-full overflow-hidden bg-muted border shrink-0">
+                    {previewUrl ? (
+                      <img
+                        src={previewUrl}
+                        alt={patternName}
+                        className="w-full h-full object-cover pattern-preview"
+                      />
+                    ) : (
+                      <div className="w-full h-full flex items-center justify-center">
+                        <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
+                      </div>
+                    )}
+                  </div>
+                  <div className="text-left min-w-0">
+                    {isWaiting ? (
+                      <>
+                        <h2 className="text-lg md:text-xl font-semibold text-muted-foreground">
+                          Waiting for next pattern...
+                        </h2>
+                        {status?.playlist?.next_file && (
+                          <p className="text-sm text-muted-foreground">
+                            Up next: {formatPatternName(status.playlist.next_file)}
+                          </p>
+                        )}
+                      </>
+                    ) : (
+                      <>
+                        <h2 className="text-lg md:text-xl font-semibold truncate">{patternName}</h2>
+                        {status?.playlist && (
+                          <p className="text-sm text-muted-foreground">
+                            Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
+                          </p>
+                        )}
+                      </>
+                    )}
+                  </div>
+                </div>
+
+                {/* Progress */}
+                {isWaiting ? (
+                  <div className="space-y-1 md:space-y-2">
+                    <Progress value={waitProgress} className="h-1.5 md:h-2" />
+                    <div className="flex justify-center items-center gap-2 text-xs md:text-sm text-muted-foreground font-mono">
+                      <span className="material-icons-outlined text-base">hourglass_top</span>
+                      <span>{formatTime(waitTimeRemaining)} remaining</span>
+                    </div>
+                  </div>
+                ) : (
+                  <div className="space-y-1 md:space-y-2">
+                    <Progress value={progressPercent} className="h-1.5 md:h-2" />
+                    <div className="flex justify-between text-xs md:text-sm text-muted-foreground font-mono">
+                      <span className="w-16">{formatTime(elapsedTime)}</span>
+                      <span>{progressPercent.toFixed(0)}%</span>
+                      <span className="w-16 flex items-center justify-end gap-1">
+                        {usingHistoricalEta && <span className="material-icons-outlined text-xs">history</span>}
+                        -{formatTime(remainingTime)}
+                      </span>
+                    </div>
+                  </div>
+                )}
+
+                {/* Playback Controls */}
+                <div className="flex items-center justify-center gap-2 md:gap-3">
+                  <Button
+                    variant="secondary"
+                    size="icon"
+                    className="h-10 w-10 md:h-12 md:w-12 rounded-full"
+                    onClick={handleStop}
+                    title="Stop"
+                  >
+                    <span className="material-icons text-lg md:text-2xl">stop</span>
+                  </Button>
+                  <Button
+                    variant="default"
+                    size="icon"
+                    className="h-12 w-12 md:h-14 md:w-14 rounded-full"
+                    onClick={handlePause}
+                  >
+                    <span className="material-icons text-xl md:text-2xl">
+                      {status?.is_paused ? 'play_arrow' : 'pause'}
+                    </span>
+                  </Button>
+                  {status?.playlist && (
+                    <Button
+                      variant="secondary"
+                      size="icon"
+                      className="h-10 w-10 md:h-12 md:w-12 rounded-full"
+                      onClick={handleSkip}
+                      title="Skip to next"
+                    >
+                      <span className="material-icons text-lg md:text-2xl">skip_next</span>
+                    </Button>
+                  )}
+                </div>
+
+                {/* Speed Control */}
+                <div className="flex items-center justify-center gap-2">
+                  <span className="text-sm text-muted-foreground">Speed:</span>
+                  <Input
+                    type="number"
+                    placeholder={String(status?.speed || 1000)}
+                    value={speedInput}
+                    onChange={(e) => setSpeedInput(e.target.value)}
+                    onKeyDown={(e) => e.key === 'Enter' && handleSpeedSubmit()}
+                    className="h-8 w-24 text-sm px-2"
+                  />
+                  <span className="text-sm text-muted-foreground">mm/s</span>
+                </div>
+
+                {/* Next Pattern */}
+                {status?.playlist?.next_file && (
+                  <div
+                    className="flex items-center gap-3 bg-muted/50 rounded-lg p-2 md:p-3 cursor-pointer hover:bg-muted/70 transition-colors"
+                    onClick={() => setShowQueue(true)}
+                    title="View queue"
+                  >
+                    <div className="w-10 h-10 md:w-12 md:h-12 rounded-full overflow-hidden bg-muted border shrink-0">
+                      {nextPreviewUrl ? (
+                        <img
+                          src={nextPreviewUrl}
+                          alt="Next pattern"
+                          className="w-full h-full object-cover pattern-preview"
+                        />
+                      ) : (
+                        <div className="w-full h-full flex items-center justify-center">
+                          <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
+                        </div>
+                      )}
+                    </div>
+                    <div className="min-w-0 flex-1">
+                      <p className="text-xs text-muted-foreground">Up Next</p>
+                      <p className="text-sm font-medium truncate">
+                        {formatPatternName(status.playlist.next_file)}
+                      </p>
+                    </div>
+                    <span className="material-icons-outlined text-muted-foreground text-lg">queue_music</span>
+                  </div>
+                )}
+              </div>
+              </div>
+            </div>
+          )}
+        </div>
+        </div>{/* Close max-width container */}
+      </div>
+
+      {/* Queue Dialog */}
+      <Dialog open={showQueue} onOpenChange={setShowQueue}>
+        <DialogContent
+          ref={queueDialogRef}
+          className="max-w-md max-h-[80vh] flex flex-col"
+          onTouchStart={handleQueueTouchStart}
+          onTouchEnd={handleQueueTouchEnd}
+        >
+          {/* Swipe indicator for mobile */}
+          <div className="md:hidden flex justify-center -mt-2 mb-2">
+            <div className="w-10 h-1 bg-muted-foreground/30 rounded-full" />
+          </div>
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              <span className="material-icons-outlined">queue_music</span>
+              Queue
+              {status?.playlist?.name && (
+                <span className="text-sm font-normal text-muted-foreground">
+                  — {status.playlist.name}
+                </span>
+              )}
+            </DialogTitle>
+            <DialogDescription className="sr-only">
+              List of patterns in the current playlist queue. Swipe down to dismiss.
+            </DialogDescription>
+          </DialogHeader>
+
+          <div className="flex-1 overflow-y-auto -mx-6 px-6 py-2" data-scrollable>
+            {status?.playlist && displayQueue.length > 0 ? (
+              (() => {
+                // Only show upcoming patterns (after current)
+                const currentIndex = status.playlist!.current_index
+                const upcomingFiles = displayQueue
+                  .map((file, index) => ({ file, index }))
+                  .filter(({ index }) => index > currentIndex)
+
+                if (upcomingFiles.length === 0) {
+                  return <p className="text-center text-muted-foreground py-8">No upcoming patterns</p>
+                }
+
+                const firstUpcomingIndex = upcomingFiles[0].index
+                const lastUpcomingIndex = upcomingFiles[upcomingFiles.length - 1].index
+
+                return (
+                  <DndContext
+                    sensors={sensors}
+                    collisionDetection={closestCenter}
+                    onDragEnd={handleDragEnd}
+                  >
+                    <SortableContext
+                      items={upcomingFiles.map(({ index }) => `queue-item-${index}`)}
+                      strategy={verticalListSortingStrategy}
+                    >
+                      <div className="space-y-1">
+                        {upcomingFiles.map(({ file, index }) => (
+                          <SortableQueueItem
+                            key={`queue-item-${index}`}
+                            id={`queue-item-${index}`}
+                            file={file}
+                            index={index}
+                            previewUrl={queuePreviews[file] || null}
+                            isFirst={index === firstUpcomingIndex}
+                            isLast={index === lastUpcomingIndex}
+                            onMoveToTop={() => moveToPosition(index, firstUpcomingIndex)}
+                            requestPreview={requestQueuePreview}
+                            onMoveToBottom={() => moveToPosition(index, lastUpcomingIndex)}
+                          />
+                        ))}
+                      </div>
+                    </SortableContext>
+                  </DndContext>
+                )
+              })()
+            ) : (
+              <p className="text-center text-muted-foreground py-8">No queue</p>
+            )}
+          </div>
+          {status?.playlist && (
+            <div className="pt-3 border-t text-xs text-muted-foreground flex justify-between">
+              <span>Mode: {status.playlist.mode}</span>
+              <span>
+                {status.playlist.current_index + 1} of {status.playlist.total_files}
+              </span>
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
+    </>
+  )
+}

+ 131 - 0
frontend/src/components/ShinyText.tsx

@@ -0,0 +1,131 @@
+import React, { useState, useCallback, useEffect, useRef } from 'react';
+import { motion, useMotionValue, useAnimationFrame, useTransform } from 'motion/react';
+
+interface ShinyTextProps {
+  text: string;
+  disabled?: boolean;
+  speed?: number;
+  className?: string;
+  color?: string;
+  shineColor?: string;
+  spread?: number;
+  yoyo?: boolean;
+  pauseOnHover?: boolean;
+  direction?: 'left' | 'right';
+  delay?: number;
+}
+
+const ShinyText: React.FC<ShinyTextProps> = ({
+  text,
+  disabled = false,
+  speed = 2,
+  className = '',
+  color = '#b5b5b5',
+  shineColor = '#ffffff',
+  spread = 120,
+  yoyo = false,
+  pauseOnHover = false,
+  direction = 'left',
+  delay = 0
+}) => {
+  const [isPaused, setIsPaused] = useState(false);
+  const progress = useMotionValue(0);
+  const elapsedRef = useRef(0);
+  const lastTimeRef = useRef<number | null>(null);
+  const directionRef = useRef(direction === 'left' ? 1 : -1);
+
+  const animationDuration = speed * 1000;
+  const delayDuration = delay * 1000;
+
+  useAnimationFrame(time => {
+    if (disabled || isPaused) {
+      lastTimeRef.current = null;
+      return;
+    }
+
+    if (lastTimeRef.current === null) {
+      lastTimeRef.current = time;
+      return;
+    }
+
+    const deltaTime = time - lastTimeRef.current;
+    lastTimeRef.current = time;
+
+    elapsedRef.current += deltaTime;
+
+    // Animation goes from 0 to 100
+    if (yoyo) {
+      const cycleDuration = animationDuration + delayDuration;
+      const fullCycle = cycleDuration * 2;
+      const cycleTime = elapsedRef.current % fullCycle;
+
+      if (cycleTime < animationDuration) {
+        // Forward animation: 0 -> 100
+        const p = (cycleTime / animationDuration) * 100;
+        progress.set(directionRef.current === 1 ? p : 100 - p);
+      } else if (cycleTime < cycleDuration) {
+        // Delay at end
+        progress.set(directionRef.current === 1 ? 100 : 0);
+      } else if (cycleTime < cycleDuration + animationDuration) {
+        // Reverse animation: 100 -> 0
+        const reverseTime = cycleTime - cycleDuration;
+        const p = 100 - (reverseTime / animationDuration) * 100;
+        progress.set(directionRef.current === 1 ? p : 100 - p);
+      } else {
+        // Delay at start
+        progress.set(directionRef.current === 1 ? 0 : 100);
+      }
+    } else {
+      const cycleDuration = animationDuration + delayDuration;
+      const cycleTime = elapsedRef.current % cycleDuration;
+
+      if (cycleTime < animationDuration) {
+        // Animation phase: 0 -> 100
+        const p = (cycleTime / animationDuration) * 100;
+        progress.set(directionRef.current === 1 ? p : 100 - p);
+      } else {
+        // Delay phase - hold at end (shine off-screen)
+        progress.set(directionRef.current === 1 ? 100 : 0);
+      }
+    }
+  });
+
+  useEffect(() => {
+    directionRef.current = direction === 'left' ? 1 : -1;
+    elapsedRef.current = 0;
+    progress.set(0);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [direction]);
+
+  // Transform: p=0 -> 150% (shine off right), p=100 -> -50% (shine off left)
+  const backgroundPosition = useTransform(progress, p => `${150 - p * 2}% center`);
+
+  const handleMouseEnter = useCallback(() => {
+    if (pauseOnHover) setIsPaused(true);
+  }, [pauseOnHover]);
+
+  const handleMouseLeave = useCallback(() => {
+    if (pauseOnHover) setIsPaused(false);
+  }, [pauseOnHover]);
+
+  const gradientStyle: React.CSSProperties = {
+    backgroundImage: `linear-gradient(${spread}deg, ${color} 0%, ${color} 35%, ${shineColor} 50%, ${color} 65%, ${color} 100%)`,
+    backgroundSize: '200% auto',
+    WebkitBackgroundClip: 'text',
+    backgroundClip: 'text',
+    WebkitTextFillColor: 'transparent'
+  };
+
+  return (
+    <motion.span
+      className={`inline-block ${className}`}
+      style={{ ...gradientStyle, backgroundPosition }}
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+    >
+      {text}
+    </motion.span>
+  );
+};
+
+export default ShinyText;

+ 310 - 0
frontend/src/components/TableSelector.tsx

@@ -0,0 +1,310 @@
+/**
+ * TableSelector - Header component for switching between sand tables
+ *
+ * Displays the current table and provides a dropdown to switch between
+ * discovered tables or add new ones manually.
+ */
+
+import { useState } from 'react'
+import { useTable, type Table } from '@/contexts/TableContext'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/popover'
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+  DialogFooter,
+} from '@/components/ui/dialog'
+import { toast } from 'sonner'
+import {
+  Layers,
+  Plus,
+  Check,
+  Pencil,
+  Trash2,
+} from 'lucide-react'
+
+interface TableSelectorProps {
+  children?: React.ReactNode
+}
+
+export function TableSelector({ children }: TableSelectorProps) {
+  const {
+    tables,
+    activeTable,
+    setActiveTable,
+    addTable,
+    removeTable,
+    updateTableName,
+  } = useTable()
+
+  const [isOpen, setIsOpen] = useState(false)
+  const [showAddDialog, setShowAddDialog] = useState(false)
+  const [showRenameDialog, setShowRenameDialog] = useState(false)
+  const [newTableUrl, setNewTableUrl] = useState('')
+  const [newTableName, setNewTableName] = useState('')
+  const [renameTable, setRenameTable] = useState<Table | null>(null)
+  const [renameValue, setRenameValue] = useState('')
+  const [isAdding, setIsAdding] = useState(false)
+
+  const handleSelectTable = (table: Table) => {
+    if (table.id !== activeTable?.id) {
+      setActiveTable(table)
+      toast.success(`Switched to ${table.name}`)
+    }
+    setIsOpen(false)
+  }
+
+  const handleAddTable = async () => {
+    if (!newTableUrl.trim()) {
+      toast.error('Please enter a URL')
+      return
+    }
+
+    setIsAdding(true)
+    try {
+      // Ensure URL has protocol
+      let url = newTableUrl.trim()
+      if (!url.startsWith('http://') && !url.startsWith('https://')) {
+        url = `http://${url}`
+      }
+
+      const table = await addTable(url, newTableName.trim() || undefined)
+      if (table) {
+        toast.success(`Added ${table.name}`)
+        setShowAddDialog(false)
+        setNewTableUrl('')
+        setNewTableName('')
+      } else {
+        toast.error('Failed to add table. Check the URL and try again.')
+      }
+    } finally {
+      setIsAdding(false)
+    }
+  }
+
+  const handleRename = async () => {
+    if (!renameTable || !renameValue.trim()) return
+
+    await updateTableName(renameTable.id, renameValue.trim())
+    toast.success('Table renamed')
+    setShowRenameDialog(false)
+    setRenameTable(null)
+    setRenameValue('')
+  }
+
+  const handleRemove = (table: Table) => {
+    if (table.isCurrent) {
+      toast.error("Can't remove the current table")
+      return
+    }
+    removeTable(table.id)
+    toast.success(`Removed ${table.name}`)
+  }
+
+  const openRenameDialog = (table: Table) => {
+    setRenameTable(table)
+    setRenameValue(table.name)
+    setShowRenameDialog(true)
+  }
+
+  // Always show if there are tables or discovering
+  // This allows users to manually add tables even with just one
+
+  return (
+    <>
+      <Popover open={isOpen} onOpenChange={setIsOpen}>
+        <PopoverTrigger asChild>
+          {children || (
+            <Button
+              variant="ghost"
+              size="sm"
+              className="gap-2 h-9 px-2"
+            >
+              <Layers className="h-4 w-4" />
+              <span className="hidden sm:inline max-w-[120px] truncate">
+                {activeTable?.appName || activeTable?.name || 'Select Table'}
+              </span>
+            </Button>
+          )}
+        </PopoverTrigger>
+        <PopoverContent className="w-72 p-2" align="start" sideOffset={12} alignOffset={-56}>
+          <div className="space-y-2">
+            {/* Header */}
+            <div className="px-2 py-1">
+              <span className="text-sm font-medium">Sand Tables</span>
+            </div>
+
+            {/* Table list */}
+            <div className="space-y-1">
+              {tables.map(table => (
+                <div
+                  key={table.id}
+                  className={`flex items-center gap-2 px-2 py-2 rounded-md cursor-pointer hover:bg-accent group ${
+                    activeTable?.id === table.id ? 'bg-accent' : ''
+                  }`}
+                  onClick={() => handleSelectTable(table)}
+                >
+                  {/* Table icon with status indicator */}
+                  <div className="relative flex-shrink-0">
+                    <img
+                      src={
+                        table.customLogo
+                          ? `${table.isCurrent ? '' : table.url}/static/custom/${table.customLogo}`
+                          : `${table.isCurrent ? '' : table.url}/static/android-chrome-192x192.png`
+                      }
+                      alt={table.name}
+                      className="w-8 h-8 rounded-full object-cover"
+                      onError={(e) => {
+                        // Fallback to default icon if image fails to load
+                        (e.target as HTMLImageElement).src = '/static/android-chrome-192x192.png'
+                      }}
+                    />
+                    {/* Online status dot */}
+                    <span
+                      className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-popover ${
+                        table.isOnline ? 'bg-green-500' : 'bg-red-500'
+                      }`}
+                    />
+                  </div>
+
+                  {/* Name and info */}
+                  <div className="flex-1 min-w-0">
+                    <div className="flex items-center gap-2">
+                      <span className="text-sm truncate">{table.name}</span>
+                      {table.isCurrent && (
+                        <Badge variant="secondary" className="text-[10px] px-1 py-0">
+                          This
+                        </Badge>
+                      )}
+                    </div>
+                    <span className="text-xs text-muted-foreground truncate block">
+                      {table.host || new URL(table.url).hostname}
+                    </span>
+                  </div>
+
+                  {/* Actions - always visible on mobile, hover on desktop */}
+                  <div className="flex md:opacity-0 md:group-hover:opacity-100 items-center gap-1 transition-opacity">
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      className="h-7 w-7 p-0"
+                      onClick={e => {
+                        e.stopPropagation()
+                        openRenameDialog(table)
+                      }}
+                      title="Rename"
+                    >
+                      <Pencil className="h-3.5 w-3.5" />
+                    </Button>
+                    {!table.isCurrent && (
+                      <Button
+                        variant="ghost"
+                        size="sm"
+                        className="h-7 w-7 p-0 text-destructive hover:text-destructive"
+                        onClick={e => {
+                          e.stopPropagation()
+                          handleRemove(table)
+                        }}
+                        title="Remove"
+                      >
+                        <Trash2 className="h-3.5 w-3.5" />
+                      </Button>
+                    )}
+                  </div>
+
+                  {/* Selected indicator - far right */}
+                  {activeTable?.id === table.id && (
+                    <Check className="h-4 w-4 text-primary flex-shrink-0" />
+                  )}
+                </div>
+              ))}
+            </div>
+
+            {/* Add table button */}
+            <Button
+              variant="secondary"
+              size="sm"
+              className="w-full gap-2"
+              onClick={() => setShowAddDialog(true)}
+            >
+              <Plus className="h-3.5 w-3.5" />
+              Add Table Manually
+            </Button>
+          </div>
+        </PopoverContent>
+      </Popover>
+
+      {/* Add Table Dialog */}
+      <Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>Add Table Manually</DialogTitle>
+          </DialogHeader>
+          <div className="space-y-4 py-4">
+            <div className="space-y-2">
+              <label className="text-sm font-medium">Table URL</label>
+              <Input
+                placeholder="192.168.1.100:8080 or http://..."
+                value={newTableUrl}
+                onChange={e => setNewTableUrl(e.target.value)}
+                onKeyDown={e => e.key === 'Enter' && handleAddTable()}
+              />
+              <p className="text-xs text-muted-foreground">
+                Enter the IP address and port of the table's backend
+              </p>
+            </div>
+            <div className="space-y-2">
+              <label className="text-sm font-medium">Name (optional)</label>
+              <Input
+                placeholder="Living Room Table"
+                value={newTableName}
+                onChange={e => setNewTableName(e.target.value)}
+                onKeyDown={e => e.key === 'Enter' && handleAddTable()}
+              />
+            </div>
+          </div>
+          <DialogFooter className="gap-2 sm:gap-0">
+            <Button variant="secondary" onClick={() => setShowAddDialog(false)}>
+              Cancel
+            </Button>
+            <Button onClick={handleAddTable} disabled={isAdding}>
+              {isAdding ? 'Adding...' : 'Add Table'}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      {/* Rename Dialog */}
+      <Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>Rename Table</DialogTitle>
+          </DialogHeader>
+          <div className="py-4">
+            <Input
+              placeholder="Table name"
+              value={renameValue}
+              onChange={e => setRenameValue(e.target.value)}
+              onKeyDown={e => e.key === 'Enter' && handleRename()}
+              autoFocus
+            />
+          </div>
+          <DialogFooter className="gap-2 sm:gap-0">
+            <Button variant="secondary" onClick={() => setShowRenameDialog(false)}>
+              Cancel
+            </Button>
+            <Button onClick={handleRename}>Save</Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </>
+  )
+}

+ 1935 - 0
frontend/src/components/layout/Layout.tsx

@@ -0,0 +1,1935 @@
+import { Outlet, Link, useLocation } from 'react-router-dom'
+import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
+import { toast } from 'sonner'
+import { NowPlayingBar } from '@/components/NowPlayingBar'
+import { Button } from '@/components/ui/button'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Separator } from '@/components/ui/separator'
+import { cacheAllPreviews } from '@/lib/previewCache'
+import { TableSelector } from '@/components/TableSelector'
+import { useTable } from '@/contexts/TableContext'
+import { apiClient } from '@/lib/apiClient'
+import ShinyText from '@/components/ShinyText'
+
+const navItems = [
+  { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' },
+  { path: '/playlists', label: 'Playlists', icon: 'playlist_play', title: 'Playlists' },
+  { path: '/table-control', label: 'Control', icon: 'tune', title: 'Table Control' },
+  { path: '/led', label: 'LED', icon: 'lightbulb', title: 'LED Control' },
+  { path: '/settings', label: 'Settings', icon: 'settings', title: 'Settings' },
+]
+
+const DEFAULT_APP_NAME = 'Dune Weaver'
+
+export function Layout() {
+  const location = useLocation()
+
+  // Scroll to top on route change
+  useEffect(() => {
+    window.scrollTo(0, 0)
+  }, [location.pathname])
+
+  // Multi-table context - must be called before any hooks that depend on activeTable
+  const { activeTable, tables } = useTable()
+
+  // Use table name as app name when multiple tables exist
+  const hasMultipleTables = tables.length > 1
+
+  const [isDark, setIsDark] = useState(() => {
+    if (typeof window !== 'undefined') {
+      const saved = localStorage.getItem('theme')
+      if (saved) return saved === 'dark'
+      return window.matchMedia('(prefers-color-scheme: dark)').matches
+    }
+    return false
+  })
+
+  // App customization
+  const [appName, setAppName] = useState(DEFAULT_APP_NAME)
+  const [customLogo, setCustomLogo] = useState<string | null>(null)
+
+  // Display name: when multiple tables exist, use the active table's name; otherwise use app settings
+  // Get the table from the tables array (most up-to-date source) to ensure we have current data
+  const activeTableData = tables.find(t => t.id === activeTable?.id)
+  const tableName = activeTableData?.name || activeTable?.name
+  const displayName = hasMultipleTables && tableName ? tableName : appName
+
+  // Connection status
+  const [isConnected, setIsConnected] = useState(false)
+  const [isBackendConnected, setIsBackendConnected] = useState(false)
+  const [isHoming, setIsHoming] = useState(false)
+  const [homingDismissed, setHomingDismissed] = useState(false)
+  const [homingJustCompleted, setHomingJustCompleted] = useState(false)
+  const [homingCountdown, setHomingCountdown] = useState(0)
+  const [keepHomingLogsOpen, setKeepHomingLogsOpen] = useState(false)
+  const wasHomingRef = useRef(false)
+  const [connectionAttempts, setConnectionAttempts] = useState(0)
+  const wsRef = useRef<WebSocket | null>(null)
+
+  // Sensor homing failure state
+  const [sensorHomingFailed, setSensorHomingFailed] = useState(false)
+  const [isRecoveringHoming, setIsRecoveringHoming] = useState(false)
+
+  // Update availability
+  const [updateAvailable, setUpdateAvailable] = useState(false)
+
+  // Fetch app settings
+  const fetchAppSettings = () => {
+    apiClient.get<{ app?: { name?: string; custom_logo?: string } }>('/api/settings')
+      .then((settings) => {
+        if (settings.app?.name) {
+          setAppName(settings.app.name)
+        } else {
+          setAppName(DEFAULT_APP_NAME)
+        }
+        setCustomLogo(settings.app?.custom_logo || null)
+      })
+      .catch(() => {})
+  }
+
+  useEffect(() => {
+    fetchAppSettings()
+
+    // Listen for branding updates from Settings page
+    const handleBrandingUpdate = () => {
+      fetchAppSettings()
+    }
+    window.addEventListener('branding-updated', handleBrandingUpdate)
+
+    return () => {
+      window.removeEventListener('branding-updated', handleBrandingUpdate)
+    }
+    // Refetch when active table changes
+  }, [activeTable?.id])
+
+  // Check for software updates on mount
+  useEffect(() => {
+    apiClient.get<{ update_available?: boolean }>('/api/version')
+      .then((data) => {
+        if (data.update_available) {
+          setUpdateAvailable(true)
+        }
+      })
+      .catch(() => {})
+  }, [activeTable?.id])
+
+  // Homing completion countdown timer
+  useEffect(() => {
+    if (!homingJustCompleted || keepHomingLogsOpen) return
+
+    if (homingCountdown <= 0) {
+      // Countdown finished, dismiss the overlay
+      setHomingJustCompleted(false)
+      setKeepHomingLogsOpen(false)
+      return
+    }
+
+    const timer = setTimeout(() => {
+      setHomingCountdown((prev) => prev - 1)
+    }, 1000)
+
+    return () => clearTimeout(timer)
+  }, [homingJustCompleted, homingCountdown, keepHomingLogsOpen])
+
+  // Mobile menu state
+  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
+
+  // Logs drawer state
+  const [isLogsOpen, setIsLogsOpen] = useState(false)
+  const [logsDrawerHeight, setLogsDrawerHeight] = useState(256) // Default 256px (h-64)
+  const [isResizing, setIsResizing] = useState(false)
+  const isResizingRef = useRef(false)
+  const startYRef = useRef(0)
+  const startHeightRef = useRef(0)
+
+  const [logSearchQuery, setLogSearchQuery] = useState('')
+
+  // Handle drawer resize
+  const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
+    e.preventDefault()
+    isResizingRef.current = true
+    setIsResizing(true)
+    startYRef.current = 'touches' in e ? e.touches[0].clientY : e.clientY
+    startHeightRef.current = logsDrawerHeight
+    document.body.style.cursor = 'ns-resize'
+    document.body.style.userSelect = 'none'
+  }
+
+  useEffect(() => {
+    const handleResizeMove = (e: MouseEvent | TouchEvent) => {
+      if (!isResizingRef.current) return
+      const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
+      const delta = startYRef.current - clientY
+      const newHeight = Math.min(Math.max(startHeightRef.current + delta, 150), window.innerHeight - 150)
+      setLogsDrawerHeight(newHeight)
+    }
+
+    const handleResizeEnd = () => {
+      if (isResizingRef.current) {
+        isResizingRef.current = false
+        setIsResizing(false)
+        document.body.style.cursor = ''
+        document.body.style.userSelect = ''
+      }
+    }
+
+    window.addEventListener('mousemove', handleResizeMove)
+    window.addEventListener('mouseup', handleResizeEnd)
+    window.addEventListener('touchmove', handleResizeMove)
+    window.addEventListener('touchend', handleResizeEnd)
+
+    return () => {
+      window.removeEventListener('mousemove', handleResizeMove)
+      window.removeEventListener('mouseup', handleResizeEnd)
+      window.removeEventListener('touchmove', handleResizeMove)
+      window.removeEventListener('touchend', handleResizeEnd)
+    }
+  }, [])
+
+  // Now Playing bar state
+  const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
+  const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false)
+  const [currentPlayingFile, setCurrentPlayingFile] = useState<string | null>(null) // Track current file for header button
+  const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
+
+  // Draggable Now Playing button state
+  type SnapPosition = 'left' | 'center' | 'right'
+  const [nowPlayingButtonPos, setNowPlayingButtonPos] = useState<SnapPosition>(() => {
+    if (typeof window !== 'undefined') {
+      const saved = localStorage.getItem('nowPlayingButtonPos')
+      if (saved === 'left' || saved === 'center' || saved === 'right') return saved
+    }
+    return 'center'
+  })
+  const [isDraggingButton, setIsDraggingButton] = useState(false)
+  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
+  const buttonRef = useRef<HTMLButtonElement>(null)
+  const dragStartRef = useRef<{ x: number; y: number; buttonX: number } | null>(null)
+  const wasDraggingRef = useRef(false) // Track if a meaningful drag occurred
+
+  // Derive isCurrentlyPlaying from currentPlayingFile
+  const isCurrentlyPlaying = Boolean(currentPlayingFile)
+
+  // Listen for playback-started event (dispatched when user starts a pattern)
+  useEffect(() => {
+    const handlePlaybackStarted = () => {
+      setIsNowPlayingOpen(true)
+      setOpenNowPlayingExpanded(true)
+      setIsLogsOpen(false)
+      // Reset expanded flag after animation
+      setTimeout(() => setOpenNowPlayingExpanded(false), 500)
+    }
+    window.addEventListener('playback-started', handlePlaybackStarted)
+    return () => window.removeEventListener('playback-started', handlePlaybackStarted)
+  }, [])
+  const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
+  const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
+  const [logsTotal, setLogsTotal] = useState(0)
+  const [logsHasMore, setLogsHasMore] = useState(false)
+  const [isLoadingMoreLogs, setIsLoadingMoreLogs] = useState(false)
+  const logsWsRef = useRef<WebSocket | null>(null)
+  const logsContainerRef = useRef<HTMLDivElement>(null)
+  const logsLoadedCountRef = useRef(0) // Track how many logs we've loaded (for offset)
+
+  // Check device connection status via WebSocket
+  // This effect runs once on mount and manages its own reconnection logic
+  useEffect(() => {
+    let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
+    let isMounted = true
+
+    const connectWebSocket = () => {
+      if (!isMounted) return
+
+      // Only close existing connection if it's open (not still connecting)
+      // This prevents "WebSocket closed before connection established" errors
+      if (wsRef.current) {
+        if (wsRef.current.readyState === WebSocket.OPEN) {
+          wsRef.current.close()
+          wsRef.current = null
+        } else if (wsRef.current.readyState === WebSocket.CONNECTING) {
+          // Already connecting, don't interrupt
+          return
+        }
+      }
+
+      const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
+      // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
+      wsRef.current = ws
+
+      ws.onopen = () => {
+        if (!isMounted) {
+          // Component unmounted while connecting - close the WebSocket now
+          ws.close()
+          return
+        }
+        setIsBackendConnected(true)
+        setConnectionAttempts(0)
+        // Dispatch event so pages can refetch data
+        window.dispatchEvent(new CustomEvent('backend-connected'))
+      }
+
+      ws.onmessage = (event) => {
+        if (!isMounted) return
+        try {
+          const data = JSON.parse(event.data)
+          // Handle status updates
+          if (data.type === 'status_update' && data.data) {
+            // Update device connection status from the status message
+            if (data.data.connection_status !== undefined) {
+              setIsConnected(data.data.connection_status)
+            }
+            // Update homing status and detect completion
+            if (data.data.is_homing !== undefined) {
+              const newIsHoming = data.data.is_homing
+              // Detect transition from not homing to homing - reset dismissal
+              if (!wasHomingRef.current && newIsHoming) {
+                setHomingDismissed(false)
+              }
+              // Detect transition from homing to not homing
+              if (wasHomingRef.current && !newIsHoming) {
+                // Homing just completed - show completion state with countdown
+                // But not if sensor homing failed (that shows a different dialog)
+                if (!data.data.sensor_homing_failed) {
+                  setHomingJustCompleted(true)
+                  setHomingCountdown(5)
+                  setHomingDismissed(false)
+                }
+              }
+              wasHomingRef.current = newIsHoming
+              setIsHoming(newIsHoming)
+            }
+            // Update sensor homing failure status
+            if (data.data.sensor_homing_failed !== undefined) {
+              setSensorHomingFailed(data.data.sensor_homing_failed)
+            }
+            // Auto-open/close Now Playing bar based on playback state
+            // Track current file - this is the most reliable indicator of playback
+            const currentFile = data.data.current_file || null
+            setCurrentPlayingFile(currentFile)
+
+            const isPlaying = Boolean(currentFile) || Boolean(data.data.is_running) || Boolean(data.data.is_paused)
+            // Skip auto-open on first message (page refresh) - only react to state changes
+            if (wasPlayingRef.current !== null) {
+              if (isPlaying && !wasPlayingRef.current) {
+                // Playback just started - open the Now Playing bar in expanded mode
+                setIsNowPlayingOpen(true)
+                setOpenNowPlayingExpanded(true)
+                // Close the logs drawer if open
+                setIsLogsOpen(false)
+                // Reset the expanded flag after a short delay
+                setTimeout(() => setOpenNowPlayingExpanded(false), 500)
+                // Dispatch event so pages can close their sidebars/panels
+                window.dispatchEvent(new CustomEvent('playback-started'))
+              } else if (!isPlaying && wasPlayingRef.current) {
+                // Playback just stopped - close the Now Playing bar
+                setIsNowPlayingOpen(false)
+              }
+            }
+            wasPlayingRef.current = isPlaying
+          }
+        } catch {
+          // Ignore parse errors
+        }
+      }
+
+      ws.onclose = () => {
+        if (!isMounted) return
+        wsRef.current = null
+        setIsBackendConnected(false)
+        setConnectionAttempts((prev) => prev + 1)
+        // Reconnect after 3 seconds (don't change device status on WS disconnect)
+        reconnectTimeout = setTimeout(connectWebSocket, 3000)
+      }
+
+      ws.onerror = () => {
+        if (!isMounted) return
+        setIsBackendConnected(false)
+      }
+    }
+
+    // Reset playing state on mount
+    wasPlayingRef.current = null
+
+    // Connect on mount
+    connectWebSocket()
+
+    // Subscribe to base URL changes (when user switches tables)
+    // This triggers reconnection to the new backend
+    const unsubscribe = apiClient.onBaseUrlChange(() => {
+      if (isMounted) {
+        wasPlayingRef.current = null // Reset playing state for new table
+        setCurrentPlayingFile(null) // Reset playback state for new table
+        setIsConnected(false) // Reset connection status until new table reports
+        setIsBackendConnected(false) // Show connecting state
+        setSensorHomingFailed(false) // Reset sensor homing failure state for new table
+        connectWebSocket()
+      }
+    })
+
+    return () => {
+      isMounted = false
+      unsubscribe()
+      if (reconnectTimeout) {
+        clearTimeout(reconnectTimeout)
+      }
+      if (wsRef.current) {
+        // Only close if already OPEN - CONNECTING WebSockets will close in onopen
+        if (wsRef.current.readyState === WebSocket.OPEN) {
+          wsRef.current.close()
+        }
+        wsRef.current = null
+      }
+    }
+  }, []) // Empty deps - runs once on mount, reconnects via apiClient listener
+
+  // Connect to logs WebSocket when drawer opens
+  useEffect(() => {
+    if (!isLogsOpen) {
+      // Close WebSocket when drawer closes - only if OPEN (CONNECTING will close in onopen)
+      if (logsWsRef.current && logsWsRef.current.readyState === WebSocket.OPEN) {
+        logsWsRef.current.close()
+      }
+      logsWsRef.current = null
+      return
+    }
+
+    let shouldConnect = true
+
+    // Fetch initial logs (most recent)
+    const fetchInitialLogs = async () => {
+      try {
+        type LogEntry = { timestamp: string; level: string; logger: string; message: string }
+        type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean }
+        const data = await apiClient.get<LogsResponse>('/api/logs?limit=200')
+        // Filter out empty/invalid log entries
+        const validLogs = (data.logs || []).filter(
+          (log) => log && log.message && log.message.trim() !== ''
+        )
+        // API returns newest first, reverse to show oldest first (newest at bottom)
+        setLogs(validLogs.reverse())
+        setLogsTotal(data.total || 0)
+        setLogsHasMore(data.has_more || false)
+        logsLoadedCountRef.current = validLogs.length
+        // Scroll to bottom after initial load
+        setTimeout(() => {
+          if (logsContainerRef.current) {
+            logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
+          }
+        }, 100)
+      } catch {
+        // Ignore errors
+      }
+    }
+
+    fetchInitialLogs()
+
+    // Connect to WebSocket for real-time updates
+    let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
+
+    const connectLogsWebSocket = () => {
+      // Don't interrupt an existing connection that's still connecting
+      if (logsWsRef.current) {
+        if (logsWsRef.current.readyState === WebSocket.CONNECTING) {
+          return // Already connecting, wait for it
+        }
+        if (logsWsRef.current.readyState === WebSocket.OPEN) {
+          logsWsRef.current.close()
+        }
+        logsWsRef.current = null
+      }
+
+      const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/logs'))
+      // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
+      logsWsRef.current = ws
+
+      ws.onopen = () => {
+        if (!shouldConnect) {
+          // Effect cleanup ran while connecting - close now
+          ws.close()
+          return
+        }
+        console.log('Logs WebSocket connected')
+      }
+
+      ws.onmessage = (event) => {
+        try {
+          const message = JSON.parse(event.data)
+
+          // Skip heartbeat messages
+          if (message.type === 'heartbeat') {
+            return
+          }
+
+          // Extract log from wrapped structure
+          const log = message.type === 'log_entry' ? message.data : message
+
+          // Skip empty or invalid log entries
+          if (!log || !log.message || log.message.trim() === '') {
+            return
+          }
+          // Append new log - no limit, lazy loading handles old logs
+          setLogs((prev) => [...prev, log])
+          // Auto-scroll to bottom if user is near the bottom
+          setTimeout(() => {
+            if (logsContainerRef.current) {
+              const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current
+              // Only auto-scroll if user is within 100px of the bottom
+              if (scrollHeight - scrollTop - clientHeight < 100) {
+                logsContainerRef.current.scrollTop = scrollHeight
+              }
+            }
+          }, 10)
+        } catch {
+          // Ignore parse errors
+        }
+      }
+
+      ws.onclose = () => {
+        if (!shouldConnect) return
+        console.log('Logs WebSocket closed, reconnecting...')
+        // Reconnect after 3 seconds if drawer is still open
+        reconnectTimeout = setTimeout(() => {
+          if (shouldConnect && logsWsRef.current === ws) {
+            connectLogsWebSocket()
+          }
+        }, 3000)
+      }
+
+      ws.onerror = (error) => {
+        console.error('Logs WebSocket error:', error)
+      }
+    }
+
+    connectLogsWebSocket()
+
+    return () => {
+      shouldConnect = false
+      if (reconnectTimeout) {
+        clearTimeout(reconnectTimeout)
+      }
+      if (logsWsRef.current) {
+        // Only close if already OPEN - CONNECTING WebSockets will close in onopen
+        if (logsWsRef.current.readyState === WebSocket.OPEN) {
+          logsWsRef.current.close()
+        }
+        logsWsRef.current = null
+      }
+    }
+    // Also reconnect when active table changes
+  }, [isLogsOpen, activeTable?.id])
+
+  // Load older logs when user scrolls to top (lazy loading)
+  const loadOlderLogs = useCallback(async () => {
+    if (isLoadingMoreLogs || !logsHasMore) return
+
+    setIsLoadingMoreLogs(true)
+    try {
+      type LogEntry = { timestamp: string; level: string; logger: string; message: string }
+      type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean }
+      const offset = logsLoadedCountRef.current
+      const data = await apiClient.get<LogsResponse>(`/api/logs?limit=100&offset=${offset}`)
+
+      const validLogs = (data.logs || []).filter(
+        (log) => log && log.message && log.message.trim() !== ''
+      )
+
+      if (validLogs.length > 0) {
+        // Prepend older logs (they come newest-first, so reverse them)
+        setLogs((prev) => [...validLogs.reverse(), ...prev])
+        logsLoadedCountRef.current += validLogs.length
+        setLogsHasMore(data.has_more || false)
+        setLogsTotal(data.total || 0)
+
+        // Maintain scroll position after prepending
+        setTimeout(() => {
+          if (logsContainerRef.current) {
+            // Calculate approximate height of new content (rough estimate: 24px per log line)
+            const newContentHeight = validLogs.length * 24
+            logsContainerRef.current.scrollTop = newContentHeight
+          }
+        }, 10)
+      } else {
+        setLogsHasMore(false)
+      }
+    } catch {
+      // Ignore errors
+    } finally {
+      setIsLoadingMoreLogs(false)
+    }
+  }, [isLoadingMoreLogs, logsHasMore])
+
+  // Scroll event handler for lazy loading
+  useEffect(() => {
+    const container = logsContainerRef.current
+    if (!container || !isLogsOpen) return
+
+    const handleScroll = () => {
+      // Load more when scrolled to top (within 50px)
+      if (container.scrollTop < 50 && logsHasMore && !isLoadingMoreLogs) {
+        loadOlderLogs()
+      }
+    }
+
+    container.addEventListener('scroll', handleScroll)
+    return () => container.removeEventListener('scroll', handleScroll)
+  }, [isLogsOpen, logsHasMore, isLoadingMoreLogs, loadOlderLogs])
+
+  const handleToggleLogs = () => {
+    setIsLogsOpen((prev) => !prev)
+  }
+
+  // Filter logs by level and search query
+  const filteredLogs = useMemo(() => {
+    let result = logLevelFilter === 'ALL'
+      ? logs
+      : logs.filter((log) => log.level === logLevelFilter)
+    if (logSearchQuery) {
+      const q = logSearchQuery.toLowerCase()
+      result = result.filter((log) =>
+        log.message?.toLowerCase().includes(q) ||
+        log.logger?.toLowerCase().includes(q)
+      )
+    }
+    return result
+  }, [logs, logLevelFilter, logSearchQuery])
+
+  // Format timestamp safely
+  const formatTimestamp = (timestamp: string) => {
+    if (!timestamp) return '--:--:--'
+    try {
+      const date = new Date(timestamp)
+      if (isNaN(date.getTime())) return '--:--:--'
+      return date.toLocaleTimeString()
+    } catch {
+      return '--:--:--'
+    }
+  }
+
+  // Copy logs to clipboard (with fallback for non-HTTPS)
+  const handleCopyLogs = () => {
+    const text = filteredLogs
+      .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`)
+      .join('\n')
+    copyToClipboard(text)
+  }
+
+  // Helper to copy text with fallback for non-secure contexts
+  const copyToClipboard = (text: string) => {
+    if (navigator.clipboard && window.isSecureContext) {
+      navigator.clipboard.writeText(text).then(() => {
+        toast.success('Logs copied to clipboard')
+      }).catch(() => {
+        toast.error('Failed to copy logs')
+      })
+    } else {
+      // Fallback for non-secure contexts (http://)
+      const textArea = document.createElement('textarea')
+      textArea.value = text
+      textArea.style.position = 'fixed'
+      textArea.style.left = '-9999px'
+      document.body.appendChild(textArea)
+      textArea.select()
+      try {
+        document.execCommand('copy')
+        toast.success('Logs copied to clipboard')
+      } catch {
+        toast.error('Failed to copy logs')
+      }
+      document.body.removeChild(textArea)
+    }
+  }
+
+  // Download logs as file
+  const handleDownloadLogs = () => {
+    const text = filteredLogs
+      .map((log) => `${log.timestamp} [${log.level}] [${log.logger}] ${log.message}`)
+      .join('\n')
+    const blob = new Blob([text], { type: 'text/plain' })
+    const url = URL.createObjectURL(blob)
+    const a = document.createElement('a')
+    a.href = url
+    a.download = `dune-weaver-logs-${new Date().toISOString().split('T')[0]}.txt`
+    a.click()
+    URL.revokeObjectURL(url)
+  }
+
+  const handleRestart = async () => {
+    if (!confirm('Are you sure you want to restart Docker containers?')) return
+
+    try {
+      await apiClient.post('/api/system/restart')
+      toast.success('Docker containers are restarting...')
+    } catch {
+      toast.error('Failed to restart Docker containers')
+    }
+  }
+
+  const handleShutdown = async () => {
+    if (!confirm('Are you sure you want to shutdown the system?')) return
+
+    try {
+      await apiClient.post('/api/system/shutdown')
+      toast.success('System is shutting down...')
+    } catch {
+      toast.error('Failed to shutdown system')
+    }
+  }
+
+  // Handle sensor homing recovery
+  const handleSensorHomingRecovery = async (switchToCrashHoming: boolean) => {
+    setIsRecoveringHoming(true)
+    try {
+      const response = await apiClient.post<{
+        success: boolean
+        sensor_homing_failed?: boolean
+        message?: string
+      }>('/recover_sensor_homing', {
+        switch_to_crash_homing: switchToCrashHoming
+      })
+
+      if (response.success) {
+        toast.success(response.message || 'Homing completed successfully')
+        setSensorHomingFailed(false)
+      } else if (response.sensor_homing_failed) {
+        // Sensor homing failed again
+        toast.error(response.message || 'Sensor homing failed again')
+      } else {
+        toast.error(response.message || 'Recovery failed')
+      }
+    } catch {
+      toast.error('Failed to recover from sensor homing failure')
+    } finally {
+      setIsRecoveringHoming(false)
+    }
+  }
+
+  // Update document title based on current page
+  useEffect(() => {
+    const currentNav = navItems.find((item) => item.path === location.pathname)
+    if (currentNav) {
+      document.title = `${currentNav.title} | ${displayName}`
+    } else {
+      document.title = displayName
+    }
+  }, [location.pathname, displayName])
+
+  useEffect(() => {
+    if (isDark) {
+      document.documentElement.classList.add('dark')
+      localStorage.setItem('theme', 'dark')
+    } else {
+      document.documentElement.classList.remove('dark')
+      localStorage.setItem('theme', 'light')
+    }
+  }, [isDark])
+
+  // Blocking overlay logs state - shows connection attempts
+  const [connectionLogs, setConnectionLogs] = useState<Array<{ timestamp: string; level: string; message: string }>>([])
+  const blockingLogsRef = useRef<HTMLDivElement>(null)
+
+  // Cache progress state
+  const [cacheProgress, setCacheProgress] = useState<{
+    is_running: boolean
+    stage: string
+    processed_files: number
+    total_files: number
+    current_file: string
+    error?: string
+  } | null>(null)
+  const cacheWsRef = useRef<WebSocket | null>(null)
+
+  // Cache All Previews prompt state
+  const [showCacheAllPrompt, setShowCacheAllPrompt] = useState(false)
+  const [cacheAllProgress, setCacheAllProgress] = useState<{
+    inProgress: boolean
+    completed: number
+    total: number
+    done: boolean
+  } | null>(null)
+
+  // Blocking overlay logs WebSocket ref
+  const blockingLogsWsRef = useRef<WebSocket | null>(null)
+
+  // Add connection/homing logs when overlay is shown
+  useEffect(() => {
+    const showOverlay = !isBackendConnected || isHoming || homingJustCompleted
+
+    if (!showOverlay) {
+      setConnectionLogs([])
+      // Close WebSocket if open - only if OPEN (CONNECTING will close in onopen)
+      if (blockingLogsWsRef.current && blockingLogsWsRef.current.readyState === WebSocket.OPEN) {
+        blockingLogsWsRef.current.close()
+      }
+      blockingLogsWsRef.current = null
+      return
+    }
+
+    // Don't clear logs or reconnect WebSocket during completion state
+    if (homingJustCompleted && !isHoming) {
+      return
+    }
+
+    // Add log entry helper
+    const addLog = (level: string, message: string, timestamp?: string) => {
+      setConnectionLogs((prev) => {
+        const newLog = {
+          timestamp: timestamp || new Date().toISOString(),
+          level,
+          message,
+        }
+        const newLogs = [...prev, newLog].slice(-100) // Keep last 100 entries
+        return newLogs
+      })
+      // Auto-scroll to bottom
+      setTimeout(() => {
+        if (blockingLogsRef.current) {
+          blockingLogsRef.current.scrollTop = blockingLogsRef.current.scrollHeight
+        }
+      }, 10)
+    }
+
+    // If homing, connect to logs WebSocket to stream real logs
+    if (isHoming && isBackendConnected) {
+      addLog('INFO', 'Homing started...')
+
+      let shouldConnect = true
+
+      // Don't interrupt an existing connection that's still connecting
+      if (blockingLogsWsRef.current) {
+        if (blockingLogsWsRef.current.readyState === WebSocket.CONNECTING) {
+          return // Already connecting, wait for it
+        }
+        if (blockingLogsWsRef.current.readyState === WebSocket.OPEN) {
+          blockingLogsWsRef.current.close()
+        }
+        blockingLogsWsRef.current = null
+      }
+
+      const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/logs'))
+      // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
+      blockingLogsWsRef.current = ws
+
+      ws.onopen = () => {
+        if (!shouldConnect) {
+          // Effect cleanup ran while connecting - close now
+          ws.close()
+        }
+      }
+
+      ws.onmessage = (event) => {
+        try {
+          const message = JSON.parse(event.data)
+          if (message.type === 'heartbeat') return
+
+          const log = message.type === 'log_entry' ? message.data : message
+          if (!log || !log.message || log.message.trim() === '') return
+
+          // Filter for homing-related logs
+          const msg = log.message.toLowerCase()
+          const isHomingLog =
+            msg.includes('homing') ||
+            msg.includes('home') ||
+            msg.includes('$h') ||
+            msg.includes('idle') ||
+            msg.includes('unlock') ||
+            msg.includes('alarm') ||
+            msg.includes('grbl') ||
+            msg.includes('connect') ||
+            msg.includes('serial') ||
+            msg.includes('device') ||
+            msg.includes('position') ||
+            msg.includes('zeroing') ||
+            msg.includes('movement') ||
+            log.logger?.includes('connection')
+
+          if (isHomingLog) {
+            addLog(log.level, log.message, log.timestamp)
+          }
+        } catch {
+          // Ignore parse errors
+        }
+      }
+
+      return () => {
+        shouldConnect = false
+        // Only close if already OPEN - CONNECTING WebSockets will close in onopen
+        if (ws.readyState === WebSocket.OPEN) {
+          ws.close()
+        }
+        blockingLogsWsRef.current = null
+      }
+    }
+
+    // If backend disconnected, show connection retry logs
+    if (!isBackendConnected) {
+      addLog('INFO', `Attempting to connect to backend at ${window.location.host}...`)
+
+      const interval = setInterval(() => {
+        addLog('INFO', `Retrying connection to WebSocket /ws/status...`)
+
+        apiClient.get('/api/settings')
+          .then(() => {
+            addLog('INFO', 'HTTP endpoint responding, waiting for WebSocket...')
+          })
+          .catch(() => {
+            // Still down
+          })
+      }, 3000)
+
+      return () => clearInterval(interval)
+    }
+  }, [isBackendConnected, isHoming, homingJustCompleted])
+
+  // Cache progress WebSocket connection - always connected to monitor cache generation
+  useEffect(() => {
+    if (!isBackendConnected) return
+
+    let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
+    let shouldConnect = true
+
+    const connectCacheWebSocket = () => {
+      if (!shouldConnect) return
+      // Don't interrupt an existing connection that's still connecting
+      if (cacheWsRef.current) {
+        if (cacheWsRef.current.readyState === WebSocket.CONNECTING) {
+          return // Already connecting, wait for it
+        }
+        if (cacheWsRef.current.readyState === WebSocket.OPEN) {
+          return // Already connected
+        }
+        // CLOSING or CLOSED state - clear the ref
+        cacheWsRef.current = null
+      }
+
+      const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/cache-progress'))
+      // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
+      cacheWsRef.current = ws
+
+      ws.onopen = () => {
+        if (!shouldConnect) {
+          // Effect cleanup ran while connecting - close now
+          ws.close()
+        }
+      }
+
+      ws.onmessage = (event) => {
+        try {
+          const message = JSON.parse(event.data)
+          if (message.type === 'cache_progress') {
+            const data = message.data
+            if (data.is_running) {
+              // Cache generation is running - show splash screen
+              setCacheProgress(data)
+            } else if (data.stage === 'complete') {
+              // Cache generation just completed
+              if (cacheProgress?.is_running) {
+                // Was running before, now complete - show cache all prompt
+                const promptShown = localStorage.getItem('cacheAllPromptShown')
+                if (!promptShown) {
+                  setTimeout(() => {
+                    setCacheAllProgress(null) // Reset to clean state
+                    setShowCacheAllPrompt(true)
+                  }, 500)
+                }
+              }
+              setCacheProgress(null)
+            } else {
+              // Not running and not complete (idle state)
+              setCacheProgress(null)
+            }
+          }
+        } catch {
+          // Ignore parse errors
+        }
+      }
+
+      ws.onclose = () => {
+        if (!shouldConnect) return
+        cacheWsRef.current = null
+        // Reconnect after 3 seconds
+        if (shouldConnect && isBackendConnected) {
+          reconnectTimeout = setTimeout(connectCacheWebSocket, 3000)
+        }
+      }
+
+      ws.onerror = () => {
+        // Will trigger onclose
+      }
+    }
+
+    connectCacheWebSocket()
+
+    return () => {
+      shouldConnect = false
+      if (reconnectTimeout) {
+        clearTimeout(reconnectTimeout)
+      }
+      if (cacheWsRef.current) {
+        // Only close if already OPEN - CONNECTING WebSockets will close in onopen
+        if (cacheWsRef.current.readyState === WebSocket.OPEN) {
+          cacheWsRef.current.close()
+        }
+        cacheWsRef.current = null
+      }
+    }
+  }, [isBackendConnected]) // Only reconnect based on backend connection, not cache state
+
+  // Calculate cache progress percentage
+  const cachePercentage = cacheProgress?.total_files
+    ? Math.round((cacheProgress.processed_files / cacheProgress.total_files) * 100)
+    : 0
+
+  const getCacheStageText = () => {
+    if (!cacheProgress) return ''
+    switch (cacheProgress.stage) {
+      case 'starting':
+        return 'Initializing...'
+      case 'metadata':
+        return 'Processing pattern metadata'
+      case 'images':
+        return 'Generating pattern previews'
+      default:
+        return 'Processing...'
+    }
+  }
+
+  // Cache all previews in browser using IndexedDB
+  const handleCacheAllPreviews = async () => {
+    setCacheAllProgress({ inProgress: true, completed: 0, total: 0, done: false })
+
+    const result = await cacheAllPreviews((progress) => {
+      setCacheAllProgress({ inProgress: !progress.done, ...progress })
+    })
+
+    if (result.success) {
+      if (result.cached === 0) {
+        toast.success('All patterns are already cached!')
+      } else {
+        toast.success(`Cached ${result.cached} pattern previews`)
+      }
+    } else {
+      setCacheAllProgress(null)
+      toast.error('Failed to cache previews')
+    }
+  }
+
+  const handleSkipCacheAll = () => {
+    localStorage.setItem('cacheAllPromptShown', 'true')
+    setShowCacheAllPrompt(false)
+    setCacheAllProgress(null)
+  }
+
+  const handleCloseCacheAllDone = () => {
+    localStorage.setItem('cacheAllPromptShown', 'true')
+    setShowCacheAllPrompt(false)
+    setCacheAllProgress(null)
+  }
+
+  // Now Playing button drag handlers
+  const getSnapPositions = useCallback(() => {
+    const padding = 16
+    const buttonWidth = buttonRef.current?.offsetWidth || 140
+    return {
+      left: padding + buttonWidth / 2,
+      center: window.innerWidth / 2,
+      right: window.innerWidth - padding - buttonWidth / 2,
+    }
+  }, [])
+
+  const handleButtonDragStart = useCallback((clientX: number, clientY: number) => {
+    if (!buttonRef.current) return
+    const rect = buttonRef.current.getBoundingClientRect()
+    const buttonCenterX = rect.left + rect.width / 2
+    dragStartRef.current = { x: clientX, y: clientY, buttonX: buttonCenterX }
+    wasDraggingRef.current = false // Reset drag flag
+    setIsDraggingButton(true)
+    setDragOffset({ x: 0, y: 0 })
+  }, [])
+
+  const handleButtonDragMove = useCallback((clientX: number) => {
+    if (!dragStartRef.current || !isDraggingButton) return
+    const deltaX = clientX - dragStartRef.current.x
+    // Mark as dragging if moved more than 8px (to distinguish from clicks)
+    if (Math.abs(deltaX) > 8) {
+      wasDraggingRef.current = true
+    }
+    setDragOffset({ x: deltaX, y: 0 })
+  }, [isDraggingButton])
+
+  const handleButtonDragEnd = useCallback(() => {
+    if (!dragStartRef.current || !buttonRef.current) {
+      setIsDraggingButton(false)
+      setDragOffset({ x: 0, y: 0 })
+      return
+    }
+
+    // Calculate current position
+    const currentX = dragStartRef.current.buttonX + dragOffset.x
+    const snapPositions = getSnapPositions()
+
+    // Find nearest snap position
+    const distances = {
+      left: Math.abs(currentX - snapPositions.left),
+      center: Math.abs(currentX - snapPositions.center),
+      right: Math.abs(currentX - snapPositions.right),
+    }
+
+    let nearest: SnapPosition = 'center'
+    let minDistance = distances.center
+    if (distances.left < minDistance) {
+      nearest = 'left'
+      minDistance = distances.left
+    }
+    if (distances.right < minDistance) {
+      nearest = 'right'
+    }
+
+    // Update position and persist
+    setNowPlayingButtonPos(nearest)
+    localStorage.setItem('nowPlayingButtonPos', nearest)
+
+    // Reset drag state
+    setIsDraggingButton(false)
+    setDragOffset({ x: 0, y: 0 })
+    dragStartRef.current = null
+  }, [dragOffset.x, getSnapPositions])
+
+  // Mouse drag handlers
+  useEffect(() => {
+    if (!isDraggingButton) return
+
+    const handleMouseMove = (e: MouseEvent) => {
+      e.preventDefault()
+      handleButtonDragMove(e.clientX)
+    }
+
+    const handleMouseUp = () => {
+      handleButtonDragEnd()
+    }
+
+    window.addEventListener('mousemove', handleMouseMove)
+    window.addEventListener('mouseup', handleMouseUp)
+
+    return () => {
+      window.removeEventListener('mousemove', handleMouseMove)
+      window.removeEventListener('mouseup', handleMouseUp)
+    }
+  }, [isDraggingButton, handleButtonDragMove, handleButtonDragEnd])
+
+  // Get button position style
+  const getButtonPositionStyle = useCallback((): React.CSSProperties => {
+    const baseStyle: React.CSSProperties = {
+      bottom: 'calc(4.5rem + env(safe-area-inset-bottom, 0px))',
+    }
+
+    if (isDraggingButton && dragStartRef.current) {
+      // During drag, use transform for smooth movement
+      const snapPositions = getSnapPositions()
+      const startX = snapPositions[nowPlayingButtonPos]
+      return {
+        ...baseStyle,
+        left: startX,
+        transform: `translateX(calc(-50% + ${dragOffset.x}px))`,
+        transition: 'none',
+        cursor: 'grabbing',
+      }
+    }
+
+    // Snapped positions
+    switch (nowPlayingButtonPos) {
+      case 'left':
+        return { ...baseStyle, left: '1rem', transform: 'translateX(0)' }
+      case 'right':
+        return { ...baseStyle, right: '1rem', left: 'auto', transform: 'translateX(0)' }
+      case 'center':
+      default:
+        return { ...baseStyle, left: '50%', transform: 'translateX(-50%)' }
+    }
+  }, [isDraggingButton, dragOffset.x, nowPlayingButtonPos, getSnapPositions])
+
+  const cacheAllPercentage = cacheAllProgress?.total
+    ? Math.round((cacheAllProgress.completed / cacheAllProgress.total) * 100)
+    : 0
+
+  return (
+    <div className="min-h-dvh bg-background flex flex-col">
+      {/* Sensor Homing Failure Popup */}
+      {sensorHomingFailed && (
+        <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
+          <div className="bg-background rounded-lg shadow-xl w-full max-w-md border border-destructive/30">
+            <div className="p-6">
+              <div className="text-center space-y-4">
+                <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-destructive/10 mb-2">
+                  <span className="material-icons-outlined text-4xl text-destructive">
+                    error_outline
+                  </span>
+                </div>
+                <h2 className="text-xl font-semibold">Sensor Homing Failed</h2>
+                <p className="text-muted-foreground text-sm">
+                  The sensor homing process could not complete. The limit sensors may not be positioned correctly or may be malfunctioning.
+                </p>
+
+                <div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-sm text-left">
+                  <p className="text-amber-600 dark:text-amber-400 font-medium mb-2">
+                    Troubleshooting steps:
+                  </p>
+                  <ul className="text-amber-600 dark:text-amber-400 space-y-1 list-disc list-inside">
+                    <li>Check that the limit sensors are properly connected</li>
+                    <li>Verify the sensor positions are correct</li>
+                    <li>Ensure nothing is blocking the sensor path</li>
+                    <li>Check for loose wiring connections</li>
+                  </ul>
+                </div>
+
+                <p className="text-muted-foreground text-sm">
+                  Connection will not be established until this is resolved.
+                </p>
+
+                {/* Action Buttons */}
+                {!isRecoveringHoming ? (
+                  <div className="flex flex-col gap-2 pt-2">
+                    <Button
+                      onClick={() => handleSensorHomingRecovery(false)}
+                      className="w-full gap-2"
+                    >
+                      <span className="material-icons text-base">refresh</span>
+                      Retry Sensor Homing
+                    </Button>
+                    <Button
+                      variant="secondary"
+                      onClick={() => handleSensorHomingRecovery(true)}
+                      className="w-full gap-2"
+                    >
+                      <span className="material-icons text-base">sync_alt</span>
+                      Switch to Crash Homing
+                    </Button>
+                    <p className="text-xs text-muted-foreground">
+                      Crash homing moves the arm to a physical stop without using sensors.
+                    </p>
+                  </div>
+                ) : (
+                  <div className="flex items-center justify-center gap-2 py-4">
+                    <span className="material-icons-outlined text-primary animate-spin">sync</span>
+                    <span className="text-muted-foreground">Attempting recovery...</span>
+                  </div>
+                )}
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Cache Progress Blocking Overlay */}
+      {cacheProgress?.is_running && (
+        <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
+          <div className="w-full max-w-md space-y-6">
+            <div className="text-center space-y-4">
+              <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-2">
+                <span className="material-icons-outlined text-4xl text-primary animate-pulse">
+                  cached
+                </span>
+              </div>
+              <h2 className="text-2xl font-bold">Initializing Pattern Cache</h2>
+              <p className="text-muted-foreground">
+                Preparing your pattern previews...
+              </p>
+            </div>
+
+            {/* Progress Bar */}
+            <div className="space-y-2">
+              <div className="w-full bg-muted rounded-full h-2 overflow-hidden">
+                <div
+                  className="bg-primary h-2 rounded-full transition-all duration-300"
+                  style={{ width: `${cachePercentage}%` }}
+                />
+              </div>
+              <div className="flex justify-between text-sm text-muted-foreground">
+                <span>
+                  {cacheProgress.processed_files} of {cacheProgress.total_files} patterns
+                </span>
+                <span>{cachePercentage}%</span>
+              </div>
+            </div>
+
+            {/* Stage Info */}
+            <div className="text-center space-y-1">
+              <p className="text-sm font-medium">{getCacheStageText()}</p>
+              {cacheProgress.current_file && (
+                <p className="text-xs text-muted-foreground truncate max-w-full">
+                  {cacheProgress.current_file}
+                </p>
+              )}
+            </div>
+
+            {/* Hint */}
+            <p className="text-center text-xs text-muted-foreground">
+              This only happens once after updates or when new patterns are added
+            </p>
+          </div>
+        </div>
+      )}
+
+      {/* Cache All Previews Prompt Modal */}
+      {showCacheAllPrompt && (
+        <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
+          <div className="bg-background rounded-lg shadow-xl w-full max-w-md">
+            <div className="p-6">
+              <div className="text-center space-y-4">
+                <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 mb-2">
+                  <span className="material-icons-outlined text-2xl text-primary">
+                    download_for_offline
+                  </span>
+                </div>
+                <h2 className="text-xl font-semibold">Cache All Pattern Previews?</h2>
+                <p className="text-muted-foreground text-sm">
+                  Would you like to cache all pattern previews for faster browsing? This will download and store preview images in your browser for instant loading.
+                </p>
+
+                <div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-sm">
+                  <p className="text-amber-600 dark:text-amber-400">
+                    <strong>Note:</strong> This cache is browser-specific. You'll need to repeat this for each browser you use.
+                  </p>
+                </div>
+
+                {/* Initial state - show buttons */}
+                {!cacheAllProgress && (
+                  <div className="flex gap-3 justify-center">
+                    <Button variant="ghost" onClick={handleSkipCacheAll}>
+                      Skip for now
+                    </Button>
+                    <Button variant="secondary" onClick={handleCacheAllPreviews} className="gap-2">
+                      <span className="material-icons-outlined text-lg">cached</span>
+                      Cache All
+                    </Button>
+                  </div>
+                )}
+
+                {/* Progress section */}
+                {cacheAllProgress && !cacheAllProgress.done && (
+                  <div className="space-y-2">
+                    <div className="w-full bg-muted rounded-full h-2 overflow-hidden">
+                      <div
+                        className="bg-primary h-2 rounded-full transition-all duration-300"
+                        style={{ width: `${cacheAllPercentage}%` }}
+                      />
+                    </div>
+                    <div className="flex justify-between text-sm text-muted-foreground">
+                      <span>
+                        {cacheAllProgress.completed} of {cacheAllProgress.total} previews
+                      </span>
+                      <span>{cacheAllPercentage}%</span>
+                    </div>
+                  </div>
+                )}
+
+                {/* Completion message */}
+                {cacheAllProgress?.done && (
+                  <div className="space-y-4">
+                    <p className="text-green-600 dark:text-green-400 flex items-center justify-center gap-2">
+                      <span className="material-icons text-base">check_circle</span>
+                      All {cacheAllProgress.total} previews cached successfully!
+                    </p>
+                    <Button onClick={handleCloseCacheAllDone} className="w-full">
+                      Done
+                    </Button>
+                  </div>
+                )}
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Backend Connection / Homing Blocking Overlay */}
+      {/* Don't show this overlay when sensor homing failed - that has its own dialog */}
+      {!sensorHomingFailed && (!isBackendConnected || (isHoming && !homingDismissed) || homingJustCompleted) && (
+        <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
+          <div className="w-full max-w-2xl space-y-6">
+            {/* Status Header */}
+            <div className="text-center space-y-4">
+              <div className={`inline-flex items-center justify-center w-16 h-16 rounded-full mb-2 ${
+                homingJustCompleted
+                  ? 'bg-green-500/10'
+                  : isHoming
+                    ? 'bg-primary/10'
+                    : 'bg-amber-500/10'
+              }`}>
+                <span className={`material-icons-outlined text-4xl ${
+                  homingJustCompleted
+                    ? 'text-green-500'
+                    : isHoming
+                      ? 'text-primary animate-spin'
+                      : 'text-amber-500 animate-pulse'
+                }`}>
+                  {homingJustCompleted ? 'check_circle' : 'sync'}
+                </span>
+              </div>
+              <h2 className="text-2xl font-bold">
+                {homingJustCompleted
+                  ? 'Homing Complete'
+                  : isHoming
+                    ? 'Homing in Progress'
+                    : 'Connecting to Backend'
+                }
+              </h2>
+              <p className="text-muted-foreground">
+                {homingJustCompleted
+                  ? 'Table is ready to use'
+                  : isHoming
+                    ? 'Moving to home position... This may take up to 90 seconds.'
+                    : connectionAttempts === 0
+                      ? 'Establishing connection...'
+                      : `Reconnecting... (attempt ${connectionAttempts})`
+                }
+              </p>
+              <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
+                <span className={`w-2 h-2 rounded-full ${
+                  homingJustCompleted
+                    ? 'bg-green-500'
+                    : isHoming
+                      ? 'bg-primary animate-pulse'
+                      : 'bg-amber-500 animate-pulse'
+                }`} />
+                <span>
+                  {homingJustCompleted
+                    ? keepHomingLogsOpen
+                      ? 'Viewing logs'
+                      : `Closing in ${homingCountdown}s...`
+                    : isHoming
+                      ? 'Do not interrupt the homing process'
+                      : `Waiting for server at ${window.location.host}`
+                  }
+                </span>
+              </div>
+            </div>
+
+            {/* Logs Panel */}
+            <div className="bg-muted/50 rounded-lg border overflow-hidden">
+              <div className="flex items-center justify-between px-4 py-2 border-b bg-muted">
+                <div className="flex items-center gap-2">
+                  <span className="material-icons-outlined text-base">terminal</span>
+                  <span className="text-sm font-medium">
+                    {isHoming || homingJustCompleted ? 'Homing Log' : 'Connection Log'}
+                  </span>
+                </div>
+                <div className="flex items-center gap-2">
+                  <button
+                    onClick={() => {
+                      const logText = connectionLogs
+                        .map((log) => `[${new Date(log.timestamp).toLocaleTimeString()}] [${log.level}] ${log.message}`)
+                        .join('\n')
+                      copyToClipboard(logText)
+                    }}
+                    className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
+                    title="Copy logs to clipboard"
+                  >
+                    <span className="material-icons text-sm">content_copy</span>
+                    Copy
+                  </button>
+                  <span className="text-xs text-muted-foreground">
+                    {connectionLogs.length} entries
+                  </span>
+                </div>
+              </div>
+              <div
+                ref={blockingLogsRef}
+                className="h-48 overflow-auto p-3 font-mono text-xs space-y-0.5"
+              >
+                {connectionLogs.map((log, i) => (
+                  <div key={i} className="py-0.5 flex gap-2">
+                    <span className="text-muted-foreground shrink-0">
+                      {formatTimestamp(log.timestamp)}
+                    </span>
+                    <span className={`shrink-0 font-semibold ${
+                      log.level === 'ERROR' ? 'text-red-500' :
+                      log.level === 'WARNING' ? 'text-amber-500' :
+                      log.level === 'DEBUG' ? 'text-muted-foreground' :
+                      'text-foreground'
+                    }`}>
+                      [{log.level}]
+                    </span>
+                    <span className="break-all">{log.message}</span>
+                  </div>
+                ))}
+              </div>
+            </div>
+
+            {/* Action buttons for homing completion */}
+            {homingJustCompleted && (
+              <div className="flex justify-center gap-3">
+                {!keepHomingLogsOpen ? (
+                  <>
+                    <Button
+                      variant="secondary"
+                      onClick={() => setKeepHomingLogsOpen(true)}
+                      className="gap-2"
+                    >
+                      <span className="material-icons text-base">visibility</span>
+                      Keep Open
+                    </Button>
+                    <Button
+                      onClick={() => {
+                        setHomingJustCompleted(false)
+                        setKeepHomingLogsOpen(false)
+                      }}
+                      className="gap-2"
+                    >
+                      <span className="material-icons text-base">close</span>
+                      Dismiss
+                    </Button>
+                  </>
+                ) : (
+                  <Button
+                    onClick={() => {
+                      setHomingJustCompleted(false)
+                      setKeepHomingLogsOpen(false)
+                    }}
+                    className="gap-2"
+                  >
+                    <span className="material-icons text-base">close</span>
+                    Close Logs
+                  </Button>
+                )}
+              </div>
+            )}
+
+            {/* Dismiss button during homing */}
+            {isHoming && !homingJustCompleted && (
+              <div className="flex justify-center">
+                <Button
+                  variant="ghost"
+                  onClick={() => setHomingDismissed(true)}
+                  className="gap-2 text-muted-foreground"
+                >
+                  <span className="material-icons text-base">visibility_off</span>
+                  Dismiss
+                </Button>
+              </div>
+            )}
+
+            {/* Hint */}
+            {!homingJustCompleted && (
+              <p className="text-center text-xs text-muted-foreground">
+                {isHoming
+                  ? 'Homing will continue in the background'
+                  : 'Make sure the backend server is running on port 8080'
+                }
+              </p>
+            )}
+          </div>
+        </div>
+      )}
+
+      {/* Header - Floating Pill */}
+      <header className="fixed top-0 left-0 right-0 z-40 pt-safe">
+        {/* Blurry backdrop behind header - only on Browse page where content scrolls under */}
+        {location.pathname === '/' && (
+          <div className="absolute inset-0 bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/50" style={{ height: 'calc(5rem + env(safe-area-inset-top, 0px))' }} />
+        )}
+        <div className="relative w-full max-w-5xl mx-auto px-3 sm:px-4 pt-3 pointer-events-none">
+          <div className="flex h-12 items-center justify-between px-4 rounded-full bg-card shadow-lg border border-border pointer-events-auto">
+          <div className="flex items-center gap-2">
+            <Link to="/">
+              <img
+                src={customLogo ? apiClient.getAssetUrl(`/static/custom/${customLogo}`) : apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
+                alt={displayName}
+                className="w-8 h-8 rounded-full object-cover"
+              />
+            </Link>
+            <TableSelector>
+              <button className="flex items-center gap-1.5 hover:opacity-80 transition-opacity group">
+                <ShinyText
+                  text={displayName}
+                  className="font-semibold text-lg"
+                  speed={4}
+                  color={isDark ? '#a8a8a8' : '#555555'}
+                  shineColor={isDark ? '#ffffff' : '#999999'}
+                  spread={75}
+                />
+                <span className="material-icons-outlined text-muted-foreground text-sm group-hover:text-foreground transition-colors">
+                  expand_more
+                </span>
+                <span
+                  className={`w-2 h-2 rounded-full ${
+                    !isBackendConnected
+                      ? 'bg-gray-400'
+                      : isConnected
+                        ? 'bg-green-500 animate-pulse'
+                        : 'bg-red-500'
+                  }`}
+                  title={
+                    !isBackendConnected
+                      ? 'Backend not connected'
+                      : isConnected
+                        ? 'Table connected'
+                        : 'Table disconnected'
+                  }
+                />
+              </button>
+            </TableSelector>
+          </div>
+
+          {/* Desktop actions */}
+          <div className="hidden md:flex items-center gap-0 ml-2">
+            {updateAvailable && (
+              <Link to="/settings?section=version" title="Software update available">
+                <span className="relative flex items-center justify-center w-8 h-8 rounded-full hover:bg-accent transition-colors">
+                  <span className="material-icons-outlined text-xl">download</span>
+                  <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
+                </span>
+              </Link>
+            )}
+            <Popover>
+              <PopoverTrigger asChild>
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  className="rounded-full"
+                  aria-label="Open menu"
+                >
+                  <span className="material-icons-outlined">menu</span>
+                </Button>
+              </PopoverTrigger>
+              <PopoverContent align="end" className="w-56 p-2">
+                <div className="flex flex-col gap-1">
+                  <button
+                    onClick={() => setIsDark(!isDark)}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
+                  >
+                    <span className="material-icons-outlined text-xl">
+                      {isDark ? 'light_mode' : 'dark_mode'}
+                    </span>
+                    {isDark ? 'Light Mode' : 'Dark Mode'}
+                  </button>
+                  <button
+                    onClick={handleToggleLogs}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
+                  >
+                    <span className="material-icons-outlined text-xl">article</span>
+                    View Logs
+                  </button>
+                  <Separator className="my-1" />
+                  <button
+                    onClick={handleRestart}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
+                  >
+                    <span className="material-icons-outlined text-xl">restart_alt</span>
+                    Restart Docker
+                  </button>
+                  <button
+                    onClick={handleShutdown}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-red-500"
+                  >
+                    <span className="material-icons-outlined text-xl">power_settings_new</span>
+                    Shutdown
+                  </button>
+                </div>
+              </PopoverContent>
+            </Popover>
+          </div>
+
+          {/* Mobile actions */}
+          <div className="flex md:hidden items-center gap-0 ml-2">
+            {updateAvailable && (
+              <Link to="/settings?section=version" title="Software update available">
+                <span className="relative flex items-center justify-center w-8 h-8 rounded-full hover:bg-accent transition-colors">
+                  <span className="material-icons-outlined text-xl">download</span>
+                  <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
+                </span>
+              </Link>
+            )}
+            <Popover open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
+              <PopoverTrigger asChild>
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  className="rounded-full"
+                  aria-label="Open menu"
+                >
+                  <span className="material-icons-outlined">
+                    {isMobileMenuOpen ? 'close' : 'menu'}
+                  </span>
+                </Button>
+              </PopoverTrigger>
+              <PopoverContent align="end" className="w-56 p-2">
+                <div className="flex flex-col gap-1">
+                  <button
+                    onClick={() => {
+                      setIsDark(!isDark)
+                      setIsMobileMenuOpen(false)
+                    }}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
+                  >
+                    <span className="material-icons-outlined text-xl">
+                      {isDark ? 'light_mode' : 'dark_mode'}
+                    </span>
+                    {isDark ? 'Light Mode' : 'Dark Mode'}
+                  </button>
+                  <button
+                    onClick={() => {
+                      handleToggleLogs()
+                      setIsMobileMenuOpen(false)
+                    }}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
+                  >
+                    <span className="material-icons-outlined text-xl">article</span>
+                    View Logs
+                  </button>
+                  <Separator className="my-1" />
+                  <button
+                    onClick={() => {
+                      handleRestart()
+                      setIsMobileMenuOpen(false)
+                    }}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
+                  >
+                    <span className="material-icons-outlined text-xl">restart_alt</span>
+                    Restart Docker
+                  </button>
+                  <button
+                    onClick={() => {
+                      handleShutdown()
+                      setIsMobileMenuOpen(false)
+                    }}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-red-500"
+                  >
+                    <span className="material-icons-outlined text-xl">power_settings_new</span>
+                    Shutdown
+                  </button>
+                </div>
+              </PopoverContent>
+            </Popover>
+            </div>
+          </div>
+        </div>
+      </header>
+
+      {/* Main Content */}
+      <main
+        className="container mx-auto px-4 transition-all duration-300"
+        style={{
+          paddingTop: 'calc(4.5rem + env(safe-area-inset-top, 0px))',
+          paddingBottom: isLogsOpen
+            ? isNowPlayingOpen
+              ? `calc(${logsDrawerHeight + 256 + 64}px + env(safe-area-inset-bottom, 0px))` // drawer + now playing + nav + safe area
+              : `calc(${logsDrawerHeight + 64}px + env(safe-area-inset-bottom, 0px))` // drawer + nav + safe area
+            : isNowPlayingOpen
+              ? 'calc(20rem + env(safe-area-inset-bottom, 0px))' // now playing bar + nav + safe area
+              : 'calc(8rem + env(safe-area-inset-bottom, 0px))' // floating pill + nav + safe area
+        }}
+      >
+        <Outlet />
+      </main>
+
+      {/* Now Playing Bar */}
+      <NowPlayingBar
+        isLogsOpen={isLogsOpen}
+        logsDrawerHeight={logsDrawerHeight}
+        isVisible={isNowPlayingOpen}
+        openExpanded={openNowPlayingExpanded}
+        onClose={() => setIsNowPlayingOpen(false)}
+      />
+
+
+      {/* Logs Drawer */}
+      <div
+        className={`fixed left-0 right-0 z-30 bg-background border-t border-border ${
+          isResizing ? '' : 'transition-[height] duration-300'
+        }`}
+        style={{
+          height: isLogsOpen ? logsDrawerHeight : 0,
+          bottom: 'calc(4rem + env(safe-area-inset-bottom, 0px))'
+        }}
+      >
+        {isLogsOpen && (
+          <>
+            {/* Resize Handle */}
+            <div
+              className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize flex items-center justify-center group hover:bg-primary/10 -translate-y-1/2 z-10"
+              onMouseDown={handleResizeStart}
+              onTouchStart={handleResizeStart}
+            >
+              <div className="w-12 h-1 rounded-full bg-border group-hover:bg-primary transition-colors" />
+            </div>
+
+            {/* Logs Header */}
+            <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50 gap-2">
+              <div className="flex items-center gap-2 sm:gap-3 flex-wrap min-w-0">
+                <span className="text-sm font-medium whitespace-nowrap">Application Logs</span>
+                <select
+                  value={logLevelFilter}
+                  onChange={(e) => setLogLevelFilter(e.target.value)}
+                  className="text-xs bg-background border rounded px-2 py-1"
+                >
+                  <option value="ALL">All Levels</option>
+                  <option value="DEBUG">Debug</option>
+                  <option value="INFO">Info</option>
+                  <option value="WARNING">Warning</option>
+                  <option value="ERROR">Error</option>
+                </select>
+                <input
+                  type="text"
+                  value={logSearchQuery}
+                  onChange={(e) => setLogSearchQuery(e.target.value)}
+                  placeholder="Search logs..."
+                  className="text-xs bg-background border rounded px-2 py-1 w-28 sm:w-40"
+                />
+                {logSearchQuery && (
+                  <Button variant="ghost" size="icon-sm" onClick={() => setLogSearchQuery('')} className="rounded-full" title="Clear search">
+                    <span className="material-icons-outlined text-sm">close</span>
+                  </Button>
+                )}
+                <span className="text-xs text-muted-foreground">
+                  {filteredLogs.length}{logsTotal > 0 ? ` of ${logsTotal}` : ''} entries
+                  {logsHasMore && <span className="text-primary ml-1">↑ scroll for more</span>}
+                </span>
+              </div>
+
+              <div className="flex items-center gap-1 shrink-0">
+                <Button
+                  variant="ghost"
+                  size="icon-sm"
+                  onClick={handleCopyLogs}
+                  className="rounded-full"
+                  title="Copy logs"
+                >
+                  <span className="material-icons-outlined text-base">content_copy</span>
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="icon-sm"
+                  onClick={handleDownloadLogs}
+                  className="rounded-full"
+                  title="Download logs"
+                >
+                  <span className="material-icons-outlined text-base">download</span>
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="icon-sm"
+                  onClick={() => setIsLogsOpen(false)}
+                  className="rounded-full"
+                  title="Close"
+                >
+                  <span className="material-icons-outlined text-base">close</span>
+                </Button>
+              </div>
+            </div>
+
+            {/* Logs Content */}
+            <div
+              ref={logsContainerRef}
+              className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
+            >
+              {/* Loading indicator for older logs */}
+              {isLoadingMoreLogs && (
+                <div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
+                  <span className="material-icons-outlined text-sm animate-spin">sync</span>
+                  <span>Loading older logs...</span>
+                </div>
+              )}
+              {/* Load more hint */}
+              {logsHasMore && !isLoadingMoreLogs && (
+                <div className="text-center py-2 text-muted-foreground text-xs">
+                  ↑ Scroll up to load older logs
+                </div>
+              )}
+              {filteredLogs.length > 0 ? (
+                filteredLogs.map((log, i) => (
+                  <div key={i} className="py-0.5 flex gap-2">
+                    <span className="text-muted-foreground shrink-0">
+                      {formatTimestamp(log.timestamp)}
+                    </span>
+                    <span className={`shrink-0 font-semibold ${
+                      log.level === 'ERROR' ? 'text-red-500' :
+                      log.level === 'WARNING' ? 'text-amber-500' :
+                      log.level === 'DEBUG' ? 'text-muted-foreground' :
+                      'text-foreground'
+                    }`}>
+                      [{log.level || 'LOG'}]
+                    </span>
+                    <span className="break-all">{log.message || ''}</span>
+                  </div>
+                ))
+              ) : (
+                <p className="text-muted-foreground text-center py-4">No logs available</p>
+              )}
+            </div>
+          </>
+        )}
+      </div>
+
+      {/* Floating Now Playing Button - draggable, snaps to left/center/right */}
+      {!isNowPlayingOpen && (
+        <button
+          ref={buttonRef}
+          onClick={() => {
+            // Only open if it wasn't a drag (to distinguish click from drag)
+            if (!wasDraggingRef.current) {
+              setIsNowPlayingOpen(true)
+            }
+            wasDraggingRef.current = false
+          }}
+          onMouseDown={(e) => {
+            e.preventDefault()
+            handleButtonDragStart(e.clientX, e.clientY)
+          }}
+          onTouchStart={(e) => {
+            const touch = e.touches[0]
+            handleButtonDragStart(touch.clientX, touch.clientY)
+          }}
+          onTouchMove={(e) => {
+            const touch = e.touches[0]
+            handleButtonDragMove(touch.clientX)
+          }}
+          onTouchEnd={() => {
+            handleButtonDragEnd()
+          }}
+          className={`fixed z-40 flex items-center gap-2 px-4 py-2 rounded-full bg-card border border-border shadow-lg select-none touch-none ${
+            isDraggingButton
+              ? 'cursor-grabbing scale-105 shadow-xl'
+              : 'cursor-grab transition-all duration-300 hover:shadow-xl hover:scale-105 active:scale-95'
+          }`}
+          style={getButtonPositionStyle()}
+          aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
+        >
+          <span className={`material-icons-outlined text-xl ${isCurrentlyPlaying ? 'text-primary' : 'text-muted-foreground'}`}>
+            {isCurrentlyPlaying ? 'play_circle' : 'stop_circle'}
+          </span>
+          <span className="text-sm font-medium">
+            {isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
+          </span>
+        </button>
+      )}
+
+      {/* Bottom Navigation */}
+      <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-card pb-safe">
+        <div className="max-w-5xl mx-auto grid grid-cols-5 h-16">
+          {navItems.map((item) => {
+            const isActive = location.pathname === item.path
+            return (
+              <Link
+                key={item.path}
+                to={item.path}
+                className={`relative flex flex-col items-center justify-center gap-1 transition-all duration-200 ${
+                  isActive
+                    ? 'text-primary'
+                    : 'text-muted-foreground hover:text-foreground active:scale-95'
+                }`}
+              >
+                {/* Active indicator pill */}
+                {isActive && (
+                  <span className="absolute -top-0.5 w-8 h-1 rounded-full bg-primary" />
+                )}
+                <span className={`text-xl ${isActive ? 'material-icons' : 'material-icons-outlined'}`}>
+                  {item.icon}
+                </span>
+                <span className="text-xs font-medium">{item.label}</span>
+              </Link>
+            )
+          })}
+        </div>
+      </nav>
+    </div>
+  )
+}

+ 56 - 0
frontend/src/components/ui/accordion.tsx

@@ -0,0 +1,56 @@
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Accordion = AccordionPrimitive.Root
+
+const AccordionItem = React.forwardRef<
+  React.ElementRef<typeof AccordionPrimitive.Item>,
+  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
+>(({ className, ...props }, ref) => (
+  <AccordionPrimitive.Item
+    ref={ref}
+    className={cn("border-b", className)}
+    {...props}
+  />
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+  React.ElementRef<typeof AccordionPrimitive.Trigger>,
+  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+  <AccordionPrimitive.Header className="flex">
+    <AccordionPrimitive.Trigger
+      ref={ref}
+      className={cn(
+        "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
+    </AccordionPrimitive.Trigger>
+  </AccordionPrimitive.Header>
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+  React.ElementRef<typeof AccordionPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+  <AccordionPrimitive.Content
+    ref={ref}
+    className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
+    {...props}
+  >
+    <div className={cn("pb-4 pt-0 px-2", className)}>{children}</div>
+  </AccordionPrimitive.Content>
+))
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

+ 59 - 0
frontend/src/components/ui/alert.tsx

@@ -0,0 +1,59 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+  "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
+  {
+    variants: {
+      variant: {
+        default: "bg-muted/50 text-foreground",
+        destructive:
+          "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+const Alert = React.forwardRef<
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
+>(({ className, variant, ...props }, ref) => (
+  <div
+    ref={ref}
+    role="alert"
+    className={cn(alertVariants({ variant }), className)}
+    {...props}
+  />
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+  HTMLParagraphElement,
+  React.HTMLAttributes<HTMLHeadingElement>
+>(({ className, ...props }, ref) => (
+  <h5
+    ref={ref}
+    className={cn("mb-1 font-medium leading-none tracking-tight", className)}
+    {...props}
+  />
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+  HTMLParagraphElement,
+  React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => (
+  <div
+    ref={ref}
+    className={cn("text-sm [&_p]:leading-relaxed", className)}
+    {...props}
+  />
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }

+ 36 - 0
frontend/src/components/ui/badge.tsx

@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+  "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+  {
+    variants: {
+      variant: {
+        default:
+          "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+        secondary:
+          "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+        destructive:
+          "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+        outline: "text-foreground",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+export interface BadgeProps
+  extends React.HTMLAttributes<HTMLDivElement>,
+    VariantProps<typeof badgeVariants> {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+  return (
+    <div className={cn(badgeVariants({ variant }), className)} {...props} />
+  )
+}
+
+export { Badge, badgeVariants }

+ 58 - 0
frontend/src/components/ui/button.tsx

@@ -0,0 +1,58 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+  {
+    variants: {
+      variant: {
+        default: "bg-card text-foreground border border-border shadow-sm hover:bg-accent",
+        primary: "bg-primary text-primary-foreground hover:bg-primary/90",
+        destructive:
+          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+        outline:
+          "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground hover:scale-105 hover:shadow-md transition-all duration-150",
+        ghost: "hover:bg-accent hover:text-accent-foreground",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default: "h-10 px-4 py-2",
+        sm: "h-9 px-3",
+        lg: "h-11 px-8",
+        icon: "h-10 w-10",
+        "icon-sm": "h-8 w-8",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  }
+)
+
+export interface ButtonProps
+  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+    VariantProps<typeof buttonVariants> {
+  asChild?: boolean
+}
+
+const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+  ({ className, variant, size, asChild = false, ...props }, ref) => {
+    const Comp = asChild ? Slot : "button"
+    return (
+      <Comp
+        className={cn(buttonVariants({ variant, size, className }))}
+        ref={ref}
+        {...props}
+      />
+    )
+  }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }

+ 79 - 0
frontend/src/components/ui/card.tsx

@@ -0,0 +1,79 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+  <div
+    ref={ref}
+    className={cn(
+      "rounded-lg border bg-card text-card-foreground shadow-sm",
+      className
+    )}
+    {...props}
+  />
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+  <div
+    ref={ref}
+    className={cn("flex flex-col space-y-1.5 p-6", className)}
+    {...props}
+  />
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+  <div
+    ref={ref}
+    className={cn(
+      "text-2xl font-semibold leading-none tracking-tight",
+      className
+    )}
+    {...props}
+  />
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+  <div
+    ref={ref}
+    className={cn("text-sm text-muted-foreground", className)}
+    {...props}
+  />
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+  <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+  <div
+    ref={ref}
+    className={cn("flex items-center p-6 pt-0", className)}
+    {...props}
+  />
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

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

@@ -0,0 +1,70 @@
+import { useState } from 'react'
+import { SketchPicker } from 'react-color'
+import type { ColorResult } from 'react-color'
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/popover'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+
+interface ColorPickerProps {
+  value: string
+  onChange: (color: string) => void
+  className?: string
+  presets?: string[]
+}
+
+const defaultPresets = [
+  '#ff0000', // Red
+  '#ff8000', // Orange
+  '#ffff00', // Yellow
+  '#00ff00', // Green
+  '#00ffff', // Cyan
+  '#0000ff', // Blue
+  '#ff00ff', // Magenta
+  '#ffffff', // White
+  '#2a9d8f', // Teal
+  '#e9c46a', // Sand
+  '#dc143c', // Crimson
+  '#000000', // Black
+]
+
+export function ColorPicker({
+  value,
+  onChange,
+  className,
+  presets = defaultPresets,
+}: ColorPickerProps) {
+  const [open, setOpen] = useState(false)
+
+  const handleChange = (color: ColorResult) => {
+    onChange(color.hex)
+  }
+
+  return (
+    <Popover open={open} onOpenChange={setOpen}>
+      <PopoverTrigger asChild>
+        <Button
+          variant="secondary"
+          className={cn(
+            'w-12 h-12 rounded-full p-1 border-2',
+            className
+          )}
+          style={{ backgroundColor: value }}
+        >
+          <span className="sr-only">Pick a color</span>
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent className="w-auto p-0" align="start">
+        <SketchPicker
+          color={value}
+          onChange={handleChange}
+          presetColors={presets}
+          disableAlpha={true}
+        />
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 120 - 0
frontend/src/components/ui/dialog.tsx

@@ -0,0 +1,120 @@
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Overlay>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Overlay
+    ref={ref}
+    className={cn(
+      "fixed inset-0 z-[100] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+      className
+    )}
+    {...props}
+  />
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+  <DialogPortal>
+    <DialogOverlay />
+    <DialogPrimitive.Content
+      ref={ref}
+      className={cn(
+        "fixed left-[50%] top-[50%] z-[100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
+        <X className="h-4 w-4" />
+        <span className="sr-only">Close</span>
+      </DialogPrimitive.Close>
+    </DialogPrimitive.Content>
+  </DialogPortal>
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col space-y-1.5 text-center sm:text-left",
+      className
+    )}
+    {...props}
+  />
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+      className
+    )}
+    {...props}
+  />
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Title>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Title
+    ref={ref}
+    className={cn(
+      "text-lg font-semibold leading-none tracking-tight",
+      className
+    )}
+    {...props}
+  />
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Description>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Description
+    ref={ref}
+    className={cn("text-sm text-muted-foreground", className)}
+    {...props}
+  />
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+  Dialog,
+  DialogPortal,
+  DialogOverlay,
+  DialogClose,
+  DialogTrigger,
+  DialogContent,
+  DialogHeader,
+  DialogFooter,
+  DialogTitle,
+  DialogDescription,
+}

+ 22 - 0
frontend/src/components/ui/input.tsx

@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
+  ({ className, type, ...props }, ref) => {
+    return (
+      <input
+        type={type}
+        className={cn(
+          "flex h-10 w-full rounded-full border border-input bg-background px-4 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+          className
+        )}
+        ref={ref}
+        {...props}
+      />
+    )
+  }
+)
+Input.displayName = "Input"
+
+export { Input }

+ 24 - 0
frontend/src/components/ui/label.tsx

@@ -0,0 +1,24 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+  "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+  React.ElementRef<typeof LabelPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
+    VariantProps<typeof labelVariants>
+>(({ className, ...props }, ref) => (
+  <LabelPrimitive.Root
+    ref={ref}
+    className={cn(labelVariants(), className)}
+    {...props}
+  />
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }

+ 29 - 0
frontend/src/components/ui/popover.tsx

@@ -0,0 +1,29 @@
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+const Popover = PopoverPrimitive.Root
+
+const PopoverTrigger = PopoverPrimitive.Trigger
+
+const PopoverContent = React.forwardRef<
+  React.ElementRef<typeof PopoverPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+  <PopoverPrimitive.Portal>
+    <PopoverPrimitive.Content
+      ref={ref}
+      align={align}
+      sideOffset={sideOffset}
+      className={cn(
+        "z-[100] w-72 rounded-2xl border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
+        className
+      )}
+      {...props}
+    />
+  </PopoverPrimitive.Portal>
+))
+PopoverContent.displayName = PopoverPrimitive.Content.displayName
+
+export { Popover, PopoverTrigger, PopoverContent }

+ 26 - 0
frontend/src/components/ui/progress.tsx

@@ -0,0 +1,26 @@
+import * as React from "react"
+import * as ProgressPrimitive from "@radix-ui/react-progress"
+
+import { cn } from "@/lib/utils"
+
+const Progress = React.forwardRef<
+  React.ElementRef<typeof ProgressPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
+>(({ className, value, ...props }, ref) => (
+  <ProgressPrimitive.Root
+    ref={ref}
+    className={cn(
+      "relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
+      className
+    )}
+    {...props}
+  >
+    <ProgressPrimitive.Indicator
+      className="h-full w-full flex-1 bg-primary transition-all"
+      style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
+    />
+  </ProgressPrimitive.Root>
+))
+Progress.displayName = ProgressPrimitive.Root.displayName
+
+export { Progress }

+ 44 - 0
frontend/src/components/ui/radio-group.tsx

@@ -0,0 +1,44 @@
+"use client"
+
+import * as React from "react"
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
+import { Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const RadioGroup = React.forwardRef<
+  React.ElementRef<typeof RadioGroupPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
+>(({ className, ...props }, ref) => {
+  return (
+    <RadioGroupPrimitive.Root
+      className={cn("grid gap-2", className)}
+      {...props}
+      ref={ref}
+    />
+  )
+})
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
+
+const RadioGroupItem = React.forwardRef<
+  React.ElementRef<typeof RadioGroupPrimitive.Item>,
+  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
+>(({ className, ...props }, ref) => {
+  return (
+    <RadioGroupPrimitive.Item
+      ref={ref}
+      className={cn(
+        "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+        className
+      )}
+      {...props}
+    >
+      <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
+        <Circle className="h-2.5 w-2.5 fill-current text-current" />
+      </RadioGroupPrimitive.Indicator>
+    </RadioGroupPrimitive.Item>
+  )
+})
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
+
+export { RadioGroup, RadioGroupItem }

+ 125 - 0
frontend/src/components/ui/searchable-select.tsx

@@ -0,0 +1,125 @@
+import * as React from 'react'
+import { cn, fuzzyMatch } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/popover'
+
+interface SearchableSelectOption {
+  value: string
+  label: string
+}
+
+interface SearchableSelectProps {
+  value?: string
+  onValueChange: (value: string) => void
+  options: SearchableSelectOption[]
+  placeholder?: string
+  searchPlaceholder?: string
+  emptyMessage?: string
+  className?: string
+  disabled?: boolean
+}
+
+export function SearchableSelect({
+  value,
+  onValueChange,
+  options,
+  placeholder = 'Select...',
+  searchPlaceholder = 'Search...',
+  emptyMessage = 'No results found',
+  className,
+  disabled,
+}: SearchableSelectProps) {
+  const [open, setOpen] = React.useState(false)
+  const [search, setSearch] = React.useState('')
+
+  // Find the selected option's label
+  const selectedOption = options.find((opt) => opt.value === value)
+
+  // Filter options based on search (fuzzy matching: spaces, underscores, hyphens are equivalent)
+  const filteredOptions = React.useMemo(() => {
+    if (!search) return options
+    return options.filter(
+      (opt) => fuzzyMatch(opt.label, search) || fuzzyMatch(opt.value, search)
+    )
+  }, [options, search])
+
+  const handleSelect = (selectedValue: string) => {
+    onValueChange(selectedValue)
+    setOpen(false)
+    setSearch('')
+  }
+
+  return (
+    <Popover open={open} onOpenChange={setOpen}>
+      <PopoverTrigger asChild>
+        <Button
+          variant="secondary"
+          role="combobox"
+          aria-expanded={open}
+          disabled={disabled}
+          className={cn(
+            'w-full justify-between font-normal',
+            !value && 'text-muted-foreground',
+            className
+          )}
+        >
+          <span className="truncate">
+            {selectedOption?.label || placeholder}
+          </span>
+          <span className="material-icons text-base ml-2 shrink-0 opacity-50">
+            unfold_more
+          </span>
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
+        <div className="flex flex-col">
+          {/* Search input */}
+          <div className="p-2 border-b">
+            <Input
+              placeholder={searchPlaceholder}
+              value={search}
+              onChange={(e) => setSearch(e.target.value)}
+              className="h-8"
+              autoFocus
+            />
+          </div>
+          {/* Options list */}
+          <div className="max-h-[200px] overflow-y-auto">
+            {filteredOptions.length === 0 ? (
+              <div className="py-6 text-center text-sm text-muted-foreground">
+                {emptyMessage}
+              </div>
+            ) : (
+              filteredOptions.map((option) => (
+                <button
+                  key={option.value}
+                  type="button"
+                  className={cn(
+                    'w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground flex items-center gap-2',
+                    value === option.value && 'bg-accent'
+                  )}
+                  onClick={() => handleSelect(option.value)}
+                >
+                  <span
+                    className={cn(
+                      'material-icons text-base',
+                      value === option.value ? 'opacity-100' : 'opacity-0'
+                    )}
+                  >
+                    check
+                  </span>
+                  <span className="truncate">{option.label}</span>
+                </button>
+              ))
+            )}
+          </div>
+        </div>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 158 - 0
frontend/src/components/ui/select.tsx

@@ -0,0 +1,158 @@
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown, ChevronUp } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Trigger>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+  <SelectPrimitive.Trigger
+    ref={ref}
+    className={cn(
+      "flex h-10 w-full items-center justify-between rounded-full border border-input bg-background px-4 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
+      className
+    )}
+    {...props}
+  >
+    {children}
+    <SelectPrimitive.Icon asChild>
+      <ChevronDown className="h-4 w-4 opacity-50" />
+    </SelectPrimitive.Icon>
+  </SelectPrimitive.Trigger>
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.ScrollUpButton
+    ref={ref}
+    className={cn(
+      "flex cursor-default items-center justify-center py-1",
+      className
+    )}
+    {...props}
+  >
+    <ChevronUp className="h-4 w-4" />
+  </SelectPrimitive.ScrollUpButton>
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.ScrollDownButton
+    ref={ref}
+    className={cn(
+      "flex cursor-default items-center justify-center py-1",
+      className
+    )}
+    {...props}
+  >
+    <ChevronDown className="h-4 w-4" />
+  </SelectPrimitive.ScrollDownButton>
+))
+SelectScrollDownButton.displayName =
+  SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
+>(({ className, children, position = "popper", ...props }, ref) => (
+  <SelectPrimitive.Portal>
+    <SelectPrimitive.Content
+      ref={ref}
+      className={cn(
+        "relative z-[9999] max-h-[min(var(--radix-select-content-available-height,256px),256px)] min-w-[8rem] overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
+        position === "popper" &&
+          "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+        className
+      )}
+      position={position}
+      {...props}
+    >
+      <SelectScrollUpButton />
+      <SelectPrimitive.Viewport
+        className={cn(
+          "p-1 max-h-[inherit] overflow-y-auto",
+          position === "popper" &&
+            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
+        )}
+      >
+        {children}
+      </SelectPrimitive.Viewport>
+      <SelectScrollDownButton />
+    </SelectPrimitive.Content>
+  </SelectPrimitive.Portal>
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Label>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.Label
+    ref={ref}
+    className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
+    {...props}
+  />
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Item>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
+>(({ className, children, ...props }, ref) => (
+  <SelectPrimitive.Item
+    ref={ref}
+    className={cn(
+      "relative flex w-full cursor-default select-none items-center rounded-xl py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+      className
+    )}
+    {...props}
+  >
+    <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+      <SelectPrimitive.ItemIndicator>
+        <Check className="h-4 w-4" />
+      </SelectPrimitive.ItemIndicator>
+    </span>
+
+    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+  </SelectPrimitive.Item>
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Separator>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.Separator
+    ref={ref}
+    className={cn("-mx-1 my-1 h-px bg-muted", className)}
+    {...props}
+  />
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+  Select,
+  SelectGroup,
+  SelectValue,
+  SelectTrigger,
+  SelectContent,
+  SelectLabel,
+  SelectItem,
+  SelectSeparator,
+  SelectScrollUpButton,
+  SelectScrollDownButton,
+}

+ 31 - 0
frontend/src/components/ui/separator.tsx

@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+
+import { cn } from "@/lib/utils"
+
+const Separator = React.forwardRef<
+  React.ElementRef<typeof SeparatorPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
+>(
+  (
+    { className, orientation = "horizontal", decorative = true, ...props },
+    ref
+  ) => (
+    <SeparatorPrimitive.Root
+      ref={ref}
+      decorative={decorative}
+      orientation={orientation}
+      className={cn(
+        "shrink-0 bg-border",
+        orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
+        className
+      )}
+      {...props}
+    />
+  )
+)
+Separator.displayName = SeparatorPrimitive.Root.displayName
+
+export { Separator }

+ 138 - 0
frontend/src/components/ui/sheet.tsx

@@ -0,0 +1,138 @@
+import * as React from "react"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Sheet = SheetPrimitive.Root
+
+const SheetTrigger = SheetPrimitive.Trigger
+
+const SheetClose = SheetPrimitive.Close
+
+const SheetPortal = SheetPrimitive.Portal
+
+const SheetOverlay = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Overlay>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+  <SheetPrimitive.Overlay
+    className={cn(
+      "fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+      className
+    )}
+    {...props}
+    ref={ref}
+  />
+))
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
+
+const sheetVariants = cva(
+  "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
+  {
+    variants: {
+      side: {
+        top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+        bottom:
+          "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+        left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+        right:
+          "inset-y-0 right-0 h-full w-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-md",
+      },
+    },
+    defaultVariants: {
+      side: "right",
+    },
+  }
+)
+
+interface SheetContentProps
+  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
+    VariantProps<typeof sheetVariants> {}
+
+const SheetContent = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Content>,
+  SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+  <SheetPortal>
+    <SheetOverlay />
+    <SheetPrimitive.Content
+      ref={ref}
+      className={cn(sheetVariants({ side }), className)}
+      {...props}
+    >
+      {children}
+      <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
+        <X className="h-4 w-4" />
+        <span className="sr-only">Close</span>
+      </SheetPrimitive.Close>
+    </SheetPrimitive.Content>
+  </SheetPortal>
+))
+SheetContent.displayName = SheetPrimitive.Content.displayName
+
+const SheetHeader = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col space-y-2 text-center sm:text-left",
+      className
+    )}
+    {...props}
+  />
+)
+SheetHeader.displayName = "SheetHeader"
+
+const SheetFooter = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+      className
+    )}
+    {...props}
+  />
+)
+SheetFooter.displayName = "SheetFooter"
+
+const SheetTitle = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Title>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
+>(({ className, ...props }, ref) => (
+  <SheetPrimitive.Title
+    ref={ref}
+    className={cn("text-lg font-semibold text-foreground", className)}
+    {...props}
+  />
+))
+SheetTitle.displayName = SheetPrimitive.Title.displayName
+
+const SheetDescription = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Description>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
+>(({ className, ...props }, ref) => (
+  <SheetPrimitive.Description
+    ref={ref}
+    className={cn("text-sm text-muted-foreground", className)}
+    {...props}
+  />
+))
+SheetDescription.displayName = SheetPrimitive.Description.displayName
+
+export {
+  Sheet,
+  SheetPortal,
+  SheetOverlay,
+  SheetTrigger,
+  SheetClose,
+  SheetContent,
+  SheetHeader,
+  SheetFooter,
+  SheetTitle,
+  SheetDescription,
+}

+ 26 - 0
frontend/src/components/ui/slider.tsx

@@ -0,0 +1,26 @@
+import * as React from "react"
+import * as SliderPrimitive from "@radix-ui/react-slider"
+
+import { cn } from "@/lib/utils"
+
+const Slider = React.forwardRef<
+  React.ElementRef<typeof SliderPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
+>(({ className, ...props }, ref) => (
+  <SliderPrimitive.Root
+    ref={ref}
+    className={cn(
+      "relative flex w-full touch-none select-none items-center",
+      className
+    )}
+    {...props}
+  >
+    <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
+      <SliderPrimitive.Range className="absolute h-full bg-primary" />
+    </SliderPrimitive.Track>
+    <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
+  </SliderPrimitive.Root>
+))
+Slider.displayName = SliderPrimitive.Root.displayName
+
+export { Slider }

+ 48 - 0
frontend/src/components/ui/sonner.tsx

@@ -0,0 +1,48 @@
+import { useEffect, useState } from "react"
+import { Toaster as Sonner } from "sonner"
+
+type ToasterProps = React.ComponentProps<typeof Sonner>
+
+const Toaster = ({ ...props }: ToasterProps) => {
+  const [theme, setTheme] = useState<"light" | "dark">("light")
+
+  useEffect(() => {
+    // Check initial theme
+    const isDark = document.documentElement.classList.contains("dark")
+    setTheme(isDark ? "dark" : "light")
+
+    // Watch for theme changes
+    const observer = new MutationObserver((mutations) => {
+      mutations.forEach((mutation) => {
+        if (mutation.attributeName === "class") {
+          const isDark = document.documentElement.classList.contains("dark")
+          setTheme(isDark ? "dark" : "light")
+        }
+      })
+    })
+
+    observer.observe(document.documentElement, { attributes: true })
+    return () => observer.disconnect()
+  }, [])
+
+  return (
+    <Sonner
+      theme={theme}
+      className="toaster group"
+      toastOptions={{
+        classNames: {
+          toast:
+            "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
+          description: "group-[.toast]:text-muted-foreground",
+          actionButton:
+            "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
+          cancelButton:
+            "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
+        },
+      }}
+      {...props}
+    />
+  )
+}
+
+export { Toaster }

+ 29 - 0
frontend/src/components/ui/switch.tsx

@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as SwitchPrimitives from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+const Switch = React.forwardRef<
+  React.ElementRef<typeof SwitchPrimitives.Root>,
+  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
+>(({ className, ...props }, ref) => (
+  <SwitchPrimitives.Root
+    className={cn(
+      "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-secondary",
+      className
+    )}
+    {...props}
+    ref={ref}
+  >
+    <SwitchPrimitives.Thumb
+      className={cn(
+        "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
+      )}
+    />
+  </SwitchPrimitives.Root>
+))
+Switch.displayName = SwitchPrimitives.Root.displayName
+
+export { Switch }

+ 55 - 0
frontend/src/components/ui/tabs.tsx

@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+const Tabs = TabsPrimitive.Root
+
+const TabsList = React.forwardRef<
+  React.ElementRef<typeof TabsPrimitive.List>,
+  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
+>(({ className, ...props }, ref) => (
+  <TabsPrimitive.List
+    ref={ref}
+    className={cn(
+      "inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
+      className
+    )}
+    {...props}
+  />
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+const TabsTrigger = React.forwardRef<
+  React.ElementRef<typeof TabsPrimitive.Trigger>,
+  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
+>(({ className, ...props }, ref) => (
+  <TabsPrimitive.Trigger
+    ref={ref}
+    className={cn(
+      "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
+      className
+    )}
+    {...props}
+  />
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+const TabsContent = React.forwardRef<
+  React.ElementRef<typeof TabsPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
+>(({ className, ...props }, ref) => (
+  <TabsPrimitive.Content
+    ref={ref}
+    className={cn(
+      "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
+      className
+    )}
+    {...props}
+  />
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }

+ 28 - 0
frontend/src/components/ui/tooltip.tsx

@@ -0,0 +1,28 @@
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+const TooltipProvider = TooltipPrimitive.Provider
+
+const Tooltip = TooltipPrimitive.Root
+
+const TooltipTrigger = TooltipPrimitive.Trigger
+
+const TooltipContent = React.forwardRef<
+  React.ElementRef<typeof TooltipPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
+>(({ className, sideOffset = 4, ...props }, ref) => (
+  <TooltipPrimitive.Content
+    ref={ref}
+    sideOffset={sideOffset}
+    className={cn(
+      "z-[100] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
+      className
+    )}
+    {...props}
+  />
+))
+TooltipContent.displayName = TooltipPrimitive.Content.displayName
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

+ 470 - 0
frontend/src/contexts/TableContext.tsx

@@ -0,0 +1,470 @@
+/**
+ * TableContext - Multi-table state management
+ *
+ * Manages discovered tables, active table selection, and persistence.
+ * When the active table changes, the API client's base URL is updated
+ * and components can react to reconnect WebSockets.
+ */
+
+import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
+import { apiClient } from '@/lib/apiClient'
+
+export interface Table {
+  id: string
+  name: string
+  appName?: string // Application name from settings (e.g., "Dune Weaver")
+  url: string
+  host?: string
+  port?: number
+  version?: string
+  isOnline?: boolean
+  isCurrent?: boolean // True if this is the backend serving the frontend
+  customLogo?: string // Custom logo filename if set (e.g., "logo_abc123.png")
+}
+
+interface TableContextType {
+  // State
+  tables: Table[]
+  activeTable: Table | null
+  isDiscovering: boolean
+  lastDiscovery: Date | null
+
+  // Actions
+  setActiveTable: (table: Table) => void
+  discoverTables: () => Promise<void>
+  addTable: (url: string, name?: string) => Promise<Table | null>
+  removeTable: (id: string) => void
+  updateTableName: (id: string, name: string) => Promise<void>
+  refreshTableStatus: (table: Table) => Promise<boolean>
+}
+
+const TableContext = createContext<TableContextType | null>(null)
+
+const STORAGE_KEY = 'duneweaver_tables'
+const ACTIVE_TABLE_KEY = 'duneweaver_active_table'
+
+/**
+ * Normalize a URL to its origin for comparison purposes.
+ * This handles port normalization (e.g., :80 for HTTP is stripped).
+ * Returns the origin or the original string if parsing fails.
+ */
+function normalizeUrlOrigin(url: string): string {
+  try {
+    return new URL(url).origin
+  } catch {
+    return url
+  }
+}
+
+interface StoredTableData {
+  tables: Table[]
+  activeTableId: string | null
+}
+
+export function TableProvider({ children }: { children: React.ReactNode }) {
+  const [tables, setTables] = useState<Table[]>([])
+  const [activeTable, setActiveTableState] = useState<Table | null>(null)
+  const [isDiscovering, setIsDiscovering] = useState(false)
+  const [lastDiscovery, setLastDiscovery] = useState<Date | null>(null)
+  const initializedRef = useRef(false)
+  const restoredActiveIdRef = useRef<string | null>(null) // Track restored selection
+
+  // Load saved tables from localStorage on mount
+  useEffect(() => {
+    if (initializedRef.current) return
+    initializedRef.current = true
+
+    try {
+      const stored = localStorage.getItem(STORAGE_KEY)
+      const activeId = localStorage.getItem(ACTIVE_TABLE_KEY)
+
+      if (stored) {
+        const data: StoredTableData = JSON.parse(stored)
+        setTables(data.tables || [])
+
+        // Restore active table
+        if (activeId && data.tables) {
+          const active = data.tables.find(t => t.id === activeId)
+          if (active) {
+            restoredActiveIdRef.current = activeId // Mark that we restored a selection
+            setActiveTableState(active)
+            // Set base URL for remote tables (tables not on the current origin)
+            // Use normalized URL comparison to handle port differences (e.g., :80 vs no port)
+            // Note: apiClient pre-initializes from localStorage, but this ensures consistency
+            if (normalizeUrlOrigin(active.url) !== window.location.origin) {
+              apiClient.setBaseUrl(active.url)
+            }
+          }
+        }
+      }
+
+      // Always refresh to ensure current table is available and up-to-date
+      discoverTables()
+    } catch (e) {
+      console.error('Failed to load saved tables:', e)
+      discoverTables()
+    }
+  }, [])
+
+  // Save tables to localStorage when they change
+  useEffect(() => {
+    if (!initializedRef.current) return
+
+    try {
+      const data: StoredTableData = {
+        tables,
+        activeTableId: activeTable?.id || null,
+      }
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
+      if (activeTable) {
+        localStorage.setItem(ACTIVE_TABLE_KEY, activeTable.id)
+      } else {
+        localStorage.removeItem(ACTIVE_TABLE_KEY)
+      }
+    } catch (e) {
+      console.error('Failed to save tables:', e)
+    }
+  }, [tables, activeTable])
+
+  // Set active table - saves to localStorage and reloads page for clean state
+  const setActiveTable = useCallback((table: Table) => {
+    // Save to localStorage before reload
+    try {
+      const currentTables = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
+      const data: StoredTableData = {
+        tables: currentTables.tables || tables,
+        activeTableId: table.id,
+      }
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
+      localStorage.setItem(ACTIVE_TABLE_KEY, table.id)
+    } catch (e) {
+      console.error('Failed to save table selection:', e)
+    }
+
+    // Update API client base URL
+    // Use normalized URL comparison to handle port differences (e.g., :80 vs no port)
+    if (normalizeUrlOrigin(table.url) === window.location.origin) {
+      apiClient.setBaseUrl('')
+    } else {
+      apiClient.setBaseUrl(table.url)
+    }
+
+    // Reload page for clean state (WebSockets, caches, etc.)
+    window.location.reload()
+  }, [tables])
+
+  // Refresh tables - ensures current table is always available
+  const discoverTables = useCallback(async () => {
+    setIsDiscovering(true)
+
+    try {
+      // Fetch table info, settings, and known tables in parallel
+      const [infoResponse, settingsResponse, knownTablesResponse] = await Promise.all([
+        fetch('/api/table-info'),
+        fetch('/api/settings').catch(() => null),
+        fetch('/api/known-tables').catch(() => null),
+      ])
+
+      if (!infoResponse.ok) {
+        throw new Error('Failed to fetch table info')
+      }
+
+      const info = await infoResponse.json()
+      const settings = settingsResponse?.ok ? await settingsResponse.json() : null
+      const knownTablesData = knownTablesResponse?.ok ? await knownTablesResponse.json() : null
+      const knownTables: Array<{ id: string; name: string; url: string; host?: string; port?: number; version?: string }> = knownTablesData?.tables || []
+
+      const currentTable: Table = {
+        id: info.id,
+        name: info.name,
+        url: window.location.origin,
+        version: info.version,
+        isOnline: true,
+        isCurrent: true,
+        customLogo: settings?.app?.custom_logo || undefined,
+      }
+
+      // Merge current table with known tables from backend
+      setTables(() => {
+        // Start with current table
+        const merged: Table[] = [currentTable]
+
+        // Add known tables from backend (these are persisted remote tables)
+        knownTables.forEach(known => {
+          if (known.id !== currentTable.id) {
+            merged.push({
+              id: known.id,
+              name: known.name,
+              url: known.url,
+              host: known.host,
+              port: known.port,
+              version: known.version,
+              isOnline: false, // Will be updated by background refresh
+              isCurrent: false,
+            })
+          }
+        })
+
+        return merged
+      })
+
+      // If no active table AND no restored selection, select the current one
+      // Use ref to check restored selection because activeTable state may not be updated yet
+      if (!activeTable && !restoredActiveIdRef.current) {
+        // For initial selection of current table, just update state without reload
+        // Reload is only needed when switching between DIFFERENT tables
+        setActiveTableState(currentTable)
+        // Save to localStorage so it persists
+        try {
+          const data: StoredTableData = {
+            tables: [currentTable],
+            activeTableId: currentTable.id,
+          }
+          localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
+          localStorage.setItem(ACTIVE_TABLE_KEY, currentTable.id)
+        } catch (e) {
+          console.error('Failed to save initial table selection:', e)
+        }
+      } else if (activeTable?.isCurrent) {
+        // Update active table name if it changed on the backend
+        setActiveTableState(prev => prev ? { ...prev, name: currentTable.name } : null)
+      }
+      // Clear the restored ref after first discovery
+      restoredActiveIdRef.current = null
+
+      setLastDiscovery(new Date())
+
+      // Refresh remote tables in the background to get their customLogo
+      // Use setTimeout to not block the main discovery flow
+      setTimeout(() => {
+        setTables(currentTables => {
+          const remoteTables = currentTables.filter(t => !t.isCurrent)
+          remoteTables.forEach(async (table) => {
+            try {
+              const [infoResponse, settingsResponse] = await Promise.all([
+                fetch(`${table.url}/api/table-info`, { signal: AbortSignal.timeout(3000) }),
+                fetch(`${table.url}/api/settings`, { signal: AbortSignal.timeout(3000) }).catch(() => null),
+              ])
+              const isOnline = infoResponse.ok
+              const settings = settingsResponse?.ok ? await settingsResponse.json() : null
+              const customLogo = settings?.app?.custom_logo || undefined
+
+              setTables(prev =>
+                prev.map(t => (t.id === table.id ? { ...t, isOnline, customLogo } : t))
+              )
+            } catch {
+              setTables(prev =>
+                prev.map(t => (t.id === table.id ? { ...t, isOnline: false } : t))
+              )
+            }
+          })
+          return currentTables // Return unchanged for now, updates happen in the async callbacks
+        })
+      }, 100)
+    } catch (e) {
+      console.error('Table refresh failed:', e)
+    } finally {
+      setIsDiscovering(false)
+    }
+  }, [activeTable]) // Only depends on activeTable for checking if we need to update name
+
+  // Add a table manually by URL
+  const addTable = useCallback(async (url: string, name?: string): Promise<Table | null> => {
+    try {
+      // Normalize URL
+      const normalizedUrl = url.replace(/\/$/, '')
+
+      // Check if already exists
+      if (tables.find(t => t.url === normalizedUrl)) {
+        return null
+      }
+
+      // Fetch table info and settings in parallel
+      const [infoResponse, settingsResponse] = await Promise.all([
+        fetch(`${normalizedUrl}/api/table-info`),
+        fetch(`${normalizedUrl}/api/settings`).catch(() => null),
+      ])
+
+      if (!infoResponse.ok) {
+        throw new Error('Failed to fetch table info')
+      }
+
+      const info = await infoResponse.json()
+      const settings = settingsResponse?.ok ? await settingsResponse.json() : null
+
+      const newTable: Table = {
+        id: info.id,
+        name: name || info.name,
+        url: normalizedUrl,
+        version: info.version,
+        isOnline: true,
+        isCurrent: false,
+        customLogo: settings?.app?.custom_logo || undefined,
+      }
+
+      // Persist to backend
+      try {
+        const hostname = new URL(normalizedUrl).hostname
+        await fetch('/api/known-tables', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({
+            id: newTable.id,
+            name: newTable.name,
+            url: newTable.url,
+            host: hostname,
+            version: newTable.version,
+          }),
+        })
+      } catch (e) {
+        console.error('Failed to persist table to backend:', e)
+        // Continue anyway - table will still work for this session
+      }
+
+      setTables(prev => [...prev, newTable])
+      return newTable
+    } catch (e) {
+      console.error('Failed to add table:', e)
+      return null
+    }
+  }, [tables])
+
+  // Remove a table
+  const removeTable = useCallback(async (id: string) => {
+    // Remove from backend
+    try {
+      await fetch(`/api/known-tables/${id}`, { method: 'DELETE' })
+    } catch (e) {
+      console.error('Failed to remove table from backend:', e)
+      // Continue anyway - remove from local state
+    }
+
+    setTables(prev => prev.filter(t => t.id !== id))
+
+    // If removing active table, switch to another
+    if (activeTable?.id === id) {
+      const remaining = tables.filter(t => t.id !== id)
+      if (remaining.length > 0) {
+        setActiveTable(remaining[0])
+      } else {
+        setActiveTableState(null)
+        apiClient.setBaseUrl('')
+      }
+    }
+  }, [activeTable, tables, setActiveTable])
+
+  // Update table name (on the backend)
+  const updateTableName = useCallback(async (id: string, name: string) => {
+    const table = tables.find(t => t.id === id)
+    if (!table) return
+
+    try {
+      const baseUrl = table.isCurrent ? '' : table.url
+      const response = await fetch(`${baseUrl}/api/table-info`, {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ name }),
+      })
+
+      if (response.ok) {
+        // Also update the known table name in the current backend (for remote tables)
+        if (!table.isCurrent) {
+          try {
+            await fetch(`/api/known-tables/${id}`, {
+              method: 'PATCH',
+              headers: { 'Content-Type': 'application/json' },
+              body: JSON.stringify({ name }),
+            })
+          } catch (e) {
+            console.error('Failed to update known table name:', e)
+          }
+        }
+
+        setTables(prev =>
+          prev.map(t => (t.id === id ? { ...t, name } : t))
+        )
+
+        // Update active table if it's the one being renamed
+        if (activeTable?.id === id) {
+          setActiveTableState(prev => prev ? { ...prev, name } : null)
+        }
+      }
+    } catch (e) {
+      console.error('Failed to update table name:', e)
+    }
+  }, [tables, activeTable])
+
+  // Check if a table is online and update its info (including custom logo)
+  const refreshTableStatus = useCallback(async (table: Table): Promise<boolean> => {
+    try {
+      const baseUrl = table.isCurrent ? '' : table.url
+
+      // Fetch table info and settings in parallel
+      const [infoResponse, settingsResponse] = await Promise.all([
+        fetch(`${baseUrl}/api/table-info`, { signal: AbortSignal.timeout(3000) }),
+        fetch(`${baseUrl}/api/settings`, { signal: AbortSignal.timeout(3000) }).catch(() => null),
+      ])
+
+      const isOnline = infoResponse.ok
+      const settings = settingsResponse?.ok ? await settingsResponse.json() : null
+      const customLogo = settings?.app?.custom_logo || undefined
+
+      setTables(prev =>
+        prev.map(t => (t.id === table.id ? { ...t, isOnline, customLogo } : t))
+      )
+
+      return isOnline
+    } catch {
+      setTables(prev =>
+        prev.map(t => (t.id === table.id ? { ...t, isOnline: false } : t))
+      )
+      return false
+    }
+  }, [])
+
+  return (
+    <TableContext.Provider
+      value={{
+        tables,
+        activeTable,
+        isDiscovering,
+        lastDiscovery,
+        setActiveTable,
+        discoverTables,
+        addTable,
+        removeTable,
+        updateTableName,
+        refreshTableStatus,
+      }}
+    >
+      {children}
+    </TableContext.Provider>
+  )
+}
+
+export function useTable() {
+  const context = useContext(TableContext)
+  if (!context) {
+    throw new Error('useTable must be used within a TableProvider')
+  }
+  return context
+}
+
+// Hook for subscribing to active table changes (for WebSocket reconnection)
+export function useActiveTableChange(callback: (table: Table | null) => void) {
+  const { activeTable } = useTable()
+  const callbackRef = useRef(callback)
+  const prevTableRef = useRef<Table | null>(null)
+
+  callbackRef.current = callback
+
+  useEffect(() => {
+    // Only call on actual changes, not initial render
+    if (prevTableRef.current !== null || activeTable !== null) {
+      if (prevTableRef.current?.id !== activeTable?.id) {
+        callbackRef.current(activeTable)
+      }
+    }
+    prevTableRef.current = activeTable
+  }, [activeTable])
+}

+ 44 - 0
frontend/src/hooks/useBackendConnection.ts

@@ -0,0 +1,44 @@
+import { useEffect, useRef } from 'react'
+
+/**
+ * Hook that triggers a callback when the backend connection is established.
+ * Useful for refetching data after the app reconnects to the backend.
+ */
+export function useOnBackendConnected(callback: () => void) {
+  const callbackRef = useRef(callback)
+
+  // Keep callback ref up to date
+  useEffect(() => {
+    callbackRef.current = callback
+  }, [callback])
+
+  useEffect(() => {
+    const handleConnected = () => {
+      callbackRef.current()
+    }
+
+    window.addEventListener('backend-connected', handleConnected)
+    return () => {
+      window.removeEventListener('backend-connected', handleConnected)
+    }
+  }, [])
+}
+
+/**
+ * Hook that returns a function wrapped to also be called on backend reconnection.
+ * Automatically calls the function on mount and whenever backend reconnects.
+ */
+export function useFetchOnConnect<T extends (...args: unknown[]) => unknown>(fetchFn: T): T {
+  const fetchRef = useRef(fetchFn)
+
+  useEffect(() => {
+    fetchRef.current = fetchFn
+  }, [fetchFn])
+
+  // Call on backend connect
+  useOnBackendConnected(() => {
+    fetchRef.current()
+  })
+
+  return fetchFn
+}

+ 252 - 0
frontend/src/index.css

@@ -0,0 +1,252 @@
+@import "tailwindcss";
+
+/* Import fonts */
+@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Noto+Sans:wght@400;500;600;700&display=swap');
+
+/* Material Icons */
+@import url('https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined');
+
+/* Theme configuration using CSS variables */
+@theme {
+  --color-border: hsl(214.3 31.8% 91.4%);
+  --color-input: hsl(214.3 31.8% 91.4%);
+  --color-ring: hsl(207 90% 50%);
+  --color-background: hsl(220 14% 98%);
+  --color-foreground: hsl(222.2 84% 4.9%);
+
+  --color-primary: hsl(207 90% 50%);
+  --color-primary-foreground: hsl(210 40% 98%);
+
+  --color-secondary: hsl(210 40% 96.1%);
+  --color-secondary-foreground: hsl(222.2 47.4% 11.2%);
+
+  --color-muted: hsl(220 14% 92%);
+  --color-muted-foreground: hsl(215.4 16.3% 46.9%);
+
+  --color-accent: hsl(210 40% 96.1%);
+  --color-accent-foreground: hsl(222.2 47.4% 11.2%);
+
+  --color-destructive: hsl(0 84.2% 60.2%);
+  --color-destructive-foreground: hsl(210 40% 98%);
+
+  --color-card: hsl(0 0% 100%);
+  --color-card-foreground: hsl(222.2 84% 4.9%);
+
+  --color-popover: hsl(0 0% 100%);
+  --color-popover-foreground: hsl(222.2 84% 4.9%);
+
+  --radius: 0.5rem;
+  --radius-lg: 0.75rem;
+  --radius-md: calc(var(--radius) - 2px);
+  --radius-sm: calc(var(--radius) - 4px);
+
+  --font-family-sans: 'Plus Jakarta Sans', 'Noto Sans', sans-serif;
+}
+
+/* Dark mode */
+.dark {
+  color-scheme: dark;
+  --color-border: hsl(0 0% 32%);
+  --color-input: hsl(0 0% 32%);
+  --color-ring: hsl(207 90% 50%);
+  --color-background: hsl(0 0% 10%);
+  --color-foreground: hsl(210 40% 98%);
+
+  --color-primary: hsl(207 90% 50%);
+  --color-primary-foreground: hsl(210 40% 98%);
+
+  --color-secondary: hsl(0 0% 25%);
+  --color-secondary-foreground: hsl(210 40% 98%);
+
+  --color-muted: hsl(0 0% 25%);
+  --color-muted-foreground: hsl(215 20.2% 65.1%);
+
+  --color-accent: hsl(0 0% 25%);
+  --color-accent-foreground: hsl(210 40% 98%);
+
+  --color-destructive: hsl(0 62.8% 50.6%);
+  --color-destructive-foreground: hsl(210 40% 98%);
+
+  --color-card: hsl(0 0% 18%);
+  --color-card-foreground: hsl(210 40% 98%);
+
+  --color-input-background: hsl(0 0% 28%);
+
+  --color-popover: hsl(0 0% 15%);
+  --color-popover-foreground: hsl(210 40% 98%);
+}
+
+/* Base styles */
+* {
+  border-color: var(--color-border);
+}
+
+/* Form label spacing - adds margin below labels for better readability */
+label {
+  display: block;
+  margin-bottom: 0.5rem;
+}
+
+/* Form input background - lighter than card for visibility */
+.dark input:not([type="checkbox"]):not([type="radio"]),
+.dark select,
+.dark textarea,
+.dark [role="combobox"] {
+  background-color: var(--color-input-background);
+}
+
+body {
+  background-color: var(--color-background);
+  color: var(--color-foreground);
+  font-family: var(--font-family-sans);
+  margin: 0;
+  min-height: 100dvh; /* Use dynamic viewport height for mobile */
+}
+
+/* Push Sonner toasts below the Dynamic Island / notch in PWA mode */
+[data-sonner-toaster][data-y-position="top"] {
+  top: env(safe-area-inset-top, 0px) !important;
+}
+
+/* Safe area utilities for iOS notch/Dynamic Island/home indicator */
+.pt-safe {
+  padding-top: env(safe-area-inset-top, 0px);
+}
+
+.pb-safe {
+  padding-bottom: env(safe-area-inset-bottom, 0px);
+}
+
+.mt-safe {
+  margin-top: env(safe-area-inset-top, 0px);
+}
+
+.mb-safe {
+  margin-bottom: env(safe-area-inset-bottom, 0px);
+}
+
+/* Material Icons */
+.material-icons,
+.material-icons-outlined {
+  font-family: 'Material Icons';
+  font-weight: normal;
+  font-style: normal;
+  font-size: 24px;
+  line-height: 1;
+  letter-spacing: normal;
+  text-transform: none;
+  display: inline-block;
+  white-space: nowrap;
+  word-wrap: normal;
+  direction: ltr;
+  -webkit-font-feature-settings: 'liga';
+  -webkit-font-smoothing: antialiased;
+}
+
+.material-icons-outlined {
+  font-family: 'Material Icons Outlined';
+}
+
+/* Invert pattern previews in dark mode */
+.dark .pattern-preview {
+  filter: invert(1);
+}
+
+/* Marquee animation for scrolling text */
+@keyframes marquee {
+  0%, 10% {
+    transform: translateX(calc(-100% + 100cqw));
+  }
+  45%, 55% {
+    transform: translateX(0);
+  }
+  90%, 100% {
+    transform: translateX(calc(-100% + 100cqw));
+  }
+}
+
+.marquee-container {
+  container-type: inline-size;
+  overflow: hidden;
+}
+
+.animate-marquee {
+  display: inline-block;
+}
+
+/* Marquee animation only on mobile */
+@media (max-width: 767px) {
+  .animate-marquee {
+    animation: marquee 8s ease-in-out infinite;
+    animation-play-state: running;
+  }
+
+  .animate-marquee:hover {
+    animation-play-state: paused;
+  }
+}
+
+/* Now Playing Bar heights - responsive for mobile vs desktop */
+[data-now-playing-bar="collapsed"] {
+  height: 232px;
+}
+
+[data-now-playing-bar="expanded"] {
+  height: 50vh;
+}
+
+@media (max-width: 767px) {
+  [data-now-playing-bar="collapsed"] {
+    height: 288px;
+  }
+
+  [data-now-playing-bar="expanded"] {
+    height: calc(100vh - 64px - 64px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
+  }
+}
+
+/* Smooth fade in for lazy-loaded content */
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+    transform: scale(0.98);
+  }
+  to {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+
+.animate-fade-in {
+  animation: fadeIn 0.2s ease-out forwards;
+}
+
+/* Subtle pulse for connection status */
+@keyframes subtle-pulse {
+  0%, 100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.6;
+  }
+}
+
+.animate-subtle-pulse {
+  animation: subtle-pulse 2s ease-in-out infinite;
+}
+
+/* Slide in from right for panels */
+@keyframes slideInRight {
+  from {
+    transform: translateX(100%);
+    opacity: 0;
+  }
+  to {
+    transform: translateX(0);
+    opacity: 1;
+  }
+}
+
+.animate-slide-in-right {
+  animation: slideInRight 0.3s ease-out forwards;
+}

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

@@ -0,0 +1,246 @@
+/**
+ * Centralized API client for multi-table support.
+ *
+ * This module provides a single point for all API and WebSocket communications,
+ * allowing easy switching between different backend instances.
+ */
+
+type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'
+
+interface RequestOptions {
+  method?: RequestMethod
+  body?: unknown
+  headers?: Record<string, string>
+  signal?: AbortSignal
+}
+
+class ApiClient {
+  private _baseUrl: string = ''
+  private _listeners: Set<(url: string) => void> = new Set()
+
+  /**
+   * Get the current base URL.
+   * Empty string means use the current origin (relative URLs).
+   */
+  get baseUrl(): string {
+    return this._baseUrl
+  }
+
+  /**
+   * Set the base URL for all API requests.
+   * @param url - The base URL (e.g., 'http://192.168.1.100:8080') or empty for relative URLs
+   */
+  setBaseUrl(url: string): void {
+    // Remove trailing slash
+    const newUrl = url.replace(/\/$/, '')
+    // Only notify if the URL actually changed
+    if (newUrl === this._baseUrl) return
+    this._baseUrl = newUrl
+    // Notify listeners
+    this._listeners.forEach(listener => listener(this._baseUrl))
+  }
+
+  /**
+   * Subscribe to base URL changes.
+   * @param listener - Callback when base URL changes
+   * @returns Unsubscribe function
+   */
+  onBaseUrlChange(listener: (url: string) => void): () => void {
+    this._listeners.add(listener)
+    return () => this._listeners.delete(listener)
+  }
+
+  /**
+   * Build full URL for an endpoint.
+   */
+  private buildUrl(endpoint: string): string {
+    // Ensure endpoint starts with /
+    const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
+    return `${this._baseUrl}${path}`
+  }
+
+  /**
+   * Make an HTTP request.
+   */
+  async request<T = unknown>(endpoint: string, options: RequestOptions = {}): Promise<T> {
+    const { method = 'GET', body, headers = {}, signal } = options
+
+    const url = this.buildUrl(endpoint)
+
+    const fetchOptions: RequestInit = {
+      method,
+      headers: {
+        'Content-Type': 'application/json',
+        ...headers,
+      },
+      signal,
+    }
+
+    if (body !== undefined) {
+      fetchOptions.body = JSON.stringify(body)
+    }
+
+    const response = await fetch(url, fetchOptions)
+
+    if (!response.ok) {
+      const errorText = await response.text()
+      throw new Error(`HTTP ${response.status}: ${errorText}`)
+    }
+
+    // Handle empty responses
+    const text = await response.text()
+    if (!text) {
+      return {} as T
+    }
+
+    return JSON.parse(text) as T
+  }
+
+  /**
+   * GET request
+   */
+  async get<T = unknown>(endpoint: string, signal?: AbortSignal): Promise<T> {
+    return this.request<T>(endpoint, { method: 'GET', signal })
+  }
+
+  /**
+   * POST request
+   */
+  async post<T = unknown>(endpoint: string, body?: unknown, signal?: AbortSignal): Promise<T> {
+    return this.request<T>(endpoint, { method: 'POST', body, signal })
+  }
+
+  /**
+   * PATCH request
+   */
+  async patch<T = unknown>(endpoint: string, body?: unknown, signal?: AbortSignal): Promise<T> {
+    return this.request<T>(endpoint, { method: 'PATCH', body, signal })
+  }
+
+  /**
+   * DELETE request
+   */
+  async delete<T = unknown>(endpoint: string, body?: unknown, signal?: AbortSignal): Promise<T> {
+    return this.request<T>(endpoint, { method: 'DELETE', body, signal })
+  }
+
+  /**
+   * Build WebSocket URL for an endpoint.
+   */
+  getWebSocketUrl(endpoint: string): string {
+    // Ensure endpoint starts with /
+    const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
+
+    if (this._baseUrl) {
+      // Parse the base URL to get host
+      const url = new URL(this._baseUrl)
+      const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+      return `${protocol}//${url.host}${path}`
+    } else {
+      // Use current page's host for relative URLs
+      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+      // In development mode (Vite on port 5173), connect directly to backend (port 8080)
+      // This bypasses Vite's WebSocket proxy which has issues with Safari mobile
+      const host = window.location.hostname
+      const port = import.meta.env.DEV ? '8080' : window.location.port
+      const portSuffix = port ? `:${port}` : ''
+      return `${protocol}//${host}${portSuffix}${path}`
+    }
+  }
+
+  /**
+   * Build URL for static assets (like pattern previews).
+   */
+  getAssetUrl(path: string): string {
+    // Ensure path starts with /
+    const assetPath = path.startsWith('/') ? path : `/${path}`
+    return `${this._baseUrl}${assetPath}`
+  }
+
+  /**
+   * Upload a file via POST.
+   */
+  async uploadFile(
+    endpoint: string,
+    file: File,
+    fieldName: string = 'file',
+    additionalData?: Record<string, string>
+  ): Promise<unknown> {
+    const url = this.buildUrl(endpoint)
+    const formData = new FormData()
+    formData.append(fieldName, file)
+
+    if (additionalData) {
+      Object.entries(additionalData).forEach(([key, value]) => {
+        formData.append(key, value)
+      })
+    }
+
+    const response = await fetch(url, {
+      method: 'POST',
+      body: formData,
+      // Don't set Content-Type - let browser set it with boundary
+    })
+
+    if (!response.ok) {
+      const errorText = await response.text()
+      throw new Error(`HTTP ${response.status}: ${errorText}`)
+    }
+
+    const text = await response.text()
+    if (!text) {
+      return {}
+    }
+
+    return JSON.parse(text)
+  }
+}
+
+// Export singleton instance
+export const apiClient = new ApiClient()
+
+// Pre-initialize base URL from localStorage to avoid race conditions.
+// This runs synchronously at module load time, before React renders,
+// ensuring WebSocket connections use the correct URL from the start.
+function initializeBaseUrlFromStorage(): void {
+  try {
+    const STORAGE_KEY = 'duneweaver_tables'
+    const ACTIVE_TABLE_KEY = 'duneweaver_active_table'
+
+    const stored = localStorage.getItem(STORAGE_KEY)
+    const activeId = localStorage.getItem(ACTIVE_TABLE_KEY)
+
+    if (!stored || !activeId) return
+
+    const data = JSON.parse(stored)
+    const tables = data.tables || []
+    const active = tables.find((t: { id: string }) => t.id === activeId)
+
+    if (!active?.url) return
+
+    // Normalize URL for comparison (handles port differences like :80)
+    const normalizeOrigin = (url: string): string => {
+      try {
+        return new URL(url).origin
+      } catch {
+        return url
+      }
+    }
+
+    const normalizedActiveUrl = normalizeOrigin(active.url)
+    const currentOrigin = window.location.origin
+
+    // Only set base URL for remote tables (different origin)
+    if (normalizedActiveUrl !== currentOrigin) {
+      apiClient.setBaseUrl(active.url)
+    }
+  } catch {
+    // Silently fail - TableContext will handle initialization as fallback
+  }
+}
+
+// Run initialization immediately at module load
+initializeBaseUrlFromStorage()
+
+// Export class for testing
+export { ApiClient }

+ 380 - 0
frontend/src/lib/previewCache.ts

@@ -0,0 +1,380 @@
+// IndexedDB cache for preview images - matches original implementation
+import { apiClient } from './apiClient'
+
+const PREVIEW_CACHE_DB_NAME = 'dune_weaver_previews'
+const PREVIEW_CACHE_DB_VERSION = 1
+const PREVIEW_CACHE_STORE_NAME = 'previews'
+const MAX_CACHE_SIZE_MB = 200
+const MAX_CACHE_SIZE_BYTES = MAX_CACHE_SIZE_MB * 1024 * 1024
+
+interface PreviewData {
+  image_data: string
+  first_coordinate: { x: number; y: number } | null
+  last_coordinate: { x: number; y: number } | null
+  error?: string
+}
+
+interface CacheEntry {
+  pattern: string
+  data: PreviewData
+  size: number
+  lastAccessed: number
+  created: number
+}
+
+let previewCacheDB: IDBDatabase | null = null
+
+// In-memory cache for faster access during session
+const memoryCache = new Map<string, PreviewData>()
+const MAX_MEMORY_CACHE_SIZE = 100
+
+// Initialize IndexedDB
+export async function initPreviewCacheDB(): Promise<IDBDatabase> {
+  if (previewCacheDB) return previewCacheDB
+
+  return new Promise((resolve, reject) => {
+    const request = indexedDB.open(PREVIEW_CACHE_DB_NAME, PREVIEW_CACHE_DB_VERSION)
+
+    request.onerror = () => {
+      console.error('Failed to open preview cache database')
+      reject(request.error)
+    }
+
+    request.onsuccess = () => {
+      previewCacheDB = request.result
+      console.debug('Preview cache database opened successfully')
+      resolve(previewCacheDB)
+    }
+
+    request.onupgradeneeded = (event) => {
+      const db = (event.target as IDBOpenDBRequest).result
+
+      // Create object store for preview cache
+      const store = db.createObjectStore(PREVIEW_CACHE_STORE_NAME, { keyPath: 'pattern' })
+      store.createIndex('lastAccessed', 'lastAccessed', { unique: false })
+      store.createIndex('size', 'size', { unique: false })
+
+      console.debug('Preview cache database schema created')
+    }
+  })
+}
+
+// Get preview from cache (memory first, then IndexedDB)
+export async function getPreviewFromCache(pattern: string): Promise<PreviewData | null> {
+  // Check memory cache first
+  if (memoryCache.has(pattern)) {
+    return memoryCache.get(pattern)!
+  }
+
+  // Check IndexedDB
+  try {
+    if (!previewCacheDB) await initPreviewCacheDB()
+
+    const transaction = previewCacheDB!.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite')
+    const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME)
+
+    return new Promise((resolve, reject) => {
+      const request = store.get(pattern)
+
+      request.onsuccess = () => {
+        const result = request.result as CacheEntry | undefined
+        if (result) {
+          // Update last accessed time
+          result.lastAccessed = Date.now()
+          store.put(result)
+
+          // Add to memory cache
+          addToMemoryCache(pattern, result.data)
+
+          resolve(result.data)
+        } else {
+          resolve(null)
+        }
+      }
+
+      request.onerror = () => reject(request.error)
+    })
+  } catch (error) {
+    console.warn(`Error getting preview from cache: ${error}`)
+    return null
+  }
+}
+
+// Add to memory cache with size limit
+function addToMemoryCache(pattern: string, data: PreviewData) {
+  if (memoryCache.size >= MAX_MEMORY_CACHE_SIZE) {
+    // Remove oldest entry (first key)
+    const oldestKey = memoryCache.keys().next().value
+    if (oldestKey) {
+      memoryCache.delete(oldestKey)
+    }
+  }
+  memoryCache.set(pattern, data)
+}
+
+// Save preview to IndexedDB cache with size management
+export async function savePreviewToCache(pattern: string, previewData: PreviewData): Promise<void> {
+  try {
+    if (!previewData || !previewData.image_data) {
+      console.warn(`Invalid preview data for ${pattern}, skipping cache save`)
+      return
+    }
+
+    if (!previewCacheDB) await initPreviewCacheDB()
+
+    // Add to memory cache
+    addToMemoryCache(pattern, previewData)
+
+    // Calculate size from base64 data
+    const size = Math.ceil((previewData.image_data.length * 3) / 4)
+
+    // Check if we need to free up space
+    await managePreviewCacheSize(size)
+
+    const cacheEntry: CacheEntry = {
+      pattern: pattern,
+      data: previewData,
+      size: size,
+      lastAccessed: Date.now(),
+      created: Date.now(),
+    }
+
+    const transaction = previewCacheDB!.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite')
+    const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME)
+
+    return new Promise((resolve, reject) => {
+      const request = store.put(cacheEntry)
+
+      request.onsuccess = () => {
+        console.debug(`Preview cached for ${pattern} (${(size / 1024).toFixed(1)}KB)`)
+        resolve()
+      }
+
+      request.onerror = () => reject(request.error)
+    })
+  } catch (error) {
+    console.warn(`Error saving preview to cache: ${error}`)
+  }
+}
+
+// Get current cache size
+async function getPreviewCacheSize(): Promise<number> {
+  try {
+    if (!previewCacheDB) return 0
+
+    const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readonly')
+    const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME)
+
+    return new Promise((resolve, reject) => {
+      const request = store.getAll()
+
+      request.onsuccess = () => {
+        const totalSize = request.result.reduce(
+          (sum: number, entry: CacheEntry) => sum + (entry.size || 0),
+          0
+        )
+        resolve(totalSize)
+      }
+
+      request.onerror = () => reject(request.error)
+    })
+  } catch (error) {
+    console.warn(`Error getting cache size: ${error}`)
+    return 0
+  }
+}
+
+// Manage cache size by removing least recently used items (LRU eviction)
+async function managePreviewCacheSize(newItemSize: number): Promise<void> {
+  try {
+    const currentSize = await getPreviewCacheSize()
+
+    if (currentSize + newItemSize <= MAX_CACHE_SIZE_BYTES) {
+      return // No cleanup needed
+    }
+
+    console.debug(
+      `Cache size would exceed limit (${((currentSize + newItemSize) / 1024 / 1024).toFixed(1)}MB), cleaning up...`
+    )
+
+    const transaction = previewCacheDB!.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite')
+    const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME)
+    const index = store.index('lastAccessed')
+
+    // Get all entries sorted by last accessed (oldest first)
+    const entries = await new Promise<CacheEntry[]>((resolve, reject) => {
+      const request = index.getAll()
+      request.onsuccess = () => resolve(request.result)
+      request.onerror = () => reject(request.error)
+    })
+
+    // Sort by last accessed time (oldest first)
+    entries.sort((a, b) => a.lastAccessed - b.lastAccessed)
+
+    let freedSpace = 0
+    const targetSpace = newItemSize + MAX_CACHE_SIZE_BYTES * 0.1 // Free 10% extra buffer
+
+    for (const entry of entries) {
+      if (freedSpace >= targetSpace) break
+
+      await new Promise<void>((resolve, reject) => {
+        const deleteRequest = store.delete(entry.pattern)
+        deleteRequest.onsuccess = () => {
+          freedSpace += entry.size
+          // Also remove from memory cache
+          memoryCache.delete(entry.pattern)
+          console.debug(
+            `Evicted cached preview for ${entry.pattern} (${(entry.size / 1024).toFixed(1)}KB)`
+          )
+          resolve()
+        }
+        deleteRequest.onerror = () => reject(deleteRequest.error)
+      })
+    }
+
+    console.debug(`Freed ${(freedSpace / 1024 / 1024).toFixed(1)}MB from preview cache`)
+  } catch (error) {
+    console.warn(`Error managing cache size: ${error}`)
+  }
+}
+
+// Clear a specific pattern from cache
+export async function clearPatternFromCache(pattern: string): Promise<void> {
+  try {
+    // Remove from memory cache
+    memoryCache.delete(pattern)
+
+    if (!previewCacheDB) await initPreviewCacheDB()
+
+    const transaction = previewCacheDB!.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite')
+    const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME)
+
+    await new Promise<void>((resolve, reject) => {
+      const deleteRequest = store.delete(pattern)
+      deleteRequest.onsuccess = () => {
+        console.debug(`Cleared ${pattern} from cache`)
+        resolve()
+      }
+      deleteRequest.onerror = () => reject(deleteRequest.error)
+    })
+  } catch (error) {
+    console.warn(`Error clearing pattern from cache: ${error}`)
+  }
+}
+
+// Get multiple previews from cache (batch operation)
+export async function getPreviewsFromCache(
+  patterns: string[]
+): Promise<Map<string, PreviewData>> {
+  const results = new Map<string, PreviewData>()
+  const uncachedPatterns: string[] = []
+
+  // Check memory cache first
+  for (const pattern of patterns) {
+    if (memoryCache.has(pattern)) {
+      results.set(pattern, memoryCache.get(pattern)!)
+    } else {
+      uncachedPatterns.push(pattern)
+    }
+  }
+
+  // Check IndexedDB for remaining patterns
+  if (uncachedPatterns.length > 0 && previewCacheDB) {
+    try {
+      const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite')
+      const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME)
+
+      await Promise.all(
+        uncachedPatterns.map(
+          (pattern) =>
+            new Promise<void>((resolve) => {
+              const request = store.get(pattern)
+              request.onsuccess = () => {
+                const result = request.result as CacheEntry | undefined
+                if (result) {
+                  // Update last accessed time
+                  result.lastAccessed = Date.now()
+                  store.put(result)
+
+                  // Add to results and memory cache
+                  results.set(pattern, result.data)
+                  addToMemoryCache(pattern, result.data)
+                }
+                resolve()
+              }
+              request.onerror = () => resolve()
+            })
+        )
+      )
+    } catch (error) {
+      console.warn(`Error batch getting from cache: ${error}`)
+    }
+  }
+
+  return results
+}
+
+// Shared function to cache all previews - used by both BrowsePage and Layout modal
+export interface CacheAllProgress {
+  completed: number
+  total: number
+  done: boolean
+}
+
+export async function cacheAllPreviews(
+  onProgress: (progress: CacheAllProgress) => void
+): Promise<{ success: boolean; cached: number }> {
+  const BATCH_SIZE = 10
+
+  try {
+    await initPreviewCacheDB()
+
+    // Fetch all patterns
+    const patterns: { path: string }[] = await apiClient.get('/list_theta_rho_files_with_metadata')
+    const allPaths = patterns.map((p) => p.path)
+
+    // Check which patterns are already cached
+    const cachedPreviews = await getPreviewsFromCache(allPaths)
+    const uncachedPatterns = allPaths.filter((path) => !cachedPreviews.has(path))
+
+    if (uncachedPatterns.length === 0) {
+      onProgress({ completed: patterns.length, total: patterns.length, done: true })
+      return { success: true, cached: 0 }
+    }
+
+    onProgress({ completed: 0, total: uncachedPatterns.length, done: false })
+
+    const totalBatches = Math.ceil(uncachedPatterns.length / BATCH_SIZE)
+
+    for (let i = 0; i < totalBatches; i++) {
+      const batchStart = i * BATCH_SIZE
+      const batchEnd = Math.min(batchStart + BATCH_SIZE, uncachedPatterns.length)
+      const batchPatterns = uncachedPatterns.slice(batchStart, batchEnd)
+
+      try {
+        const results = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batchPatterns })
+
+        for (const [path, data] of Object.entries(results)) {
+          if (data && !data.error) {
+            await savePreviewToCache(path, data)
+          }
+        }
+      } catch {
+        // Continue even if batch fails
+      }
+
+      onProgress({ completed: batchEnd, total: uncachedPatterns.length, done: false })
+
+      // Small delay between batches
+      if (i + 1 < totalBatches) {
+        await new Promise((resolve) => setTimeout(resolve, 100))
+      }
+    }
+
+    onProgress({ completed: uncachedPatterns.length, total: uncachedPatterns.length, done: true })
+    return { success: true, cached: uncachedPatterns.length }
+  } catch (error) {
+    console.error('Error caching previews:', error)
+    return { success: false, cached: 0 }
+  }
+}

+ 33 - 0
frontend/src/lib/types.ts

@@ -0,0 +1,33 @@
+// Shared types across pages
+
+export interface PatternMetadata {
+  path: string
+  name: string
+  category: string
+  date_modified: number
+  coordinates_count: number
+}
+
+export interface PreviewData {
+  image_data: string
+  first_coordinate: { x: number; y: number } | null
+  last_coordinate: { x: number; y: number } | null
+  error?: string
+}
+
+export interface Playlist {
+  name: string
+  files: string[]
+}
+
+export type SortOption = 'name' | 'date' | 'size' | 'favorites'
+export type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
+export type RunMode = 'single' | 'indefinite'
+
+export const preExecutionOptions: { value: PreExecution; label: string }[] = [
+  { value: 'adaptive', label: 'Adaptive' },
+  { value: 'clear_from_in', label: 'Clear From Center' },
+  { value: 'clear_from_out', label: 'Clear From Perimeter' },
+  { value: 'clear_sideway', label: 'Clear Sideways' },
+  { value: 'none', label: 'None' },
+]

+ 23 - 0
frontend/src/lib/utils.ts

@@ -0,0 +1,23 @@
+import { type ClassValue, clsx } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs))
+}
+
+/**
+ * Normalize a string for fuzzy search matching.
+ * Treats spaces, underscores, and hyphens as equivalent.
+ * Example: "clear from out" matches "clear_from_out"
+ */
+export function normalizeForSearch(str: string): string {
+  return str.toLowerCase().replace(/[\s_-]+/g, ' ')
+}
+
+/**
+ * Check if a search query matches a target string (fuzzy match).
+ * Spaces, underscores, and hyphens are treated as equivalent.
+ */
+export function fuzzyMatch(target: string, query: string): boolean {
+  return normalizeForSearch(target).includes(normalizeForSearch(query))
+}

+ 25 - 0
frontend/src/main.tsx

@@ -0,0 +1,25 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import './index.css'
+import App from './App.tsx'
+
+const queryClient = new QueryClient({
+  defaultOptions: {
+    queries: {
+      staleTime: 1000 * 60, // 1 minute
+      retry: 1,
+    },
+  },
+})
+
+createRoot(document.getElementById('root')!).render(
+  <StrictMode>
+    <BrowserRouter>
+      <QueryClientProvider client={queryClient}>
+        <App />
+      </QueryClientProvider>
+    </BrowserRouter>
+  </StrictMode>,
+)

+ 1474 - 0
frontend/src/pages/BrowsePage.tsx

@@ -0,0 +1,1474 @@
+import { useState, useEffect, useMemo, useRef, useCallback, createContext, useContext } from 'react'
+import { toast } from 'sonner'
+import {
+  initPreviewCacheDB,
+  getPreviewsFromCache,
+  savePreviewToCache,
+  cacheAllPreviews,
+} from '@/lib/previewCache'
+import { fuzzyMatch } from '@/lib/utils'
+import { apiClient } from '@/lib/apiClient'
+import { useOnBackendConnected } from '@/hooks/useBackendConnection'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Slider } from '@/components/ui/slider'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import {
+  Sheet,
+  SheetContent,
+  SheetHeader,
+  SheetTitle,
+} from '@/components/ui/sheet'
+
+// Types
+interface PatternMetadata {
+  path: string
+  name: string
+  category: string
+  date_modified: number
+  coordinates_count: number
+}
+
+interface PreviewData {
+  image_data: string
+  first_coordinate: { x: number; y: number } | null
+  last_coordinate: { x: number; y: number } | null
+  error?: string
+}
+
+// Coordinates come as [theta, rho] tuples from the backend
+type Coordinate = [number, number]
+
+type SortOption = 'name' | 'date' | 'size' | 'favorites'
+type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
+
+const preExecutionOptions: { value: PreExecution; label: string }[] = [
+  { value: 'adaptive', label: 'Adaptive' },
+  { value: 'clear_from_in', label: 'Clear From Center' },
+  { value: 'clear_from_out', label: 'Clear From Perimeter' },
+  { value: 'clear_sideway', label: 'Clear Sideways' },
+  { value: 'none', label: 'None' },
+]
+
+// Context for lazy loading previews
+interface PreviewContextType {
+  requestPreview: (path: string) => void
+  previews: Record<string, PreviewData>
+}
+
+const PreviewContext = createContext<PreviewContextType | null>(null)
+
+export function BrowsePage() {
+  // Data state
+  const [patterns, setPatterns] = useState<PatternMetadata[]>([])
+  const [previews, setPreviews] = useState<Record<string, PreviewData>>({})
+  const [isLoading, setIsLoading] = useState(true)
+
+  // Filter/sort state
+  const [searchQuery, setSearchQuery] = useState('')
+  const [selectedCategory, setSelectedCategory] = useState<string>('all')
+  const [sortBy, setSortBy] = useState<SortOption>('name')
+  const [sortAsc, setSortAsc] = useState(true)
+
+  // Selection and panel state
+  const [selectedPattern, setSelectedPattern] = useState<PatternMetadata | null>(null)
+  const [isPanelOpen, setIsPanelOpen] = useState(false)
+  const [preExecution, setPreExecution] = useState<PreExecution>(() => {
+    const cached = localStorage.getItem('preExecution')
+    return (cached as PreExecution) || 'adaptive'
+  })
+  const [isRunning, setIsRunning] = useState(false)
+
+  // Animated preview modal state
+  const [isAnimatedPreviewOpen, setIsAnimatedPreviewOpen] = useState(false)
+  const [coordinates, setCoordinates] = useState<Coordinate[]>([])
+  const [isLoadingCoordinates, setIsLoadingCoordinates] = useState(false)
+  const [isPlaying, setIsPlaying] = useState(false)
+  const [speed, setSpeed] = useState(1)
+  const [progress, setProgress] = useState(0)
+
+  // Pattern execution history state
+  const [patternHistory, setPatternHistory] = useState<{
+    actual_time_formatted: string | null
+    speed: number | null
+  } | null>(null)
+
+  // All pattern histories for badges
+  const [allPatternHistories, setAllPatternHistories] = useState<Record<string, {
+    actual_time_formatted: string | null
+    timestamp: string | null
+  }>>({})
+
+  // Canvas and animation refs
+  const canvasRef = useRef<HTMLCanvasElement>(null)
+  const animationRef = useRef<number | null>(null)
+  const currentIndexRef = useRef(0)
+
+  // Lazy loading queue for previews
+  const pendingPreviewsRef = useRef<Set<string>>(new Set())
+  const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const abortControllerRef = useRef<AbortController | null>(null)
+
+  // Cache all previews state
+  const [isCaching, setIsCaching] = useState(false)
+  const [cacheProgress, setCacheProgress] = useState(0)
+  const [allCached, setAllCached] = useState(false)
+
+  // Favorites state
+  const [favorites, setFavorites] = useState<Set<string>>(new Set())
+
+  // Upload state
+  const fileInputRef = useRef<HTMLInputElement>(null)
+  const [isUploading, setIsUploading] = useState(false)
+
+  // Swipe to dismiss sheet on mobile
+  const sheetTouchStartRef = useRef<{ x: number; y: number } | null>(null)
+  const handleSheetTouchStart = (e: React.TouchEvent) => {
+    sheetTouchStartRef.current = {
+      x: e.touches[0].clientX,
+      y: e.touches[0].clientY,
+    }
+  }
+  const handleSheetTouchEnd = (e: React.TouchEvent) => {
+    if (!sheetTouchStartRef.current) return
+    const deltaX = e.changedTouches[0].clientX - sheetTouchStartRef.current.x
+    const deltaY = e.changedTouches[0].clientY - sheetTouchStartRef.current.y
+
+    // Swipe right (positive X) or swipe down (positive Y) to dismiss
+    // Require at least 80px movement and more horizontal/vertical than the other direction
+    if (deltaX > 80 && deltaX > Math.abs(deltaY)) {
+      setIsPanelOpen(false)
+    } else if (deltaY > 80 && deltaY > Math.abs(deltaX)) {
+      setIsPanelOpen(false)
+    }
+    sheetTouchStartRef.current = null
+  }
+
+  // Close panel when playback starts
+  useEffect(() => {
+    const handlePlaybackStarted = () => {
+      setIsPanelOpen(false)
+    }
+    window.addEventListener('playback-started', handlePlaybackStarted)
+    return () => window.removeEventListener('playback-started', handlePlaybackStarted)
+  }, [])
+
+  // Persist pre-execution selection to localStorage
+  useEffect(() => {
+    localStorage.setItem('preExecution', preExecution)
+  }, [preExecution])
+
+  // Initialize IndexedDB cache and fetch patterns on mount
+  useEffect(() => {
+    initPreviewCacheDB().then(() => {
+      fetchPatterns()
+    }).catch(() => {
+      // Continue even if IndexedDB fails - just won't have persistent cache
+      fetchPatterns()
+    })
+    loadFavorites()
+
+    // Cleanup on unmount: abort in-flight requests and clear pending queue
+    return () => {
+      if (batchTimeoutRef.current) {
+        clearTimeout(batchTimeoutRef.current)
+      }
+      if (abortControllerRef.current) {
+        abortControllerRef.current.abort()
+      }
+      pendingPreviewsRef.current.clear()
+    }
+  }, [])
+
+  // Refetch when backend reconnects
+  useOnBackendConnected(() => {
+    fetchPatterns()
+    loadFavorites()
+  })
+
+  // Load favorites from "Favorites" playlist
+  const loadFavorites = async () => {
+    try {
+      const playlist = await apiClient.get<{ files?: string[] }>('/get_playlist?name=Favorites')
+      setFavorites(new Set(playlist.files || []))
+    } catch {
+      // Favorites playlist doesn't exist yet - that's OK
+    }
+  }
+
+  // Toggle favorite status for a pattern
+  const toggleFavorite = async (path: string, e: React.MouseEvent) => {
+    e.stopPropagation() // Don't trigger card click
+
+    const isFavorite = favorites.has(path)
+    const newFavorites = new Set(favorites)
+
+    try {
+      if (isFavorite) {
+        // Remove from favorites
+        newFavorites.delete(path)
+        await apiClient.post('/modify_playlist', { playlist_name: 'Favorites', files: Array.from(newFavorites) })
+        setFavorites(newFavorites)
+        toast.success('Removed from favorites')
+      } else {
+        // Add to favorites - first check if playlist exists
+        newFavorites.add(path)
+        try {
+          await apiClient.get('/get_playlist?name=Favorites')
+          // Playlist exists, add to it
+          await apiClient.post('/add_to_playlist', { playlist_name: 'Favorites', pattern: path })
+        } catch {
+          // Create playlist with this pattern
+          await apiClient.post('/create_playlist', { playlist_name: 'Favorites', files: [path] })
+        }
+        setFavorites(newFavorites)
+        toast.success('Added to favorites')
+      }
+    } catch {
+      toast.error('Failed to update favorites')
+    }
+  }
+
+  const fetchPatterns = async () => {
+    setIsLoading(true)
+    try {
+      // Fetch patterns and history in parallel
+      const [data, historyData] = await Promise.all([
+        apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata'),
+        apiClient.get<Record<string, { actual_time_formatted: string | null; timestamp: string | null }>>('/api/pattern_history_all')
+      ])
+      setPatterns(data)
+      setAllPatternHistories(historyData)
+
+      if (data.length > 0) {
+        // Sort patterns by name (default sort) before preloading
+        const sortedPatterns = [...data].sort((a: PatternMetadata, b: PatternMetadata) =>
+          a.name.localeCompare(b.name)
+        )
+        const allPaths = data.map((p: PatternMetadata) => p.path)
+
+        // Preload first 30 patterns in sorted order (fills most viewports)
+        const initialBatch = sortedPatterns.slice(0, 30).map((p: PatternMetadata) => p.path)
+        const cachedPreviews = await getPreviewsFromCache(initialBatch)
+
+        // Immediately display cached previews
+        if (cachedPreviews.size > 0) {
+          const cachedData: Record<string, PreviewData> = {}
+          cachedPreviews.forEach((previewData, path) => {
+            cachedData[path] = previewData
+          })
+          setPreviews(cachedData)
+        }
+
+        // Fetch any uncached patterns in the initial batch
+        const uncachedInitial = initialBatch.filter((p: string) => !cachedPreviews.has(p))
+        if (uncachedInitial.length > 0) {
+          fetchPreviewsBatch(uncachedInitial)
+        }
+
+        // Check if ALL patterns are cached (for Cache All button)
+        const allCachedPreviews = await getPreviewsFromCache(allPaths)
+        setAllCached(allCachedPreviews.size === allPaths.length)
+      }
+    } catch (error) {
+      console.error('Error fetching patterns:', error)
+      toast.error('Failed to load patterns')
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  const fetchPreviewsBatch = async (filePaths: string[]) => {
+    const BATCH_SIZE = 10 // Process 10 patterns at a time to avoid overwhelming the backend
+
+    // Create new AbortController for this batch of requests
+    abortControllerRef.current = new AbortController()
+    const signal = abortControllerRef.current.signal
+
+    try {
+      // First check IndexedDB cache for all patterns
+      const cachedPreviews = await getPreviewsFromCache(filePaths)
+
+      // Update state with cached previews immediately
+      if (cachedPreviews.size > 0) {
+        const cachedData: Record<string, PreviewData> = {}
+        cachedPreviews.forEach((data, path) => {
+          cachedData[path] = data
+        })
+        setPreviews((prev) => ({ ...prev, ...cachedData }))
+      }
+
+      // Find patterns not in cache
+      const uncachedPaths = filePaths.filter((path) => !cachedPreviews.has(path))
+
+      // Fetch uncached patterns in batches to avoid overwhelming the backend
+      if (uncachedPaths.length > 0) {
+        for (let i = 0; i < uncachedPaths.length; i += BATCH_SIZE) {
+          // Check if aborted before each batch
+          if (signal.aborted) break
+
+          const batch = uncachedPaths.slice(i, i + BATCH_SIZE)
+
+          try {
+            const data = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batch }, signal)
+
+            // Save fetched previews to IndexedDB cache
+            for (const [path, previewData] of Object.entries(data)) {
+              if (previewData && !(previewData as PreviewData).error) {
+                savePreviewToCache(path, previewData as PreviewData)
+              }
+            }
+
+            setPreviews((prev) => ({ ...prev, ...data }))
+          } catch (err) {
+            // Stop processing if aborted, otherwise continue with next batch
+            if (err instanceof Error && err.name === 'AbortError') break
+          }
+
+          // Small delay between batches to reduce backend load
+          if (i + BATCH_SIZE < uncachedPaths.length) {
+            await new Promise((resolve) => setTimeout(resolve, 100))
+          }
+        }
+      }
+    } catch (error) {
+      // Silently ignore abort errors
+      if (error instanceof Error && error.name === 'AbortError') return
+      console.error('Error fetching previews:', error)
+    }
+  }
+
+  const fetchCoordinates = async (filePath: string) => {
+    setIsLoadingCoordinates(true)
+    try {
+      const data = await apiClient.post<{ coordinates?: Coordinate[] }>('/get_theta_rho_coordinates', { file_name: filePath })
+      setCoordinates(data.coordinates || [])
+    } catch (error) {
+      console.error('Error fetching coordinates:', error)
+      toast.error('Failed to load pattern coordinates')
+    } finally {
+      setIsLoadingCoordinates(false)
+    }
+  }
+
+  // Get unique categories
+  const categories = useMemo(() => {
+    const cats = new Set(patterns.map((p) => p.category))
+    return ['all', ...Array.from(cats).sort()]
+  }, [patterns])
+
+  // Filter and sort patterns
+  const filteredPatterns = useMemo(() => {
+    let result = patterns
+
+    if (selectedCategory !== 'all') {
+      result = result.filter((p) => p.category === selectedCategory)
+    }
+
+    if (searchQuery) {
+      result = result.filter(
+        (p) =>
+          fuzzyMatch(p.name, searchQuery) ||
+          fuzzyMatch(p.category, searchQuery)
+      )
+    }
+
+    result = [...result].sort((a, b) => {
+      let comparison = 0
+      switch (sortBy) {
+        case 'name':
+          comparison = a.name.localeCompare(b.name)
+          break
+        case 'date':
+          comparison = a.date_modified - b.date_modified
+          break
+        case 'size':
+          comparison = a.coordinates_count - b.coordinates_count
+          break
+        case 'favorites': {
+          const aFav = favorites.has(a.path) ? 1 : 0
+          const bFav = favorites.has(b.path) ? 1 : 0
+          comparison = bFav - aFav // Favorites first
+          if (comparison === 0) {
+            comparison = a.name.localeCompare(b.name) // Then by name
+          }
+          break
+        }
+        default:
+          return 0
+      }
+      return sortAsc ? comparison : -comparison
+    })
+
+    return result
+  }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc, favorites])
+
+  // Batched preview loading - collects requests and fetches in batches
+  const requestPreview = useCallback((path: string) => {
+    // Skip if already loaded or pending
+    if (previews[path] || pendingPreviewsRef.current.has(path)) return
+
+    pendingPreviewsRef.current.add(path)
+
+    // Clear existing timeout and set a new one to batch requests
+    if (batchTimeoutRef.current) {
+      clearTimeout(batchTimeoutRef.current)
+    }
+
+    batchTimeoutRef.current = setTimeout(() => {
+      const pathsToFetch = Array.from(pendingPreviewsRef.current)
+      if (pathsToFetch.length > 0) {
+        pendingPreviewsRef.current.clear()
+        fetchPreviewsBatch(pathsToFetch)
+      }
+    }, 50) // Batch requests within 50ms window
+  }, [previews])
+
+  // Canvas drawing functions
+  const polarToCartesian = useCallback((theta: number, rho: number, size: number) => {
+    const centerX = size / 2
+    const centerY = size / 2
+    const radius = (size / 2) * 0.9 * rho
+    const x = centerX + radius * Math.cos(theta)
+    const y = centerY + radius * Math.sin(theta)
+    return { x, y }
+  }, [])
+
+  // Offscreen canvas for the pattern path (performance optimization)
+  const offscreenCanvasRef = useRef<HTMLCanvasElement | null>(null)
+  const lastDrawnIndexRef = useRef<number>(-1)
+  const lastThemeRef = useRef<boolean | null>(null)
+
+  // Get theme colors
+  const getThemeColors = useCallback(() => {
+    const isDark = document.documentElement.classList.contains('dark')
+    return {
+      isDark,
+      bgOuter: isDark ? '#1a1a1a' : '#f5f5f5',
+      bgInner: isDark ? '#262626' : '#ffffff',
+      borderColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(128, 128, 128, 0.3)',
+      lineColor: isDark ? '#e5e5e5' : '#333333',
+      markerBorder: isDark ? '#333333' : '#ffffff',
+    }
+  }, [])
+
+  // Initialize or reset offscreen canvas
+  const initOffscreenCanvas = useCallback((size: number, coords: Coordinate[]) => {
+    const colors = getThemeColors()
+
+    // Create offscreen canvas if needed
+    if (!offscreenCanvasRef.current) {
+      offscreenCanvasRef.current = document.createElement('canvas')
+    }
+
+    const offscreen = offscreenCanvasRef.current
+    offscreen.width = size
+    offscreen.height = size
+
+    const ctx = offscreen.getContext('2d')
+    if (!ctx) return
+
+    // Draw background
+    ctx.fillStyle = colors.bgOuter
+    ctx.fillRect(0, 0, size, size)
+
+    // Draw background circle
+    ctx.beginPath()
+    ctx.arc(size / 2, size / 2, (size / 2) * 0.95, 0, Math.PI * 2)
+    ctx.fillStyle = colors.bgInner
+    ctx.fill()
+    ctx.strokeStyle = colors.borderColor
+    ctx.lineWidth = 1
+    ctx.stroke()
+
+    // Setup line style for incremental drawing
+    ctx.strokeStyle = colors.lineColor
+    ctx.lineWidth = 1
+    ctx.lineCap = 'round'
+    ctx.lineJoin = 'round'
+
+    // Draw initial point if we have coordinates
+    if (coords.length > 0) {
+      const firstPoint = polarToCartesian(coords[0][0], coords[0][1], size)
+      ctx.beginPath()
+      ctx.moveTo(firstPoint.x, firstPoint.y)
+      ctx.stroke()
+    }
+
+    lastDrawnIndexRef.current = 0
+    lastThemeRef.current = colors.isDark
+  }, [getThemeColors, polarToCartesian])
+
+  // Draw pattern incrementally for performance
+  const drawPattern = useCallback((ctx: CanvasRenderingContext2D, coords: Coordinate[], upToIndex: number, forceRedraw = false) => {
+    const canvas = ctx.canvas
+    const size = canvas.width
+    const colors = getThemeColors()
+
+    // Check if we need to reinitialize (theme change or reset)
+    const needsReinit = forceRedraw ||
+      !offscreenCanvasRef.current ||
+      lastThemeRef.current !== colors.isDark ||
+      upToIndex < lastDrawnIndexRef.current
+
+    if (needsReinit) {
+      initOffscreenCanvas(size, coords)
+    }
+
+    const offscreen = offscreenCanvasRef.current
+    if (!offscreen) return
+
+    const offCtx = offscreen.getContext('2d')
+    if (!offCtx) return
+
+    // Draw new segments incrementally on offscreen canvas
+    if (coords.length > 0 && upToIndex > lastDrawnIndexRef.current) {
+      offCtx.strokeStyle = colors.lineColor
+      offCtx.lineWidth = 1
+      offCtx.lineCap = 'round'
+      offCtx.lineJoin = 'round'
+
+      offCtx.beginPath()
+      const startPoint = polarToCartesian(
+        coords[lastDrawnIndexRef.current][0],
+        coords[lastDrawnIndexRef.current][1],
+        size
+      )
+      offCtx.moveTo(startPoint.x, startPoint.y)
+
+      for (let i = lastDrawnIndexRef.current + 1; i <= upToIndex && i < coords.length; i++) {
+        const point = polarToCartesian(coords[i][0], coords[i][1], size)
+        offCtx.lineTo(point.x, point.y)
+      }
+      offCtx.stroke()
+
+      lastDrawnIndexRef.current = upToIndex
+    }
+
+    // Copy offscreen canvas to main canvas
+    ctx.drawImage(offscreen, 0, 0)
+
+    // Draw current position marker on main canvas
+    if (upToIndex < coords.length && coords.length > 0) {
+      const currentPoint = polarToCartesian(coords[upToIndex][0], coords[upToIndex][1], size)
+      ctx.beginPath()
+      ctx.arc(currentPoint.x, currentPoint.y, 5, 0, Math.PI * 2)
+      ctx.fillStyle = '#0b80ee'
+      ctx.fill()
+      ctx.strokeStyle = colors.markerBorder
+      ctx.lineWidth = 1
+      ctx.stroke()
+    }
+  }, [getThemeColors, initOffscreenCanvas, polarToCartesian])
+
+  // Animation loop
+  useEffect(() => {
+    if (!isPlaying || coordinates.length === 0 || !canvasRef.current) return
+
+    const ctx = canvasRef.current.getContext('2d')
+    if (!ctx) return
+
+    let lastTime = performance.now()
+    const coordsPerSecond = 100 * speed
+
+    const animate = (currentTime: number) => {
+      const deltaTime = (currentTime - lastTime) / 1000
+      lastTime = currentTime
+
+      const coordsToAdvance = Math.floor(deltaTime * coordsPerSecond)
+      currentIndexRef.current = Math.min(
+        currentIndexRef.current + Math.max(1, coordsToAdvance),
+        coordinates.length - 1
+      )
+
+      drawPattern(ctx, coordinates, currentIndexRef.current)
+      setProgress((currentIndexRef.current / (coordinates.length - 1)) * 100)
+
+      if (currentIndexRef.current < coordinates.length - 1) {
+        animationRef.current = requestAnimationFrame(animate)
+      } else {
+        setIsPlaying(false)
+      }
+    }
+
+    animationRef.current = requestAnimationFrame(animate)
+
+    return () => {
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current)
+      }
+    }
+  }, [isPlaying, coordinates, speed, drawPattern])
+
+  // Draw initial state when coordinates load
+  useEffect(() => {
+    if (coordinates.length > 0 && canvasRef.current) {
+      const ctx = canvasRef.current.getContext('2d')
+      if (ctx) {
+        currentIndexRef.current = 0
+        setProgress(0)
+        drawPattern(ctx, coordinates, 0, true) // Force redraw on new pattern
+      }
+    }
+  }, [coordinates, drawPattern])
+
+  const handlePatternClick = async (pattern: PatternMetadata) => {
+    setSelectedPattern(pattern)
+    setIsPanelOpen(true)
+    setPreExecution('adaptive')
+    setPatternHistory(null) // Reset while loading
+
+    // Fetch pattern execution history
+    try {
+      const history = await apiClient.get<{
+        actual_time_formatted: string | null
+        speed: number | null
+      }>(`/api/pattern_history/${encodeURIComponent(pattern.path)}`)
+      setPatternHistory(history)
+    } catch {
+      // Silently ignore - history is optional
+    }
+  }
+
+  const handleOpenAnimatedPreview = async () => {
+    if (!selectedPattern) return
+    setIsPanelOpen(false) // Close sheet before opening preview
+    setIsAnimatedPreviewOpen(true)
+    setIsPlaying(false)
+    setProgress(0)
+    currentIndexRef.current = 0
+    await fetchCoordinates(selectedPattern.path)
+    // Auto-play after coordinates load
+    setIsPlaying(true)
+  }
+
+  const handleCloseAnimatedPreview = () => {
+    setIsAnimatedPreviewOpen(false)
+    setIsPlaying(false)
+    if (animationRef.current) {
+      cancelAnimationFrame(animationRef.current)
+    }
+    setCoordinates([])
+  }
+
+  const handlePlayPause = () => {
+    if (isPlaying) {
+      setIsPlaying(false)
+    } else {
+      if (currentIndexRef.current >= coordinates.length - 1) {
+        currentIndexRef.current = 0
+        setProgress(0)
+      }
+      setIsPlaying(true)
+    }
+  }
+
+  const handleReset = () => {
+    setIsPlaying(false)
+    currentIndexRef.current = 0
+    setProgress(0)
+    if (canvasRef.current && coordinates.length > 0) {
+      const ctx = canvasRef.current.getContext('2d')
+      if (ctx) {
+        drawPattern(ctx, coordinates, 0, true) // Force redraw on reset
+      }
+    }
+  }
+
+  const handleProgressChange = (value: number[]) => {
+    const newProgress = value[0]
+    setProgress(newProgress)
+    currentIndexRef.current = Math.floor((newProgress / 100) * (coordinates.length - 1))
+
+    if (canvasRef.current && coordinates.length > 0) {
+      const ctx = canvasRef.current.getContext('2d')
+      if (ctx) {
+        drawPattern(ctx, coordinates, currentIndexRef.current)
+      }
+    }
+  }
+
+  const handleRunPattern = async () => {
+    if (!selectedPattern) return
+
+    setIsRunning(true)
+    try {
+      await apiClient.post('/run_theta_rho', {
+        file_name: selectedPattern.path,
+        pre_execution: preExecution,
+      })
+      toast.success(`Running ${selectedPattern.name}`)
+      // Close the preview panel and trigger Now Playing bar to open
+      setIsPanelOpen(false)
+      window.dispatchEvent(new CustomEvent('playback-started'))
+    } catch (error) {
+      const message = error instanceof Error ? error.message : 'Failed to run pattern'
+      if (message.includes('409') || message.includes('already running')) {
+        toast.error('Another pattern is already running')
+      } else {
+        toast.error(message)
+      }
+    } finally {
+      setIsRunning(false)
+    }
+  }
+
+  const handleDeletePattern = async () => {
+    if (!selectedPattern) return
+
+    if (!selectedPattern.path.startsWith('custom_patterns/')) {
+      toast.error('Only custom patterns can be deleted')
+      return
+    }
+
+    if (!confirm(`Delete "${selectedPattern.name}"? This cannot be undone.`)) {
+      return
+    }
+
+    try {
+      await apiClient.post('/delete_theta_rho_file', { file_name: selectedPattern.path })
+      toast.success(`Deleted ${selectedPattern.name}`)
+      setIsPanelOpen(false)
+      setSelectedPattern(null)
+      fetchPatterns()
+    } catch {
+      toast.error('Failed to delete pattern')
+    }
+  }
+
+  const handleAddToQueue = async (position: 'next' | 'end') => {
+    if (!selectedPattern) return
+
+    try {
+      await apiClient.post('/add_to_queue', {
+        pattern: selectedPattern.path,
+        position,
+      })
+      toast.success(position === 'next' ? 'Playing next' : 'Added to queue')
+    } catch (error) {
+      const message = error instanceof Error ? error.message : 'Failed to add to queue'
+      if (message.includes('400') || message.includes('No playlist')) {
+        toast.error('No playlist is currently running')
+      } else {
+        toast.error(message)
+      }
+    }
+  }
+
+  const getPreviewUrl = (path: string) => {
+    const preview = previews[path]
+    return preview?.image_data || null
+  }
+
+  const formatCoordinate = (coord: { x: number; y: number } | null) => {
+    if (!coord) return '(-, -)'
+    return `(${coord.x.toFixed(1)}, ${coord.y.toFixed(1)})`
+  }
+
+  const canDelete = selectedPattern?.path.startsWith('custom_patterns/')
+
+  // Cache all previews handler
+  const handleCacheAllPreviews = async () => {
+    if (isCaching) return
+
+    setIsCaching(true)
+    setCacheProgress(0)
+
+    const result = await cacheAllPreviews((progress) => {
+      const percentage = progress.total > 0
+        ? Math.round((progress.completed / progress.total) * 100)
+        : 0
+      setCacheProgress(percentage)
+    })
+
+    if (result.success) {
+      setAllCached(true)
+      if (result.cached === 0) {
+        toast.success('All patterns are already cached!')
+      } else {
+        toast.success('All pattern previews have been cached!')
+      }
+    } else {
+      toast.error('Failed to cache previews')
+    }
+
+    setIsCaching(false)
+    setCacheProgress(0)
+  }
+
+  // Handle pattern file upload
+  const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0]
+    if (!file) return
+
+    // Validate file extension
+    if (!file.name.endsWith('.thr')) {
+      toast.error('Please select a .thr file')
+      return
+    }
+
+    setIsUploading(true)
+    try {
+      await apiClient.uploadFile('/upload_theta_rho', file)
+      toast.success(`Pattern "${file.name}" uploaded successfully`)
+
+      // Refresh patterns list using the same function as initial load
+      await fetchPatterns()
+    } catch (error) {
+      console.error('Upload error:', error)
+      toast.error(error instanceof Error ? error.message : 'Failed to upload pattern')
+    } finally {
+      setIsUploading(false)
+      // Reset file input
+      if (fileInputRef.current) {
+        fileInputRef.current.value = ''
+      }
+    }
+  }
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center min-h-[60vh]">
+        <span className="material-icons-outlined animate-spin text-4xl text-muted-foreground">
+          sync
+        </span>
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-3 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4">
+      {/* Hidden file input for pattern upload */}
+      <input
+        ref={fileInputRef}
+        type="file"
+        accept=".thr"
+        onChange={handleFileUpload}
+        className="hidden"
+      />
+
+      {/* Page Header */}
+      <div className="flex items-start justify-between gap-4 pl-1">
+        <div className="space-y-0.5">
+          <h1 className="text-xl font-semibold tracking-tight">Browse Patterns</h1>
+          <p className="text-xs text-muted-foreground">
+            {patterns.length} patterns available
+          </p>
+        </div>
+        <Button
+          variant="ghost"
+          onClick={() => fileInputRef.current?.click()}
+          disabled={isUploading}
+          className="gap-2 shrink-0 h-9 w-9 sm:h-11 sm:w-auto rounded-full px-0 sm:px-4 justify-center bg-card border border-border shadow-sm hover:bg-accent"
+        >
+          {isUploading ? (
+            <span className="material-icons-outlined animate-spin text-lg">sync</span>
+          ) : (
+            <span className="material-icons-outlined text-lg">add</span>
+          )}
+          <span className="hidden sm:inline">Add Pattern</span>
+        </Button>
+      </div>
+
+      {/* Filter Bar */}
+      <div
+        className="sticky z-30 py-3 -mx-0 sm:-mx-4 px-0 sm:px-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
+        style={{ top: 'calc(4.5rem + env(safe-area-inset-top, 0px))' }}
+      >
+        <div className="flex items-center gap-2 sm:gap-3">
+          {/* Search - Pill shaped, white background */}
+          <div className="relative flex-1 min-w-0">
+            <span className="material-icons-outlined absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-muted-foreground text-lg sm:text-xl">
+              search
+            </span>
+            <Input
+              value={searchQuery}
+              onChange={(e) => setSearchQuery(e.target.value)}
+              placeholder="Search..."
+              className="pl-9 sm:pl-11 pr-10 h-9 sm:h-11 rounded-full bg-card border-border shadow-sm text-xs sm:text-sm focus:ring-2 focus:ring-ring"
+            />
+            {searchQuery && (
+              <Button
+                variant="ghost"
+                size="icon"
+                onClick={() => setSearchQuery('')}
+                className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground h-7 w-7 rounded-full"
+              >
+                <span className="material-icons-outlined text-lg">close</span>
+              </Button>
+            )}
+          </div>
+
+          {/* Category - Icon on mobile, text on desktop */}
+          <Select value={selectedCategory} onValueChange={setSelectedCategory}>
+            <SelectTrigger className="h-9 w-9 sm:h-11 sm:w-auto rounded-full bg-card border-border shadow-sm text-xs sm:text-sm shrink-0 [&>svg]:hidden sm:[&>svg]:block px-0 sm:px-3 justify-center sm:justify-between [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
+              <span className="material-icons-outlined text-lg shrink-0 sm:hidden">folder</span>
+              <SelectValue placeholder="All" />
+            </SelectTrigger>
+            <SelectContent>
+              {categories.map((cat) => (
+                <SelectItem key={cat} value={cat}>
+                  {cat === 'all' ? 'All' : cat === 'root' ? 'Default' : cat}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+
+          {/* Sort - Icon on mobile, text on desktop */}
+          <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
+            <SelectTrigger className="h-9 w-9 sm:h-11 sm:w-auto rounded-full bg-card border-border shadow-sm text-xs sm:text-sm shrink-0 [&>svg]:hidden sm:[&>svg]:block px-0 sm:px-3 justify-center sm:justify-between [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
+              <span className="material-icons-outlined text-lg shrink-0 sm:hidden">sort</span>
+              <SelectValue placeholder="Sort" />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="favorites">Favorites</SelectItem>
+              <SelectItem value="name">Name</SelectItem>
+              <SelectItem value="date">Modified</SelectItem>
+              <SelectItem value="size">Size</SelectItem>
+            </SelectContent>
+          </Select>
+
+          {/* Sort direction - Pill shaped, white background */}
+          <Button
+            variant="outline"
+            size="icon"
+            onClick={() => setSortAsc(!sortAsc)}
+            className="shrink-0 h-9 w-9 sm:h-11 sm:w-11 rounded-full bg-card shadow-sm"
+            title={sortAsc ? 'Ascending' : 'Descending'}
+          >
+            <span className="material-icons-outlined text-lg sm:text-xl">
+              {sortAsc ? 'arrow_upward' : 'arrow_downward'}
+            </span>
+          </Button>
+
+          {/* Cache button - Pill shaped, white background */}
+          {!allCached && (
+            <Button
+              variant="outline"
+              onClick={handleCacheAllPreviews}
+              className={`shrink-0 rounded-full bg-card shadow-sm gap-2 ${
+                isCaching
+                  ? 'h-9 sm:h-11 w-auto px-3 sm:px-4'
+                  : 'h-9 w-9 sm:h-11 sm:w-auto px-0 sm:px-4 justify-center sm:justify-start'
+              }`}
+              title="Cache All Previews"
+            >
+              {isCaching ? (
+                <>
+                  <span className="material-icons-outlined animate-spin text-lg">sync</span>
+                  <span className="text-sm">{cacheProgress}%</span>
+                </>
+              ) : (
+                <>
+                  <span className="material-icons-outlined text-lg">cached</span>
+                  <span className="hidden sm:inline text-sm">Cache</span>
+                </>
+              )}
+            </Button>
+          )}
+        </div>
+      </div>
+
+      {(searchQuery || selectedCategory !== 'all') && (
+        <p className="text-sm text-muted-foreground">
+          Showing {filteredPatterns.length} of {patterns.length} patterns
+        </p>
+      )}
+
+      {/* Pattern Grid */}
+      {filteredPatterns.length === 0 ? (
+        <div className="flex flex-col items-center justify-center min-h-[40vh] gap-4 text-center">
+          <div className="p-4 rounded-full bg-muted">
+            <span className="material-icons-outlined text-5xl text-muted-foreground">
+              search_off
+            </span>
+          </div>
+          <div className="space-y-1">
+            <h2 className="text-xl font-semibold">No patterns found</h2>
+            <p className="text-muted-foreground">Try adjusting your search or filters</p>
+          </div>
+          {(searchQuery || selectedCategory !== 'all') && (
+            <Button
+              variant="secondary"
+              onClick={() => {
+                setSearchQuery('')
+                setSelectedCategory('all')
+              }}
+            >
+              Clear Filters
+            </Button>
+          )}
+        </div>
+      ) : (
+        <PreviewContext.Provider value={{ requestPreview, previews }}>
+          <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2 sm:gap-4">
+            {filteredPatterns.map((pattern) => (
+              <PatternCard
+                key={pattern.path}
+                pattern={pattern}
+                isSelected={selectedPattern?.path === pattern.path}
+                isFavorite={favorites.has(pattern.path)}
+                playTime={allPatternHistories[pattern.path.split('/').pop() || '']?.actual_time_formatted || null}
+                onToggleFavorite={toggleFavorite}
+                onClick={() => handlePatternClick(pattern)}
+              />
+            ))}
+          </div>
+        </PreviewContext.Provider>
+      )}
+
+      <div className="h-48" />
+
+      {/* Pattern Details Sheet */}
+      <Sheet open={isPanelOpen} onOpenChange={setIsPanelOpen}>
+        <SheetContent
+          className="flex flex-col p-0 overflow-hidden pt-safe"
+          onTouchStart={handleSheetTouchStart}
+          onTouchEnd={handleSheetTouchEnd}
+        >
+          <SheetHeader className="px-6 py-4 shrink-0">
+            <SheetTitle className="flex items-center gap-2 pr-8">
+              {selectedPattern && (
+                <span
+                  role="button"
+                  tabIndex={0}
+                  className={`shrink-0 transition-colors cursor-pointer flex items-center ${
+                    favorites.has(selectedPattern.path) ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-red-500'
+                  }`}
+                  onClick={(e) => toggleFavorite(selectedPattern.path, e)}
+                  onKeyDown={(e) => {
+                    if (e.key === 'Enter' || e.key === ' ') {
+                      e.preventDefault()
+                      toggleFavorite(selectedPattern.path, e as unknown as React.MouseEvent)
+                    }
+                  }}
+                  title={favorites.has(selectedPattern.path) ? 'Remove from favorites' : 'Add to favorites'}
+                >
+                  <span className="material-icons" style={{ fontSize: '20px' }}>
+                    {favorites.has(selectedPattern.path) ? 'favorite' : 'favorite_border'}
+                  </span>
+                </span>
+              )}
+              <span className="truncate">{selectedPattern?.name || 'Pattern Details'}</span>
+            </SheetTitle>
+          </SheetHeader>
+
+          {selectedPattern && (
+            <div className="p-6 overflow-y-auto flex-1">
+              {/* Clickable Round Preview Image */}
+              <div className="mb-6">
+                <div
+                  className="aspect-square w-full max-w-[280px] mx-auto overflow-hidden rounded-full border bg-muted relative group cursor-pointer"
+                  onClick={handleOpenAnimatedPreview}
+                >
+                  {getPreviewUrl(selectedPattern.path) ? (
+                    <img
+                      src={getPreviewUrl(selectedPattern.path)!}
+                      alt={selectedPattern.name}
+                      className="w-full h-full object-cover pattern-preview"
+                    />
+                  ) : (
+                    <div className="w-full h-full flex items-center justify-center">
+                      <span className="material-icons-outlined text-4xl text-muted-foreground">
+                        image
+                      </span>
+                    </div>
+                  )}
+                  {/* Play badge - always visible */}
+                  <div className="absolute bottom-2 right-2 bg-background/90 backdrop-blur-sm rounded-full w-10 h-10 flex items-center justify-center shadow-md border group-hover:scale-110 transition-transform">
+                    <span className="material-icons text-xl">play_arrow</span>
+                  </div>
+                  {/* Hover overlay */}
+                  <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-black/20 rounded-full" />
+                </div>
+                <p className="text-xs text-muted-foreground text-center mt-2">Tap to preview animation</p>
+              </div>
+
+              {/* Coordinates */}
+              <div className="mb-4 flex justify-between text-sm">
+                <div className="flex items-center gap-2">
+                  <span className="material-icons-outlined text-muted-foreground text-base">flag</span>
+                  <span className="text-muted-foreground">First:</span>
+                  <span className="font-semibold">
+                    {formatCoordinate(previews[selectedPattern.path]?.first_coordinate)}
+                  </span>
+                </div>
+                <div className="flex items-center gap-2">
+                  <span className="material-icons-outlined text-muted-foreground text-base">check</span>
+                  <span className="text-muted-foreground">Last:</span>
+                  <span className="font-semibold">
+                    {formatCoordinate(previews[selectedPattern.path]?.last_coordinate)}
+                  </span>
+                </div>
+              </div>
+
+              {/* Last Played Info */}
+              {patternHistory?.actual_time_formatted && (
+                <div className="mb-4 flex justify-between text-sm">
+                  <div className="flex items-center gap-2">
+                    <span className="material-icons-outlined text-muted-foreground text-base">schedule</span>
+                    <span className="text-muted-foreground">Last run:</span>
+                    <span className="font-semibold">{patternHistory.actual_time_formatted}</span>
+                  </div>
+                  {patternHistory.speed !== null && (
+                    <div className="flex items-center gap-2">
+                      <span className="material-icons-outlined text-muted-foreground text-base">speed</span>
+                      <span className="text-muted-foreground">Speed:</span>
+                      <span className="font-semibold">{patternHistory.speed}</span>
+                    </div>
+                  )}
+                </div>
+              )}
+
+              {/* Pre-Execution Options */}
+              <div className="mb-6">
+                <Label className="text-sm font-semibold mb-3 block">Pre-Execution Action</Label>
+                <div className="grid grid-cols-2 gap-2">
+                  {preExecutionOptions.map((option) => (
+                    <label
+                      key={option.value}
+                      className={`relative flex cursor-pointer items-center justify-center rounded-lg border p-2.5 text-center text-sm font-medium transition-all hover:border-primary ${
+                        preExecution === option.value
+                          ? 'border-primary bg-primary text-primary-foreground ring-2 ring-primary ring-offset-2 ring-offset-background'
+                          : 'border-border text-muted-foreground hover:text-foreground'
+                      }`}
+                    >
+                      {option.label}
+                      <input
+                        type="radio"
+                        name="preExecutionAction"
+                        value={option.value}
+                        checked={preExecution === option.value}
+                        onChange={() => setPreExecution(option.value)}
+                        className="sr-only"
+                      />
+                    </label>
+                  ))}
+                </div>
+              </div>
+
+              {/* Action Buttons */}
+              <div className="space-y-3">
+                {/* Play + Delete row */}
+                <div className="flex gap-2">
+                  <Button
+                    onClick={handleRunPattern}
+                    disabled={isRunning}
+                    className="flex-1 gap-2"
+                    size="lg"
+                  >
+                    {isRunning ? (
+                      <span className="material-icons-outlined animate-spin text-lg">sync</span>
+                    ) : (
+                      <span className="material-icons text-lg">play_arrow</span>
+                    )}
+                    Play
+                  </Button>
+
+                  {canDelete && (
+                    <Button
+                      variant="outline"
+                      onClick={handleDeletePattern}
+                      className="text-destructive hover:bg-destructive/10 hover:border-destructive px-3"
+                      size="lg"
+                    >
+                      <span className="material-icons text-lg">delete</span>
+                    </Button>
+                  )}
+                </div>
+
+                {/* Queue buttons */}
+                <div className="flex gap-2">
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    className="flex-1 gap-1.5"
+                    onClick={() => handleAddToQueue('next')}
+                  >
+                    <span className="material-icons-outlined text-base">playlist_play</span>
+                    Play Next
+                  </Button>
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    className="flex-1 gap-1.5"
+                    onClick={() => handleAddToQueue('end')}
+                  >
+                    <span className="material-icons-outlined text-base">playlist_add</span>
+                    Add to Queue
+                  </Button>
+                </div>
+              </div>
+            </div>
+          )}
+        </SheetContent>
+      </Sheet>
+
+      {/* Animated Preview Modal */}
+      {isAnimatedPreviewOpen && (
+        <div
+          className="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4"
+          onClick={handleCloseAnimatedPreview}
+        >
+          <div
+            className="bg-background rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] flex flex-col overflow-hidden"
+            onClick={(e) => e.stopPropagation()}
+          >
+            {/* Modal Header */}
+            <div className="flex items-center justify-between p-6 border-b shrink-0">
+              <h3 className="text-xl font-semibold">
+                {selectedPattern?.name || 'Animated Preview'}
+              </h3>
+              <Button
+                variant="ghost"
+                size="icon"
+                onClick={handleCloseAnimatedPreview}
+                className="rounded-full"
+              >
+                <span className="material-icons text-2xl">close</span>
+              </Button>
+            </div>
+
+            {/* Modal Content */}
+            <div className="p-6 overflow-y-auto flex-1 flex justify-center items-center">
+              {isLoadingCoordinates ? (
+                <div className="w-full max-w-[400px] aspect-square flex items-center justify-center rounded-full bg-muted">
+                  <span className="material-icons-outlined animate-spin text-4xl text-muted-foreground">
+                    sync
+                  </span>
+                </div>
+              ) : (
+                <div className="relative w-full max-w-[400px] aspect-square">
+                  <canvas
+                    ref={canvasRef}
+                    width={400}
+                    height={400}
+                    className="rounded-full w-full h-full"
+                  />
+                  {/* Play/Pause overlay */}
+                  <div
+                    className="absolute inset-0 flex items-center justify-center cursor-pointer rounded-full opacity-0 hover:opacity-100 transition-opacity bg-black/10"
+                    onClick={handlePlayPause}
+                  >
+                    <div className="bg-background rounded-full w-16 h-16 flex items-center justify-center shadow-lg">
+                      <span className="material-icons text-3xl">
+                        {isPlaying ? 'pause' : 'play_arrow'}
+                      </span>
+                    </div>
+                  </div>
+                </div>
+              )}
+            </div>
+
+            {/* Controls */}
+            <div className="p-6 space-y-4 shrink-0 border-t">
+              {/* Speed Control */}
+              <div>
+                <div className="flex justify-between mb-2">
+                  <Label className="text-sm font-medium">Speed</Label>
+                  <span className="text-sm text-muted-foreground">{speed}x</span>
+                </div>
+                <Slider
+                  value={[speed]}
+                  onValueChange={(v) => setSpeed(v[0])}
+                  min={0.1}
+                  max={5}
+                  step={0.1}
+                  className="py-2"
+                />
+              </div>
+
+              {/* Progress Control */}
+              <div>
+                <div className="flex justify-between mb-2">
+                  <Label className="text-sm font-medium">Progress</Label>
+                  <span className="text-sm text-muted-foreground">{progress.toFixed(0)}%</span>
+                </div>
+                <Slider
+                  value={[progress]}
+                  onValueChange={handleProgressChange}
+                  min={0}
+                  max={100}
+                  step={0.1}
+                  className="py-2"
+                />
+              </div>
+
+              {/* Control Buttons */}
+              <div className="flex items-center justify-center gap-4">
+                <Button onClick={handlePlayPause} className="gap-2">
+                  <span className="material-icons">
+                    {isPlaying ? 'pause' : 'play_arrow'}
+                  </span>
+                  {isPlaying ? 'Pause' : 'Play'}
+                </Button>
+                <Button variant="secondary" onClick={handleReset} className="gap-2">
+                  <span className="material-icons">replay</span>
+                  Reset
+                </Button>
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+// Pattern Card Component
+interface PatternCardProps {
+  pattern: PatternMetadata
+  isSelected: boolean
+  isFavorite: boolean
+  playTime: string | null
+  onToggleFavorite: (path: string, e: React.MouseEvent) => void
+  onClick: () => void
+}
+
+function PatternCard({ pattern, isSelected, isFavorite, playTime, onToggleFavorite, onClick }: PatternCardProps) {
+  const [imageLoaded, setImageLoaded] = useState(false)
+  const [imageError, setImageError] = useState(false)
+  const cardRef = useRef<HTMLButtonElement>(null)
+  const context = useContext(PreviewContext)
+
+  // Request preview when card becomes visible
+  useEffect(() => {
+    if (!context || !cardRef.current) return
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        entries.forEach((entry) => {
+          if (entry.isIntersecting) {
+            context.requestPreview(pattern.path)
+            observer.disconnect() // Only need to load once
+          }
+        })
+      },
+      { rootMargin: '100px' } // Start loading slightly before visible
+    )
+
+    observer.observe(cardRef.current)
+
+    return () => observer.disconnect()
+  }, [pattern.path, context])
+
+  const previewUrl = context?.previews[pattern.path]?.image_data || null
+
+  return (
+    <button
+      ref={cardRef}
+      onClick={onClick}
+      className={`group flex flex-col items-center gap-2 p-2.5 rounded-xl bg-card border border-border transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-md active:scale-95 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
+        isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : ''
+      }`}
+    >
+      <div className="relative w-full aspect-square">
+        <div className="w-full h-full rounded-full overflow-hidden border border-border bg-muted">
+          {previewUrl && !imageError ? (
+            <>
+              {!imageLoaded && (
+                <div className="absolute inset-0 flex items-center justify-center">
+                  <span className="material-icons-outlined animate-spin text-xl text-muted-foreground">
+                    sync
+                  </span>
+                </div>
+              )}
+              <img
+                src={previewUrl}
+                alt={pattern.name}
+                className={`w-full h-full object-cover pattern-preview transition-opacity ${
+                  imageLoaded ? 'opacity-100' : 'opacity-0'
+                }`}
+                loading="lazy"
+                onLoad={() => setImageLoaded(true)}
+                onError={() => setImageError(true)}
+              />
+            </>
+          ) : (
+            <div className="w-full h-full flex items-center justify-center">
+              <span className="material-icons-outlined text-2xl text-muted-foreground">
+                {imageError ? 'broken_image' : 'image'}
+              </span>
+            </div>
+          )}
+        </div>
+
+        {/* Play time badge */}
+        {playTime && (
+          <div className="absolute -top-1 -right-1 bg-card/90 backdrop-blur-sm text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-border shadow-sm">
+            {(() => {
+              // Parse time and convert to minutes only
+              // Try MM:SS or HH:MM:SS format first (e.g., "15:48" or "1:15:48")
+              const colonMatch = playTime.match(/^(?:(\d+):)?(\d+):(\d+)$/)
+              if (colonMatch) {
+                const hours = colonMatch[1] ? parseInt(colonMatch[1]) : 0
+                const minutes = parseInt(colonMatch[2])
+                const seconds = parseInt(colonMatch[3])
+                const totalMins = hours * 60 + minutes + (seconds >= 30 ? 1 : 0)
+                return totalMins > 0 ? `${totalMins}m` : '<1m'
+              }
+
+              // Try text-based formats
+              const match = playTime.match(/(\d+)h\s*(\d+)m|(\d+)\s*min|(\d+)m\s*(\d+)s|(\d+)\s*sec/)
+              if (match) {
+                if (match[1] && match[2]) {
+                  // "Xh Ym" format
+                  return `${parseInt(match[1]) * 60 + parseInt(match[2])}m`
+                } else if (match[3]) {
+                  // "X min" format
+                  return `${match[3]}m`
+                } else if (match[4] && match[5]) {
+                  // "Xm Ys" format - round to minutes
+                  const mins = parseInt(match[4])
+                  return mins > 0 ? `${mins}m` : '<1m'
+                } else if (match[6]) {
+                  // seconds only
+                  return '<1m'
+                }
+              }
+              // Fallback: show original
+              return playTime
+            })()}
+          </div>
+        )}
+      </div>
+
+      {/* Name and favorite row */}
+      <div className="flex items-center justify-between w-full gap-1 px-0.5">
+        <span className="text-xs font-bold text-foreground truncate" title={pattern.name}>
+          {pattern.name}
+        </span>
+        <span
+          role="button"
+          tabIndex={0}
+          className={`shrink-0 transition-colors cursor-pointer ${
+            isFavorite ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-red-500'
+          }`}
+          onClick={(e) => {
+            e.stopPropagation()
+            onToggleFavorite(pattern.path, e)
+          }}
+          onKeyDown={(e) => {
+            if (e.key === 'Enter' || e.key === ' ') {
+              e.preventDefault()
+              e.stopPropagation()
+              onToggleFavorite(pattern.path, e as unknown as React.MouseEvent)
+            }
+          }}
+          title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
+        >
+          <span className="material-icons" style={{ fontSize: '16px' }}>
+            {isFavorite ? 'favorite' : 'favorite_border'}
+          </span>
+        </span>
+      </div>
+    </button>
+  )
+}

+ 768 - 0
frontend/src/pages/LEDPage.tsx

@@ -0,0 +1,768 @@
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { Link } from 'react-router-dom'
+import { toast } from 'sonner'
+import { apiClient } from '@/lib/apiClient'
+import { Button } from '@/components/ui/button'
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardHeader,
+  CardTitle,
+} from '@/components/ui/card'
+import { Label } from '@/components/ui/label'
+import { Separator } from '@/components/ui/separator'
+import { Switch } from '@/components/ui/switch'
+import { Slider } from '@/components/ui/slider'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { ColorPicker } from '@/components/ui/color-picker'
+
+// Types
+interface LedConfig {
+  provider: 'none' | 'wled' | 'dw_leds'
+  wled_ip?: string
+  num_leds?: number
+  gpio_pin?: number
+}
+
+interface DWLedsStatus {
+  connected: boolean
+  power_on: boolean
+  brightness: number
+  speed: number
+  intensity: number
+  current_effect: number
+  current_palette: number
+  num_leds: number
+  gpio_pin: number
+  colors: string[]
+  error?: string
+}
+
+interface EffectSettings {
+  effect_id: number
+  palette_id: number
+  speed: number
+  intensity: number
+  color1: string
+  color2: string
+  color3: string
+}
+
+export function LEDPage() {
+  const [ledConfig, setLedConfig] = useState<LedConfig | null>(null)
+  const [isLoading, setIsLoading] = useState(true)
+
+  // DW LEDs state
+  const [dwStatus, setDwStatus] = useState<DWLedsStatus | null>(null)
+  const [effects, setEffects] = useState<[number, string][]>([])
+  const [palettes, setPalettes] = useState<[number, string][]>([])
+  const [brightness, setBrightness] = useState(35)
+  const [speed, setSpeed] = useState(128)
+  const [speedInput, setSpeedInput] = useState('128')
+  const [intensity, setIntensity] = useState(128)
+  const [intensityInput, setIntensityInput] = useState('128')
+  const [selectedEffect, setSelectedEffect] = useState('')
+  const [selectedPalette, setSelectedPalette] = useState('')
+  const [color1, setColor1] = useState('#ff0000')
+  const [color2, setColor2] = useState('#000000')
+  const [color3, setColor3] = useState('#0000ff')
+
+  // Ref for debouncing color picker API calls
+  const colorDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  // Effect automation state
+  const [idleEffect, setIdleEffect] = useState<EffectSettings | null>(null)
+  const [playingEffect, setPlayingEffect] = useState<EffectSettings | null>(null)
+  const [idleTimeoutEnabled, setIdleTimeoutEnabled] = useState(false)
+  const [idleTimeoutMinutes, setIdleTimeoutMinutes] = useState(30)
+  const [idleTimeoutInput, setIdleTimeoutInput] = useState('30')
+
+  // Fetch LED configuration
+  useEffect(() => {
+    const fetchConfig = async () => {
+      try {
+        const data = await apiClient.get<{ provider?: string; wled_ip?: string; dw_led_num_leds?: number; dw_led_gpio_pin?: number }>('/get_led_config')
+        // Map backend response fields to our interface
+        setLedConfig({
+          provider: (data.provider as LedConfig['provider']) || 'none',
+          wled_ip: data.wled_ip,
+          num_leds: data.dw_led_num_leds,
+          gpio_pin: data.dw_led_gpio_pin,
+        })
+      } catch (error) {
+        console.error('Error fetching LED config:', error)
+      } finally {
+        setIsLoading(false)
+      }
+    }
+    fetchConfig()
+  }, [])
+
+  // Initialize DW LEDs when provider is dw_leds
+  useEffect(() => {
+    if (ledConfig?.provider === 'dw_leds') {
+      fetchDWLedsStatus()
+      fetchEffectsAndPalettes()
+      fetchEffectSettings()
+      fetchIdleTimeout()
+    }
+  }, [ledConfig])
+
+  const fetchDWLedsStatus = async () => {
+    try {
+      const data = await apiClient.get<DWLedsStatus>('/api/dw_leds/status')
+      setDwStatus(data)
+      if (data.connected) {
+        setBrightness(data.brightness || 35)
+        setSpeed(data.speed || 128)
+        setSpeedInput(String(data.speed || 128))
+        setIntensity(data.intensity || 128)
+        setIntensityInput(String(data.intensity || 128))
+        setSelectedEffect(String(data.current_effect || 0))
+        setSelectedPalette(String(data.current_palette || 0))
+        if (data.colors) {
+          setColor1(data.colors[0] || '#ff0000')
+          setColor2(data.colors[1] || '#000000')
+          setColor3(data.colors[2] || '#0000ff')
+        }
+      }
+    } catch (error) {
+      console.error('Error fetching DW LEDs status:', error)
+    }
+  }
+
+  const fetchEffectsAndPalettes = async () => {
+    try {
+      const [effectsData, palettesData] = await Promise.all([
+        apiClient.get<{ effects?: [number, string][] }>('/api/dw_leds/effects'),
+        apiClient.get<{ palettes?: [number, string][] }>('/api/dw_leds/palettes'),
+      ])
+
+      if (effectsData.effects) {
+        const sorted = [...effectsData.effects].sort((a, b) => a[1].localeCompare(b[1]))
+        setEffects(sorted)
+      }
+      if (palettesData.palettes) {
+        const sorted = [...palettesData.palettes].sort((a, b) => a[1].localeCompare(b[1]))
+        setPalettes(sorted)
+      }
+    } catch (error) {
+      console.error('Error fetching effects/palettes:', error)
+    }
+  }
+
+  const fetchEffectSettings = async () => {
+    try {
+      const data = await apiClient.get<{ idle_effect?: EffectSettings; playing_effect?: EffectSettings }>('/api/dw_leds/get_effect_settings')
+      setIdleEffect(data.idle_effect || null)
+      setPlayingEffect(data.playing_effect || null)
+    } catch (error) {
+      console.error('Error fetching effect settings:', error)
+    }
+  }
+
+  const fetchIdleTimeout = async () => {
+    try {
+      const data = await apiClient.get<{ enabled?: boolean; minutes?: number }>('/api/dw_leds/idle_timeout')
+      setIdleTimeoutEnabled(data.enabled || false)
+      setIdleTimeoutMinutes(data.minutes || 30)
+      setIdleTimeoutInput(String(data.minutes || 30))
+    } catch (error) {
+      console.error('Error fetching idle timeout:', error)
+    }
+  }
+
+  const handlePowerToggle = async () => {
+    try {
+      const data = await apiClient.post<{ connected?: boolean; power_on?: boolean; error?: string }>('/api/dw_leds/power', { state: 2 })
+      if (data.connected) {
+        toast.success(`Power ${data.power_on ? 'ON' : 'OFF'}`)
+        await fetchDWLedsStatus()
+      } else {
+        toast.error(data.error || 'Failed to toggle power')
+      }
+    } catch {
+      toast.error('Failed to toggle power')
+    }
+  }
+
+  const handleBrightnessChange = useCallback(async (value: number[]) => {
+    setBrightness(value[0])
+  }, [])
+
+  const handleBrightnessCommit = async (value: number[]) => {
+    try {
+      const data = await apiClient.post<{ connected?: boolean }>('/api/dw_leds/brightness', { value: value[0] })
+      if (data.connected) {
+        toast.success(`Brightness: ${value[0]}%`)
+      }
+    } catch {
+      toast.error('Failed to set brightness')
+    }
+  }
+
+  const handleSpeedChange = useCallback((value: number[]) => {
+    setSpeed(value[0])
+    setSpeedInput(String(value[0]))
+  }, [])
+
+  const handleSpeedCommit = async (value: number[]) => {
+    try {
+      await apiClient.post('/api/dw_leds/speed', { speed: value[0] })
+      toast.success(`Speed: ${value[0]}`)
+    } catch {
+      toast.error('Failed to set speed')
+    }
+  }
+
+  const handleIntensityChange = useCallback((value: number[]) => {
+    setIntensity(value[0])
+    setIntensityInput(String(value[0]))
+  }, [])
+
+  const handleIntensityCommit = async (value: number[]) => {
+    try {
+      await apiClient.post('/api/dw_leds/intensity', { intensity: value[0] })
+      toast.success(`Intensity: ${value[0]}`)
+    } catch {
+      toast.error('Failed to set intensity')
+    }
+  }
+
+  const handleEffectChange = async (value: string) => {
+    setSelectedEffect(value)
+    try {
+      const data = await apiClient.post<{ connected?: boolean; power_on?: boolean }>('/api/dw_leds/effect', { effect_id: parseInt(value) })
+      if (data.connected) {
+        toast.success('Effect changed')
+        if (data.power_on !== undefined) {
+          const powerOn = data.power_on
+          setDwStatus((prev) => prev ? { ...prev, power_on: powerOn } : null)
+        }
+      }
+    } catch {
+      toast.error('Failed to set effect')
+    }
+  }
+
+  const handlePaletteChange = async (value: string) => {
+    setSelectedPalette(value)
+    try {
+      const data = await apiClient.post<{ connected?: boolean }>('/api/dw_leds/palette', { palette_id: parseInt(value) })
+      if (data.connected) {
+        toast.success('Palette changed')
+      }
+    } catch {
+      toast.error('Failed to set palette')
+    }
+  }
+
+  const handleColorChange = (slot: 1 | 2 | 3, value: string) => {
+    // Update UI immediately for responsive feedback
+    if (slot === 1) setColor1(value)
+    else if (slot === 2) setColor2(value)
+    else setColor3(value)
+
+    // Clear any pending debounce timer
+    if (colorDebounceRef.current) {
+      clearTimeout(colorDebounceRef.current)
+    }
+
+    // Debounce API call by 300ms to prevent overwhelming the backend
+    colorDebounceRef.current = setTimeout(async () => {
+      try {
+        const hexToRgb = (hex: string) => {
+          const r = parseInt(hex.slice(1, 3), 16)
+          const g = parseInt(hex.slice(3, 5), 16)
+          const b = parseInt(hex.slice(5, 7), 16)
+          return [r, g, b]
+        }
+
+        const payload: Record<string, number[]> = {}
+        payload[`color${slot}`] = hexToRgb(value)
+
+        await apiClient.post('/api/dw_leds/colors', payload)
+      } catch (error) {
+        console.error('Failed to set color:', error)
+      }
+    }, 300)
+  }
+
+  const saveCurrentEffectSettings = async (type: 'idle' | 'playing') => {
+    try {
+      const settings = {
+        type,
+        effect_id: parseInt(selectedEffect) || 0,
+        palette_id: parseInt(selectedPalette) || 0,
+        speed,
+        intensity,
+        color1,
+        color2,
+        color3,
+      }
+
+      await apiClient.post('/api/dw_leds/save_effect_settings', settings)
+      toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} effect saved`)
+      await fetchEffectSettings()
+    } catch {
+      toast.error(`Failed to save ${type} effect`)
+    }
+  }
+
+  const clearEffectSettings = async (type: 'idle' | 'playing') => {
+    try {
+      await apiClient.post('/api/dw_leds/clear_effect_settings', { type })
+      toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} effect cleared`)
+      await fetchEffectSettings()
+    } catch {
+      toast.error(`Failed to clear ${type} effect`)
+    }
+  }
+
+  const saveIdleTimeout = async (enabled?: boolean, minutes?: number) => {
+    const finalEnabled = enabled !== undefined ? enabled : idleTimeoutEnabled
+    const finalMinutes = minutes !== undefined ? minutes : idleTimeoutMinutes
+    try {
+      await apiClient.post('/api/dw_leds/idle_timeout', { enabled: finalEnabled, minutes: finalMinutes })
+      toast.success(`Idle timeout ${finalEnabled ? 'enabled' : 'disabled'}`)
+    } catch {
+      toast.error('Failed to save idle timeout')
+    }
+  }
+
+  const handleIdleTimeoutToggle = async (checked: boolean) => {
+    setIdleTimeoutEnabled(checked)
+    await saveIdleTimeout(checked, idleTimeoutMinutes)
+  }
+
+  const formatEffectSettings = (settings: EffectSettings | null) => {
+    if (!settings) return 'Not configured'
+    const effectName = effects.find((e) => e[0] === settings.effect_id)?.[1] || settings.effect_id
+    const paletteName = palettes.find((p) => p[0] === settings.palette_id)?.[1] || settings.palette_id
+    return `${effectName} | ${paletteName} | Speed: ${settings.speed} | Intensity: ${settings.intensity}`
+  }
+
+  // Loading state
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center min-h-[60vh]">
+        <span className="material-icons-outlined animate-spin text-4xl text-muted-foreground">
+          sync
+        </span>
+      </div>
+    )
+  }
+
+  // Not configured state
+  if (!ledConfig || ledConfig.provider === 'none') {
+    return (
+      <div className="flex flex-col items-center justify-center min-h-[60vh] gap-6 text-center px-4">
+        <div className="p-4 rounded-full bg-muted">
+          <span className="material-icons-outlined text-5xl text-muted-foreground">
+            lightbulb
+          </span>
+        </div>
+        <div className="space-y-2">
+          <h1 className="text-xl sm:text-2xl font-bold">LED Controller Not Configured</h1>
+          <p className="text-sm sm:text-base text-muted-foreground max-w-md">
+            Configure your LED controller (WLED or DW LEDs) in the Settings page to control your lights.
+          </p>
+        </div>
+        <Button asChild className="gap-2">
+          <Link to="/settings?section=led">
+            <span className="material-icons-outlined">settings</span>
+            Go to Settings
+          </Link>
+        </Button>
+      </div>
+    )
+  }
+
+  // WLED iframe view
+  if (ledConfig.provider === 'wled' && ledConfig.wled_ip) {
+    return (
+      <div className="flex flex-col w-full py-4" style={{ height: 'calc(100vh - 180px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
+        <iframe
+          src={`http://${ledConfig.wled_ip}`}
+          className="w-full h-full rounded-lg border border-border"
+          title="WLED Control"
+        />
+      </div>
+    )
+  }
+
+  // DW LEDs control panel
+  return (
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
+      {/* Page Header */}
+      <div className="space-y-0.5 sm:space-y-1 pl-1">
+        <h1 className="text-xl font-semibold tracking-tight">LED Control</h1>
+        <p className="text-xs text-muted-foreground">DW LEDs - GPIO controlled LED strip</p>
+      </div>
+
+      <Separator />
+
+      {/* Main Control Grid - 2 columns on large screens */}
+      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+        {/* Left Column - Primary Controls */}
+        <div className="lg:col-span-2 space-y-6">
+          {/* Power & Status Card */}
+          <Card>
+            <CardContent className="pt-6">
+              <div className="flex flex-col sm:flex-row items-center gap-6">
+                {/* Power Button - Large and prominent */}
+                <div className="flex flex-col items-center gap-3">
+                  <button
+                    onClick={handlePowerToggle}
+                    className={`w-24 h-24 rounded-full flex items-center justify-center transition-all shadow-lg ${
+                      dwStatus?.power_on
+                        ? 'bg-green-500 hover:bg-green-600 shadow-green-500/30'
+                        : 'bg-muted hover:bg-muted/80'
+                    }`}
+                  >
+                    <span className={`material-icons text-4xl ${dwStatus?.power_on ? 'text-white' : 'text-muted-foreground'}`}>
+                      power_settings_new
+                    </span>
+                  </button>
+                  <span className={`text-sm font-medium ${dwStatus?.power_on ? 'text-green-600' : 'text-muted-foreground'}`}>
+                    {dwStatus?.power_on ? 'ON' : 'OFF'}
+                  </span>
+                </div>
+
+                {/* Status & Brightness */}
+                <div className="flex-1 w-full space-y-4">
+                  {/* Connection Status */}
+                  <div className={`flex items-center gap-2 text-sm ${dwStatus?.connected ? 'text-green-600' : 'text-destructive'}`}>
+                    <span className="material-icons-outlined text-base">
+                      {dwStatus?.connected ? 'check_circle' : 'error'}
+                    </span>
+                    {dwStatus?.connected
+                      ? `${dwStatus.num_leds} LEDs on GPIO ${dwStatus.gpio_pin}`
+                      : 'Not connected'}
+                  </div>
+
+                  {/* Brightness Slider */}
+                  <div className="space-y-2">
+                    <div className="flex justify-between items-center">
+                      <Label>
+                        <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">brightness_6</span>
+                        Brightness
+                      </Label>
+                      <span className="text-sm font-medium">{brightness}%</span>
+                    </div>
+                    <Slider
+                      value={[brightness]}
+                      onValueChange={handleBrightnessChange}
+                      onValueCommit={handleBrightnessCommit}
+                      max={100}
+                      step={1}
+                    />
+                  </div>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Effects Card */}
+          <Card>
+            <CardHeader className="pb-3">
+              <CardTitle className="text-lg flex items-center gap-2">
+                <span className="material-icons-outlined text-muted-foreground">auto_awesome</span>
+                Effects & Palettes
+              </CardTitle>
+            </CardHeader>
+            <CardContent className="space-y-6">
+              {/* Effect & Palette Selects */}
+              <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+                <div className="space-y-2">
+                  <Label>Effect</Label>
+                  <Select value={selectedEffect} onValueChange={handleEffectChange}>
+                    <SelectTrigger>
+                      <SelectValue placeholder="Select effect..." />
+                    </SelectTrigger>
+                    <SelectContent>
+                      {effects.map(([id, name]) => (
+                        <SelectItem key={id} value={String(id)}>
+                          {name}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                </div>
+                <div className="space-y-2">
+                  <Label>Palette</Label>
+                  <Select value={selectedPalette} onValueChange={handlePaletteChange}>
+                    <SelectTrigger>
+                      <SelectValue placeholder="Select palette..." />
+                    </SelectTrigger>
+                    <SelectContent>
+                      {palettes.map(([id, name]) => (
+                        <SelectItem key={id} value={String(id)}>
+                          {name}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                </div>
+              </div>
+
+              {/* Speed and Intensity in styled boxes */}
+              <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+                <div className="p-4 rounded-lg border space-y-3">
+                  <div className="flex justify-between items-center">
+                    <Label>
+                      <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">speed</span>
+                      Speed
+                    </Label>
+                    <Input
+                      type="text"
+                      inputMode="numeric"
+                      value={speedInput}
+                      onChange={(e) => {
+                        const val = e.target.value.replace(/[^0-9]/g, '')
+                        setSpeedInput(val)
+                      }}
+                      onBlur={() => {
+                        const num = Math.min(255, Math.max(0, parseInt(speedInput) || 0))
+                        setSpeed(num)
+                        setSpeedInput(String(num))
+                        handleSpeedCommit([num])
+                      }}
+                      onKeyDown={(e) => {
+                        if (e.key === 'Enter') {
+                          const num = Math.min(255, Math.max(0, parseInt(speedInput) || 0))
+                          setSpeed(num)
+                          setSpeedInput(String(num))
+                          handleSpeedCommit([num])
+                        }
+                      }}
+                      className="w-16 h-7 text-center text-sm font-medium px-2"
+                    />
+                  </div>
+                  <Slider
+                    value={[speed]}
+                    onValueChange={handleSpeedChange}
+                    onValueCommit={handleSpeedCommit}
+                    max={255}
+                    step={1}
+                  />
+                </div>
+                <div className="p-4 rounded-lg border space-y-3">
+                  <div className="flex justify-between items-center">
+                    <Label>
+                      <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">tungsten</span>
+                      Intensity
+                    </Label>
+                    <Input
+                      type="text"
+                      inputMode="numeric"
+                      value={intensityInput}
+                      onChange={(e) => {
+                        const val = e.target.value.replace(/[^0-9]/g, '')
+                        setIntensityInput(val)
+                      }}
+                      onBlur={() => {
+                        const num = Math.min(255, Math.max(0, parseInt(intensityInput) || 0))
+                        setIntensity(num)
+                        setIntensityInput(String(num))
+                        handleIntensityCommit([num])
+                      }}
+                      onKeyDown={(e) => {
+                        if (e.key === 'Enter') {
+                          const num = Math.min(255, Math.max(0, parseInt(intensityInput) || 0))
+                          setIntensity(num)
+                          setIntensityInput(String(num))
+                          handleIntensityCommit([num])
+                        }
+                      }}
+                      className="w-16 h-7 text-center text-sm font-medium px-2"
+                    />
+                  </div>
+                  <Slider
+                    value={[intensity]}
+                    onValueChange={handleIntensityChange}
+                    onValueCommit={handleIntensityCommit}
+                    max={255}
+                    step={1}
+                  />
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+
+        {/* Right Column - Colors & Quick Settings */}
+        <div className="flex flex-col gap-6">
+          {/* Colors Card */}
+          <Card className="flex-1 flex flex-col">
+            <CardHeader className="pb-3">
+              <CardTitle className="text-lg flex items-center gap-2">
+                <span className="material-icons-outlined text-muted-foreground">palette</span>
+                Colors
+              </CardTitle>
+            </CardHeader>
+            <CardContent className="flex-1 flex items-center justify-center">
+              <div className="flex justify-around w-full">
+                <div className="flex flex-col items-center gap-2">
+                  <ColorPicker
+                    value={color1}
+                    onChange={(color) => handleColorChange(1, color)}
+                  />
+                  <span className="text-xs text-muted-foreground">Primary</span>
+                </div>
+                <div className="flex flex-col items-center gap-2">
+                  <ColorPicker
+                    value={color2}
+                    onChange={(color) => handleColorChange(2, color)}
+                  />
+                  <span className="text-xs text-muted-foreground">Secondary</span>
+                </div>
+                <div className="flex flex-col items-center gap-2">
+                  <ColorPicker
+                    value={color3}
+                    onChange={(color) => handleColorChange(3, color)}
+                  />
+                  <span className="text-xs text-muted-foreground">Accent</span>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Auto Turn Off */}
+          <Card className="flex-1 flex flex-col">
+            <CardHeader className="pb-3">
+              <CardTitle className="text-lg flex items-center gap-2">
+                <span className="material-icons-outlined text-muted-foreground">schedule</span>
+                Auto Turn Off
+              </CardTitle>
+            </CardHeader>
+            <CardContent className="flex-1 flex flex-col justify-center space-y-4">
+              <div className="flex items-center justify-between">
+                <span className="text-sm text-muted-foreground">Enable timeout</span>
+                <Switch
+                  checked={idleTimeoutEnabled}
+                  onCheckedChange={handleIdleTimeoutToggle}
+                />
+              </div>
+              {idleTimeoutEnabled && (
+                <div className="flex items-center gap-2">
+                  <Input
+                    type="text"
+                    inputMode="numeric"
+                    value={idleTimeoutInput}
+                    onChange={(e) => {
+                      const val = e.target.value.replace(/[^0-9]/g, '')
+                      setIdleTimeoutInput(val)
+                    }}
+                    onBlur={() => {
+                      const num = Math.min(1440, Math.max(1, parseInt(idleTimeoutInput) || 30))
+                      setIdleTimeoutMinutes(num)
+                      setIdleTimeoutInput(String(num))
+                    }}
+                    onKeyDown={(e) => {
+                      if (e.key === 'Enter') {
+                        const num = Math.min(1440, Math.max(1, parseInt(idleTimeoutInput) || 30))
+                        setIdleTimeoutMinutes(num)
+                        setIdleTimeoutInput(String(num))
+                        saveIdleTimeout(idleTimeoutEnabled, num)
+                      }
+                    }}
+                    className="w-20"
+                  />
+                  <span className="text-sm text-muted-foreground flex-1">minutes</span>
+                  <Button size="sm" onClick={() => saveIdleTimeout()}>
+                    Save
+                  </Button>
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        </div>
+      </div>
+
+      {/* Automation Settings - Full Width */}
+      <Card>
+        <CardHeader className="pb-3">
+          <CardTitle className="text-lg flex items-center gap-2">
+            <span className="material-icons-outlined text-muted-foreground">smart_toy</span>
+            Effect Automation
+          </CardTitle>
+          <CardDescription>
+            Save current settings to automatically apply when table state changes
+          </CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+            {/* Playing Effect */}
+            <div className="p-4 rounded-lg border space-y-3">
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <span className="material-icons text-green-600">play_circle</span>
+                  <span className="font-medium">While Playing</span>
+                </div>
+              </div>
+              <div className="text-xs text-muted-foreground p-2 bg-muted/50 rounded border min-h-[40px]">
+                {formatEffectSettings(playingEffect)}
+              </div>
+              <div className="flex gap-2">
+                <Button
+                  size="sm"
+                  onClick={() => saveCurrentEffectSettings('playing')}
+                  className="flex-1 gap-1"
+                >
+                  <span className="material-icons text-sm">save</span>
+                  Save Current
+                </Button>
+                <Button
+                  size="sm"
+                  variant="secondary"
+                  onClick={() => clearEffectSettings('playing')}
+                >
+                  Clear
+                </Button>
+              </div>
+            </div>
+
+            {/* Idle Effect */}
+            <div className="p-4 rounded-lg border space-y-3">
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <span className="material-icons text-blue-600">bedtime</span>
+                  <span className="font-medium">When Idle</span>
+                </div>
+              </div>
+              <div className="text-xs text-muted-foreground p-2 bg-muted/50 rounded border min-h-[40px]">
+                {formatEffectSettings(idleEffect)}
+              </div>
+              <div className="flex gap-2">
+                <Button
+                  size="sm"
+                  onClick={() => saveCurrentEffectSettings('idle')}
+                  className="flex-1 gap-1"
+                >
+                  <span className="material-icons text-sm">save</span>
+                  Save Current
+                </Button>
+                <Button
+                  size="sm"
+                  variant="secondary"
+                  onClick={() => clearEffectSettings('idle')}
+                >
+                  Clear
+                </Button>
+              </div>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  )
+}

+ 1100 - 0
frontend/src/pages/PlaylistsPage.tsx

@@ -0,0 +1,1100 @@
+import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
+import { toast } from 'sonner'
+import { Trash2 } from 'lucide-react'
+import { apiClient } from '@/lib/apiClient'
+import {
+  initPreviewCacheDB,
+  getPreviewsFromCache,
+  savePreviewToCache,
+} from '@/lib/previewCache'
+import { fuzzyMatch } from '@/lib/utils'
+import { useOnBackendConnected } from '@/hooks/useBackendConnection'
+import type { PatternMetadata, PreviewData, SortOption, PreExecution, RunMode } from '@/lib/types'
+import { preExecutionOptions } from '@/lib/types'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Separator } from '@/components/ui/separator'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+  DialogFooter,
+} from '@/components/ui/dialog'
+
+export function PlaylistsPage() {
+  // Playlists state
+  const [playlists, setPlaylists] = useState<string[]>([])
+  const [selectedPlaylist, setSelectedPlaylist] = useState<string | null>(() => {
+    return localStorage.getItem('playlist-selected')
+  })
+  const [playlistPatterns, setPlaylistPatterns] = useState<string[]>([])
+  const [isLoadingPlaylists, setIsLoadingPlaylists] = useState(true)
+
+  // All patterns for the picker modal
+  const [allPatterns, setAllPatterns] = useState<PatternMetadata[]>([])
+  const [previews, setPreviews] = useState<Record<string, PreviewData>>({})
+
+  // Pattern picker modal state
+  const [isPickerOpen, setIsPickerOpen] = useState(false)
+  const [selectedPatternPaths, setSelectedPatternPaths] = useState<Set<string>>(new Set())
+  const [searchQuery, setSearchQuery] = useState('')
+  const [selectedCategory, setSelectedCategory] = useState<string>('all')
+  const [sortBy, setSortBy] = useState<SortOption>('name')
+  const [sortAsc, setSortAsc] = useState(true)
+
+  // Favorites state (loaded from "Favorites" playlist)
+  const [favorites, setFavorites] = useState<Set<string>>(new Set())
+
+  // Create/Rename playlist modal
+  const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
+  const [isRenameModalOpen, setIsRenameModalOpen] = useState(false)
+  const [newPlaylistName, setNewPlaylistName] = useState('')
+  const [playlistToRename, setPlaylistToRename] = useState<string | null>(null)
+
+  // Mobile view state - show content panel when a playlist is selected
+  const [mobileShowContent, setMobileShowContent] = useState(false)
+
+  // Swipe gesture to go back on mobile
+  const swipeTouchStartRef = useRef<{ x: number; y: number } | null>(null)
+  const handleSwipeTouchStart = (e: React.TouchEvent) => {
+    swipeTouchStartRef.current = {
+      x: e.touches[0].clientX,
+      y: e.touches[0].clientY,
+    }
+  }
+  const handleSwipeTouchEnd = (e: React.TouchEvent) => {
+    if (!swipeTouchStartRef.current || !mobileShowContent) return
+    const deltaX = e.changedTouches[0].clientX - swipeTouchStartRef.current.x
+    const deltaY = e.changedTouches[0].clientY - swipeTouchStartRef.current.y
+
+    // Swipe right to go back (positive X, more horizontal than vertical)
+    if (deltaX > 80 && deltaX > Math.abs(deltaY)) {
+      setMobileShowContent(false)
+    }
+    swipeTouchStartRef.current = null
+  }
+
+  // Playback settings - initialized from localStorage
+  const [runMode, setRunMode] = useState<RunMode>(() => {
+    const cached = localStorage.getItem('playlist-runMode')
+    return (cached === 'single' || cached === 'indefinite') ? cached : 'single'
+  })
+  const [shuffle, setShuffle] = useState(() => {
+    return localStorage.getItem('playlist-shuffle') === 'true'
+  })
+  const [pauseTime, setPauseTime] = useState(() => {
+    const cached = localStorage.getItem('playlist-pauseTime')
+    return cached ? Number(cached) : 5
+  })
+  const [pauseUnit, setPauseUnit] = useState<'sec' | 'min' | 'hr'>(() => {
+    const cached = localStorage.getItem('playlist-pauseUnit')
+    return (cached === 'sec' || cached === 'min' || cached === 'hr') ? cached : 'min'
+  })
+  const [clearPattern, setClearPattern] = useState<PreExecution>(() => {
+    const cached = localStorage.getItem('preExecution')
+    return (cached as PreExecution) || 'adaptive'
+  })
+
+  // Persist playback settings to localStorage
+  useEffect(() => {
+    localStorage.setItem('playlist-runMode', runMode)
+  }, [runMode])
+  useEffect(() => {
+    localStorage.setItem('playlist-shuffle', String(shuffle))
+  }, [shuffle])
+  useEffect(() => {
+    localStorage.setItem('playlist-pauseTime', String(pauseTime))
+  }, [pauseTime])
+  useEffect(() => {
+    localStorage.setItem('playlist-pauseUnit', pauseUnit)
+  }, [pauseUnit])
+  useEffect(() => {
+    localStorage.setItem('preExecution', clearPattern)
+  }, [clearPattern])
+
+  // Persist selected playlist to localStorage
+  useEffect(() => {
+    if (selectedPlaylist) {
+      localStorage.setItem('playlist-selected', selectedPlaylist)
+    } else {
+      localStorage.removeItem('playlist-selected')
+    }
+  }, [selectedPlaylist])
+
+  // Validate cached playlist exists and load its patterns after playlists load
+  const initialLoadDoneRef = useRef(false)
+  useEffect(() => {
+    if (isLoadingPlaylists) return
+
+    if (selectedPlaylist) {
+      if (playlists.includes(selectedPlaylist)) {
+        // Load patterns for cached playlist on initial load only
+        if (!initialLoadDoneRef.current) {
+          initialLoadDoneRef.current = true
+          fetchPlaylistPatterns(selectedPlaylist)
+        }
+      } else {
+        // Cached playlist no longer exists
+        setSelectedPlaylist(null)
+      }
+    }
+  }, [isLoadingPlaylists, playlists, selectedPlaylist])
+
+  // Close modals when playback starts
+  useEffect(() => {
+    const handlePlaybackStarted = () => {
+      setIsPickerOpen(false)
+      setIsCreateModalOpen(false)
+      setIsRenameModalOpen(false)
+    }
+    window.addEventListener('playback-started', handlePlaybackStarted)
+    return () => window.removeEventListener('playback-started', handlePlaybackStarted)
+  }, [])
+  const [isRunning, setIsRunning] = useState(false)
+
+  // Convert pause time to seconds based on unit
+  const getPauseTimeInSeconds = () => {
+    switch (pauseUnit) {
+      case 'hr':
+        return pauseTime * 3600
+      case 'min':
+        return pauseTime * 60
+      default:
+        return pauseTime
+    }
+  }
+
+  // Preview loading
+  const pendingPreviewsRef = useRef<Set<string>>(new Set())
+  const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const abortControllerRef = useRef<AbortController | null>(null)
+
+  // Initialize and fetch data
+  useEffect(() => {
+    initPreviewCacheDB().catch(() => {})
+    fetchPlaylists()
+    fetchAllPatterns()
+    loadFavorites()
+
+    // Cleanup on unmount: abort in-flight requests and clear pending queue
+    return () => {
+      if (batchTimeoutRef.current) {
+        clearTimeout(batchTimeoutRef.current)
+      }
+      if (abortControllerRef.current) {
+        abortControllerRef.current.abort()
+      }
+      pendingPreviewsRef.current.clear()
+    }
+  }, [])
+
+  // Refetch when backend reconnects
+  useOnBackendConnected(() => {
+    fetchPlaylists()
+    fetchAllPatterns()
+    loadFavorites()
+  })
+
+  const fetchPlaylists = async () => {
+    setIsLoadingPlaylists(true)
+    try {
+      const data = await apiClient.get<string[]>('/list_all_playlists')
+      // Backend returns array directly, not { playlists: [...] }
+      setPlaylists(Array.isArray(data) ? data : [])
+    } catch (error) {
+      console.error('Error fetching playlists:', error)
+      toast.error('Failed to load playlists')
+    } finally {
+      setIsLoadingPlaylists(false)
+    }
+  }
+
+  const fetchPlaylistPatterns = async (name: string) => {
+    try {
+      const data = await apiClient.get<{ files: string[] }>(`/get_playlist?name=${encodeURIComponent(name)}`)
+      setPlaylistPatterns(data.files || [])
+
+      // Previews are now lazy-loaded via IntersectionObserver in LazyPatternPreview
+    } catch (error) {
+      console.error('Error fetching playlist:', error)
+      toast.error('Failed to load playlist')
+      setPlaylistPatterns([])
+    }
+  }
+
+  const fetchAllPatterns = async () => {
+    try {
+      const data = await apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata')
+      setAllPatterns(data)
+    } catch (error) {
+      console.error('Error fetching patterns:', error)
+    }
+  }
+
+  // Load favorites from "Favorites" playlist
+  const loadFavorites = async () => {
+    try {
+      const playlist = await apiClient.get<{ files?: string[] }>('/get_playlist?name=Favorites')
+      setFavorites(new Set(playlist.files || []))
+    } catch {
+      // Favorites playlist doesn't exist yet - that's OK
+    }
+  }
+
+  // Preview loading functions (similar to BrowsePage)
+  const loadPreviewsForPaths = async (paths: string[]) => {
+    const cachedPreviews = await getPreviewsFromCache(paths)
+
+    if (cachedPreviews.size > 0) {
+      const cachedData: Record<string, PreviewData> = {}
+      cachedPreviews.forEach((previewData, path) => {
+        cachedData[path] = previewData
+      })
+      setPreviews(prev => ({ ...prev, ...cachedData }))
+    }
+
+    const uncached = paths.filter(p => !cachedPreviews.has(p))
+    if (uncached.length > 0) {
+      fetchPreviewsBatch(uncached)
+    }
+  }
+
+  const fetchPreviewsBatch = async (paths: string[]) => {
+    const BATCH_SIZE = 10 // Process 10 patterns at a time to avoid overwhelming the backend
+
+    // Create new AbortController for this batch of requests
+    abortControllerRef.current = new AbortController()
+    const signal = abortControllerRef.current.signal
+
+    // Process in batches
+    for (let i = 0; i < paths.length; i += BATCH_SIZE) {
+      // Check if aborted before each batch
+      if (signal.aborted) break
+
+      const batch = paths.slice(i, i + BATCH_SIZE)
+
+      try {
+        const data = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batch }, signal)
+
+        const newPreviews: Record<string, PreviewData> = {}
+        for (const [path, previewData] of Object.entries(data)) {
+          newPreviews[path] = previewData as PreviewData
+          // Only cache valid previews (with image_data and no error)
+          if (previewData && !(previewData as PreviewData).error) {
+            savePreviewToCache(path, previewData as PreviewData)
+          }
+        }
+        setPreviews(prev => ({ ...prev, ...newPreviews }))
+      } catch (error) {
+        // Stop processing if aborted, otherwise continue with next batch
+        if (error instanceof Error && error.name === 'AbortError') break
+        console.error('Error fetching previews batch:', error)
+      }
+
+      // Small delay between batches to reduce backend load
+      if (i + BATCH_SIZE < paths.length) {
+        await new Promise((resolve) => setTimeout(resolve, 100))
+      }
+    }
+  }
+
+  const requestPreview = useCallback((path: string) => {
+    if (previews[path] || pendingPreviewsRef.current.has(path)) return
+
+    pendingPreviewsRef.current.add(path)
+
+    if (batchTimeoutRef.current) {
+      clearTimeout(batchTimeoutRef.current)
+    }
+
+    batchTimeoutRef.current = setTimeout(() => {
+      const pathsToFetch = Array.from(pendingPreviewsRef.current)
+      pendingPreviewsRef.current.clear()
+      if (pathsToFetch.length > 0) {
+        loadPreviewsForPaths(pathsToFetch)
+      }
+    }, 100)
+  }, [previews])
+
+  // Playlist CRUD operations
+  const handleSelectPlaylist = (name: string) => {
+    setSelectedPlaylist(name)
+    fetchPlaylistPatterns(name)
+    setMobileShowContent(true) // Show content panel on mobile
+  }
+
+  // Go back to playlist list on mobile
+  const handleMobileBack = () => {
+    setMobileShowContent(false)
+  }
+
+  const handleCreatePlaylist = async () => {
+    if (!newPlaylistName.trim()) {
+      toast.error('Please enter a playlist name')
+      return
+    }
+
+    const name = newPlaylistName.trim()
+    try {
+      await apiClient.post('/create_playlist', { playlist_name: name, files: [] })
+      toast.success('Playlist created')
+      setIsCreateModalOpen(false)
+      setNewPlaylistName('')
+      await fetchPlaylists()
+      handleSelectPlaylist(name)
+    } catch (error) {
+      console.error('Create playlist error:', error)
+      toast.error(error instanceof Error ? error.message : 'Failed to create playlist')
+    }
+  }
+
+  const handleRenamePlaylist = async () => {
+    if (!playlistToRename || !newPlaylistName.trim()) return
+
+    try {
+      await apiClient.post('/rename_playlist', { old_name: playlistToRename, new_name: newPlaylistName.trim() })
+      toast.success('Playlist renamed')
+      setIsRenameModalOpen(false)
+      setNewPlaylistName('')
+      setPlaylistToRename(null)
+      fetchPlaylists()
+      if (selectedPlaylist === playlistToRename) {
+        setSelectedPlaylist(newPlaylistName.trim())
+      }
+    } catch (error) {
+      toast.error('Failed to rename playlist')
+    }
+  }
+
+  const handleDeletePlaylist = async (name: string) => {
+    if (!confirm(`Delete playlist "${name}"?`)) return
+
+    try {
+      await apiClient.delete('/delete_playlist', { playlist_name: name })
+      toast.success('Playlist deleted')
+      fetchPlaylists()
+      if (selectedPlaylist === name) {
+        setSelectedPlaylist(null)
+        setPlaylistPatterns([])
+      }
+    } catch (error) {
+      toast.error('Failed to delete playlist')
+    }
+  }
+
+  const handleRemovePattern = async (patternPath: string) => {
+    if (!selectedPlaylist) return
+
+    const newPatterns = playlistPatterns.filter(p => p !== patternPath)
+    try {
+      await apiClient.post('/modify_playlist', { playlist_name: selectedPlaylist, files: newPatterns })
+      setPlaylistPatterns(newPatterns)
+      toast.success('Pattern removed')
+    } catch (error) {
+      toast.error('Failed to remove pattern')
+    }
+  }
+
+  // Pattern picker modal
+  const openPatternPicker = () => {
+    setSelectedPatternPaths(new Set(playlistPatterns))
+    setSearchQuery('')
+    setIsPickerOpen(true)
+    // Previews are lazy-loaded via IntersectionObserver in LazyPatternPreview
+  }
+
+  const handleSavePatterns = async () => {
+    if (!selectedPlaylist) return
+
+    const newPatterns = Array.from(selectedPatternPaths)
+    try {
+      await apiClient.post('/modify_playlist', { playlist_name: selectedPlaylist, files: newPatterns })
+      setPlaylistPatterns(newPatterns)
+      setIsPickerOpen(false)
+      toast.success('Playlist updated')
+      // Previews are lazy-loaded via IntersectionObserver
+    } catch (error) {
+      toast.error('Failed to update playlist')
+    }
+  }
+
+  const togglePatternSelection = (path: string) => {
+    setSelectedPatternPaths(prev => {
+      const next = new Set(prev)
+      if (next.has(path)) {
+        next.delete(path)
+      } else {
+        next.add(path)
+      }
+      return next
+    })
+  }
+
+  // Run playlist
+  const handleRunPlaylist = async () => {
+    if (!selectedPlaylist || playlistPatterns.length === 0) return
+
+    setIsRunning(true)
+    try {
+      await apiClient.post('/run_playlist', {
+        playlist_name: selectedPlaylist,
+        run_mode: runMode === 'indefinite' ? 'indefinite' : 'single',
+        pause_time: getPauseTimeInSeconds(),
+        clear_pattern: clearPattern,
+        shuffle: shuffle,
+      })
+      toast.success(`Started playlist: ${selectedPlaylist}`)
+      // Trigger Now Playing bar to open
+      window.dispatchEvent(new CustomEvent('playback-started'))
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : 'Failed to run playlist')
+    } finally {
+      setIsRunning(false)
+    }
+  }
+
+  // Filter and sort patterns for picker
+  const categories = useMemo(() => {
+    const cats = new Set(allPatterns.map(p => p.category))
+    return ['all', ...Array.from(cats).sort()]
+  }, [allPatterns])
+
+  const filteredPatterns = useMemo(() => {
+    let filtered = allPatterns
+
+    if (searchQuery) {
+      filtered = filtered.filter(p => fuzzyMatch(p.name, searchQuery))
+    }
+
+    if (selectedCategory !== 'all') {
+      filtered = filtered.filter(p => p.category === selectedCategory)
+    }
+
+    filtered = [...filtered].sort((a, b) => {
+      let cmp = 0
+      switch (sortBy) {
+        case 'name':
+          cmp = a.name.localeCompare(b.name)
+          break
+        case 'date':
+          cmp = a.date_modified - b.date_modified
+          break
+        case 'size':
+          cmp = a.coordinates_count - b.coordinates_count
+          break
+        case 'favorites': {
+          const aFav = favorites.has(a.path) ? 1 : 0
+          const bFav = favorites.has(b.path) ? 1 : 0
+          cmp = bFav - aFav // Favorites first
+          if (cmp === 0) {
+            cmp = a.name.localeCompare(b.name) // Then by name
+          }
+          break
+        }
+      }
+      return sortAsc ? cmp : -cmp
+    })
+
+    return filtered
+  }, [allPatterns, searchQuery, selectedCategory, sortBy, sortAsc, favorites])
+
+  // Get pattern name from path
+  const getPatternName = (path: string) => {
+    const pattern = allPatterns.find(p => p.path === path)
+    return pattern?.name || path.split('/').pop()?.replace('.thr', '') || path
+  }
+
+  // Get preview URL (backend already returns full data URL)
+  const getPreviewUrl = (path: string) => {
+    const preview = previews[path]
+    return preview?.image_data || null
+  }
+
+  return (
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4 overflow-hidden" style={{ height: 'calc(100dvh - 14rem - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
+      {/* Page Header */}
+      <div className="space-y-0.5 sm:space-y-1 shrink-0 pl-1">
+        <h1 className="text-xl font-semibold tracking-tight">Playlists</h1>
+        <p className="text-xs text-muted-foreground">
+          Create and manage pattern playlists
+        </p>
+      </div>
+
+      <Separator className="shrink-0" />
+
+      {/* Main Content Area */}
+      <div className="flex flex-col lg:flex-row gap-4 flex-1 min-h-0 relative overflow-hidden">
+        {/* Playlists Sidebar - Full screen on mobile, sidebar on desktop */}
+        <aside className={`w-full lg:w-64 shrink-0 bg-card border rounded-lg flex flex-col h-full overflow-hidden transition-transform duration-300 ease-in-out ${
+          mobileShowContent ? '-translate-x-full lg:translate-x-0 absolute lg:relative inset-0 lg:inset-auto' : 'translate-x-0'
+        }`}>
+          <div className="flex items-center justify-between px-3 py-2.5 border-b shrink-0">
+            <div>
+              <h2 className="text-lg font-semibold">My Playlists</h2>
+              <p className="text-sm text-muted-foreground">{playlists.length} playlist{playlists.length !== 1 ? 's' : ''}</p>
+            </div>
+            <Button
+              variant="ghost"
+              size="icon"
+              className="h-8 w-8"
+              onClick={() => {
+                setNewPlaylistName('')
+                setIsCreateModalOpen(true)
+              }}
+            >
+              <span className="material-icons-outlined text-xl">add</span>
+            </Button>
+          </div>
+
+          <nav className="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
+          {isLoadingPlaylists ? (
+            <div className="flex items-center justify-center py-8 text-muted-foreground">
+              <span className="text-sm">Loading...</span>
+            </div>
+          ) : playlists.length === 0 ? (
+            <div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
+              <span className="material-icons-outlined text-3xl">playlist_add</span>
+              <span className="text-sm">No playlists yet</span>
+            </div>
+          ) : (
+            playlists.map(name => (
+              <div
+                key={name}
+                className={`group flex items-center justify-between rounded-lg px-3 py-2 cursor-pointer transition-colors ${
+                  selectedPlaylist === name
+                    ? 'bg-accent text-accent-foreground'
+                    : 'hover:bg-muted text-muted-foreground'
+                }`}
+                onClick={() => handleSelectPlaylist(name)}
+              >
+                <div className="flex items-center gap-2 min-w-0">
+                  <span className="material-icons-outlined text-lg">playlist_play</span>
+                  <span className="truncate text-sm font-medium">{name}</span>
+                </div>
+                <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
+                  <Button
+                    variant="ghost"
+                    size="icon-sm"
+                    className="h-7 w-7"
+                    onClick={(e) => {
+                      e.stopPropagation()
+                      setPlaylistToRename(name)
+                      setNewPlaylistName(name)
+                      setIsRenameModalOpen(true)
+                    }}
+                  >
+                    <span className="material-icons-outlined text-base">edit</span>
+                  </Button>
+                  <Button
+                    variant="ghost"
+                    size="icon-sm"
+                    className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/20"
+                    onClick={(e) => {
+                      e.stopPropagation()
+                      handleDeletePlaylist(name)
+                    }}
+                  >
+                    <Trash2 className="h-4 w-4" />
+                  </Button>
+                </div>
+              </div>
+            ))
+          )}
+        </nav>
+      </aside>
+
+        {/* Main Content - Slides in from right on mobile, swipe right to go back */}
+        <main
+          className={`flex-1 bg-card border rounded-lg flex flex-col overflow-hidden min-h-0 relative transition-transform duration-300 ease-in-out ${
+            mobileShowContent ? 'translate-x-0' : 'translate-x-full lg:translate-x-0 absolute lg:relative inset-0 lg:inset-auto'
+          }`}
+          onTouchStart={handleSwipeTouchStart}
+          onTouchEnd={handleSwipeTouchEnd}
+        >
+          {/* Header */}
+          <header className="flex items-center justify-between px-3 py-2.5 border-b shrink-0">
+            <div className="flex items-center gap-2 min-w-0">
+              {/* Back button - mobile only */}
+              <Button
+                variant="ghost"
+                size="icon"
+                className="h-8 w-8 lg:hidden shrink-0"
+                onClick={handleMobileBack}
+              >
+                <span className="material-icons-outlined">arrow_back</span>
+              </Button>
+              <div className="min-w-0">
+                <h2 className="text-lg font-semibold truncate">
+                  {selectedPlaylist || 'Select a Playlist'}
+                </h2>
+                {selectedPlaylist && playlistPatterns.length > 0 && (
+                  <p className="text-sm text-muted-foreground">
+                    {playlistPatterns.length} pattern{playlistPatterns.length !== 1 ? 's' : ''}
+                  </p>
+                )}
+              </div>
+            </div>
+            <Button
+              onClick={openPatternPicker}
+              disabled={!selectedPlaylist}
+              size="sm"
+              className="gap-2"
+            >
+              <span className="material-icons-outlined text-base">add</span>
+              <span className="hidden sm:inline">Add Patterns</span>
+            </Button>
+          </header>
+
+          {/* Patterns List */}
+          <div className={`flex-1 overflow-y-auto p-4 min-h-0 ${selectedPlaylist ? 'pb-28 sm:pb-24' : ''}`}>
+            {!selectedPlaylist ? (
+              <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
+                <div className="p-4 rounded-full bg-muted">
+                  <span className="material-icons-outlined text-5xl">touch_app</span>
+                </div>
+                <div className="text-center">
+                  <p className="font-medium">No playlist selected</p>
+                  <p className="text-sm">Select a playlist from the sidebar to view its patterns</p>
+                </div>
+              </div>
+            ) : playlistPatterns.length === 0 ? (
+              <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
+                <div className="p-4 rounded-full bg-muted">
+                  <span className="material-icons-outlined text-5xl">library_music</span>
+                </div>
+                <div className="text-center">
+                  <p className="font-medium">Empty playlist</p>
+                  <p className="text-sm">Add patterns to get started</p>
+                </div>
+                <Button variant="secondary" className="mt-2 gap-2" onClick={openPatternPicker}>
+                  <span className="material-icons-outlined text-base">add</span>
+                  Add Patterns
+                </Button>
+              </div>
+            ) : (
+              <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
+                {playlistPatterns.map((path, index) => (
+                  <div
+                    key={`${path}-${index}`}
+                    className="flex flex-col items-center gap-1.5 sm:gap-2 group"
+                  >
+                    <div className="relative w-full aspect-square">
+                      <div className="w-full h-full rounded-full overflow-hidden border bg-muted hover:ring-2 hover:ring-primary hover:ring-offset-2 hover:ring-offset-background transition-all cursor-pointer">
+                        <LazyPatternPreview
+                          path={path}
+                          previewUrl={getPreviewUrl(path)}
+                          requestPreview={requestPreview}
+                          alt={getPatternName(path)}
+                        />
+                      </div>
+                      <button
+                        className="absolute -top-0.5 -right-0.5 sm:-top-1 sm:-right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shadow-sm z-10"
+                        onClick={() => handleRemovePattern(path)}
+                        title="Remove from playlist"
+                      >
+                        <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
+                      </button>
+                    </div>
+                    <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
+                  </div>
+                ))}
+              </div>
+            )}
+          </div>
+
+          {/* Floating Playback Controls */}
+          {selectedPlaylist && (
+            <div className="absolute bottom-0 left-0 right-0 pointer-events-none z-20">
+              {/* Blur backdrop */}
+              <div className="h-20 bg-gradient-to-t" />
+
+              {/* Controls container */}
+              <div className="absolute bottom-4 left-0 right-0 flex items-center justify-center gap-3 px-4 pointer-events-auto">
+                {/* Control pill */}
+                <div className="flex items-center h-12 sm:h-14 bg-card rounded-full shadow-xl border px-1.5 sm:px-2">
+                  {/* Shuffle & Loop */}
+                  <div className="flex items-center px-1 sm:px-2 border-r border-border gap-0.5 sm:gap-1">
+                    <button
+                      onClick={() => setShuffle(!shuffle)}
+                      className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition ${
+                        shuffle
+                          ? 'text-primary bg-primary/10'
+                          : 'text-muted-foreground hover:bg-muted'
+                      }`}
+                      title="Shuffle"
+                    >
+                      <span className="material-icons-outlined text-lg sm:text-xl">shuffle</span>
+                    </button>
+                    <button
+                      onClick={() => setRunMode(runMode === 'indefinite' ? 'single' : 'indefinite')}
+                      className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition ${
+                        runMode === 'indefinite'
+                          ? 'text-primary bg-primary/10'
+                          : 'text-muted-foreground hover:bg-muted'
+                      }`}
+                      title={runMode === 'indefinite' ? 'Loop mode' : 'Play once mode'}
+                    >
+                      <span className="material-icons-outlined text-lg sm:text-xl">repeat</span>
+                    </button>
+                  </div>
+
+                  {/* Pause Time */}
+                  <div className="flex items-center px-2 sm:px-3 gap-2 sm:gap-3 border-r border-border">
+                    <span className="text-[10px] sm:text-xs font-semibold text-muted-foreground tracking-wider hidden sm:block">Pause</span>
+                    <div className="flex items-center gap-1">
+                      <Button
+                        variant="secondary"
+                        size="icon"
+                        className="w-7 h-7 sm:w-8 sm:h-8"
+                        onClick={() => {
+                          const step = pauseUnit === 'hr' ? 0.5 : 1
+                          setPauseTime(Math.max(0, pauseTime - step))
+                        }}
+                      >
+                        <span className="material-icons-outlined text-sm">remove</span>
+                      </Button>
+                      <button
+                        onClick={() => {
+                          const units: ('sec' | 'min' | 'hr')[] = ['sec', 'min', 'hr']
+                          const currentIndex = units.indexOf(pauseUnit)
+                          setPauseUnit(units[(currentIndex + 1) % units.length])
+                        }}
+                        className="relative flex items-center justify-center min-w-14 sm:min-w-16 px-1 text-xs sm:text-sm font-bold hover:text-primary transition"
+                        title="Click to change unit"
+                      >
+                        {pauseTime}{pauseUnit === 'sec' ? 's' : pauseUnit === 'min' ? 'm' : 'h'}
+                        <span className="material-icons-outlined text-xs opacity-50 scale-75 ml-0.5">swap_vert</span>
+                      </button>
+                      <Button
+                        variant="secondary"
+                        size="icon"
+                        className="w-7 h-7 sm:w-8 sm:h-8"
+                        onClick={() => {
+                          const step = pauseUnit === 'hr' ? 0.5 : 1
+                          setPauseTime(pauseTime + step)
+                        }}
+                      >
+                        <span className="material-icons-outlined text-sm">add</span>
+                      </Button>
+                    </div>
+                  </div>
+
+                  {/* Clear Pattern Dropdown */}
+                  <div className="flex items-center px-1 sm:px-2">
+                    <Select value={clearPattern} onValueChange={(v) => setClearPattern(v as PreExecution)}>
+                      <SelectTrigger className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full border-0 p-0 shadow-none focus:ring-0 justify-center [&>svg]:hidden transition ${
+                        clearPattern !== 'none' ? '!bg-primary/10' : '!bg-transparent hover:!bg-muted'
+                      }`}>
+                        <span className={`material-icons-outlined text-lg sm:text-xl ${
+                          clearPattern !== 'none' ? 'text-primary' : 'text-muted-foreground'
+                        }`}>cleaning_services</span>
+                      </SelectTrigger>
+                      <SelectContent>
+                        {preExecutionOptions.map(opt => (
+                          <SelectItem key={opt.value} value={opt.value}>
+                            {opt.label}
+                          </SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                  </div>
+                </div>
+
+                {/* Play Button */}
+                <button
+                  onClick={handleRunPlaylist}
+                  disabled={isRunning || playlistPatterns.length === 0}
+                  className="w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground text-primary-foreground shadow-lg shadow-primary/30 hover:shadow-primary/50 hover:scale-105 disabled:shadow-none disabled:hover:scale-100 transition-all duration-200 flex items-center justify-center"
+                  title="Run Playlist"
+                >
+                  {isRunning ? (
+                    <span className="material-icons-outlined text-xl sm:text-2xl animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons text-xl sm:text-2xl ml-0.5">play_arrow</span>
+                  )}
+                </button>
+              </div>
+            </div>
+          )}
+        </main>
+      </div>
+
+      {/* Create Playlist Modal */}
+      <Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              <span className="material-icons-outlined text-primary">playlist_add</span>
+              Create New Playlist
+            </DialogTitle>
+          </DialogHeader>
+          <div className="space-y-4 py-4">
+            <div className="space-y-2">
+              <Label htmlFor="playlistName">Playlist Name</Label>
+              <Input
+                id="playlistName"
+                value={newPlaylistName}
+                onChange={(e) => setNewPlaylistName(e.target.value)}
+                placeholder="e.g., Favorites, Morning Patterns..."
+                onKeyDown={(e) => e.key === 'Enter' && handleCreatePlaylist()}
+                autoFocus
+              />
+            </div>
+          </div>
+          <DialogFooter className="gap-2 sm:gap-0">
+            <Button variant="secondary" onClick={() => setIsCreateModalOpen(false)}>
+              Cancel
+            </Button>
+            <Button onClick={handleCreatePlaylist} className="gap-2">
+              <span className="material-icons-outlined text-base">add</span>
+              Create Playlist
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      {/* Rename Playlist Modal */}
+      <Dialog open={isRenameModalOpen} onOpenChange={setIsRenameModalOpen}>
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              <span className="material-icons-outlined text-primary">edit</span>
+              Rename Playlist
+            </DialogTitle>
+          </DialogHeader>
+          <div className="space-y-4 py-4">
+            <div className="space-y-2">
+              <Label htmlFor="renamePlaylist">New Name</Label>
+              <Input
+                id="renamePlaylist"
+                value={newPlaylistName}
+                onChange={(e) => setNewPlaylistName(e.target.value)}
+                placeholder="Enter new name"
+                onKeyDown={(e) => e.key === 'Enter' && handleRenamePlaylist()}
+                autoFocus
+              />
+            </div>
+          </div>
+          <DialogFooter className="gap-2 sm:gap-0">
+            <Button variant="secondary" onClick={() => setIsRenameModalOpen(false)}>
+              Cancel
+            </Button>
+            <Button onClick={handleRenamePlaylist} className="gap-2">
+              <span className="material-icons-outlined text-base">save</span>
+              Save Name
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      {/* Pattern Picker Modal */}
+      <Dialog open={isPickerOpen} onOpenChange={setIsPickerOpen}>
+        <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              <span className="material-icons-outlined text-primary">playlist_add</span>
+              Add Patterns to {selectedPlaylist}
+            </DialogTitle>
+          </DialogHeader>
+
+          {/* Search and Filters */}
+          <div className="space-y-3 py-2">
+            <div className="relative">
+              <span className="material-icons-outlined absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-lg">
+                search
+              </span>
+              <Input
+                value={searchQuery}
+                onChange={(e) => setSearchQuery(e.target.value)}
+                placeholder="Search patterns..."
+                className="pl-10 pr-10 h-10"
+              />
+              {searchQuery && (
+                <button
+                  className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+                  onClick={() => setSearchQuery('')}
+                >
+                  <span className="material-icons-outlined text-lg">close</span>
+                </button>
+              )}
+            </div>
+
+            <div className="flex flex-wrap gap-2 items-center">
+              {/* Folder dropdown - icon only on mobile, with text on sm+ */}
+              <Select value={selectedCategory} onValueChange={setSelectedCategory}>
+                <SelectTrigger className="h-9 w-9 sm:w-auto rounded-full bg-card border-border shadow-sm text-sm px-0 sm:px-3 justify-center sm:justify-between [&>svg]:hidden sm:[&>svg]:block [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
+                  <span className="material-icons-outlined text-lg shrink-0">folder</span>
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  {categories.map(cat => (
+                    <SelectItem key={cat} value={cat}>
+                      {cat === 'all' ? 'All Folders' : cat}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+
+              {/* Sort dropdown - icon only on mobile, with text on sm+ */}
+              <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
+                <SelectTrigger className="h-9 w-9 sm:w-auto rounded-full bg-card border-border shadow-sm text-sm px-0 sm:px-3 justify-center sm:justify-between [&>svg]:hidden sm:[&>svg]:block [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
+                  <span className="material-icons-outlined text-lg shrink-0">sort</span>
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="favorites">Favorites</SelectItem>
+                  <SelectItem value="name">Name</SelectItem>
+                  <SelectItem value="date">Modified</SelectItem>
+                  <SelectItem value="size">Size</SelectItem>
+                </SelectContent>
+              </Select>
+
+              {/* Sort direction - pill shaped */}
+              <Button
+                variant="outline"
+                size="icon"
+                className="h-9 w-9 rounded-full bg-card shadow-sm"
+                onClick={() => setSortAsc(!sortAsc)}
+                title={sortAsc ? 'Ascending' : 'Descending'}
+              >
+                <span className="material-icons-outlined text-lg">
+                  {sortAsc ? 'arrow_upward' : 'arrow_downward'}
+                </span>
+              </Button>
+
+              <div className="flex-1" />
+
+              {/* Selection count - compact on mobile */}
+              <div className="flex items-center gap-1 sm:gap-2 text-sm bg-card rounded-full px-2 sm:px-3 py-2 shadow-sm border">
+                <span className="material-icons-outlined text-base text-primary">check_circle</span>
+                <span className="font-medium">{selectedPatternPaths.size}</span>
+                <span className="hidden sm:inline text-muted-foreground">selected</span>
+              </div>
+            </div>
+          </div>
+
+          {/* Patterns Grid */}
+          <div className="flex-1 overflow-y-auto border rounded-lg p-4 min-h-[300px] bg-muted/20">
+            {filteredPatterns.length === 0 ? (
+              <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
+                <div className="p-4 rounded-full bg-muted">
+                  <span className="material-icons-outlined text-5xl">search_off</span>
+                </div>
+                <span className="text-sm">No patterns found</span>
+              </div>
+            ) : (
+              <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
+                {filteredPatterns.map(pattern => {
+                  const isSelected = selectedPatternPaths.has(pattern.path)
+                  return (
+                    <div
+                      key={pattern.path}
+                      className="flex flex-col items-center gap-2 cursor-pointer"
+                      onClick={() => togglePatternSelection(pattern.path)}
+                    >
+                      <div
+                        className={`relative w-full aspect-square rounded-full overflow-hidden border-2 bg-muted transition-all ${
+                          isSelected
+                            ? 'border-primary ring-2 ring-primary/20'
+                            : 'border-transparent hover:border-muted-foreground/30'
+                        }`}
+                      >
+                        <LazyPatternPreview
+                          path={pattern.path}
+                          previewUrl={getPreviewUrl(pattern.path)}
+                          requestPreview={requestPreview}
+                          alt={pattern.name}
+                        />
+                        {isSelected && (
+                          <div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-primary flex items-center justify-center shadow-md">
+                            <span className="material-icons text-primary-foreground" style={{ fontSize: '14px' }}>
+                              check
+                            </span>
+                          </div>
+                        )}
+                      </div>
+                      <p className={`text-xs truncate font-medium w-full text-center ${isSelected ? 'text-primary' : ''}`}>
+                        {pattern.name}
+                      </p>
+                    </div>
+                  )
+                })}
+              </div>
+            )}
+          </div>
+
+          <DialogFooter className="gap-2 sm:gap-0">
+            <Button variant="secondary" onClick={() => setIsPickerOpen(false)}>
+              Cancel
+            </Button>
+            <Button onClick={handleSavePatterns} className="gap-2">
+              <span className="material-icons-outlined text-base">save</span>
+              Save Selection
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  )
+}
+
+// Lazy-loading pattern preview component
+interface LazyPatternPreviewProps {
+  path: string
+  previewUrl: string | null
+  requestPreview: (path: string) => void
+  alt: string
+  className?: string
+}
+
+function LazyPatternPreview({ path, previewUrl, requestPreview, alt, className = '' }: LazyPatternPreviewProps) {
+  const containerRef = useRef<HTMLDivElement>(null)
+  const hasRequestedRef = useRef(false)
+
+  useEffect(() => {
+    if (!containerRef.current || previewUrl || hasRequestedRef.current) return
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        entries.forEach((entry) => {
+          if (entry.isIntersecting && !hasRequestedRef.current) {
+            hasRequestedRef.current = true
+            requestPreview(path)
+            observer.disconnect()
+          }
+        })
+      },
+      { rootMargin: '100px' }
+    )
+
+    observer.observe(containerRef.current)
+
+    return () => observer.disconnect()
+  }, [path, previewUrl, requestPreview])
+
+  return (
+    <div ref={containerRef} className={`w-full h-full flex items-center justify-center ${className}`}>
+      {previewUrl ? (
+        <img
+          src={previewUrl}
+          alt={alt}
+          loading="lazy"
+          className="w-full h-full object-cover pattern-preview"
+        />
+      ) : (
+        <span className="material-icons-outlined text-muted-foreground text-sm sm:text-base">
+          image
+        </span>
+      )}
+    </div>
+  )
+}

+ 2239 - 0
frontend/src/pages/SettingsPage.tsx

@@ -0,0 +1,2239 @@
+import { useState, useEffect } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import { toast } from 'sonner'
+import { apiClient } from '@/lib/apiClient'
+import { useOnBackendConnected } from '@/hooks/useBackendConnection'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Separator } from '@/components/ui/separator'
+import { Switch } from '@/components/ui/switch'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import {
+  Accordion,
+  AccordionContent,
+  AccordionItem,
+  AccordionTrigger,
+} from '@/components/ui/accordion'
+import {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectLabel,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
+import { SearchableSelect } from '@/components/ui/searchable-select'
+
+// Types
+
+interface Settings {
+  app_name?: string
+  custom_logo?: string
+  preferred_port?: string
+  // Machine settings
+  table_type_override?: string
+  detected_table_type?: string
+  effective_table_type?: string
+  gear_ratio?: number
+  x_steps_per_mm?: number
+  y_steps_per_mm?: number
+  available_table_types?: { value: string; label: string }[]
+  // Homing settings
+  homing_mode?: number
+  angular_offset?: number
+  auto_home_enabled?: boolean
+  auto_home_after_patterns?: number
+  hard_reset_theta?: boolean
+  // Pattern clearing settings
+  clear_pattern_speed?: number
+  custom_clear_from_in?: string
+  custom_clear_from_out?: string
+}
+
+interface TimeSlot {
+  start_time: string
+  end_time: string
+  days: 'daily' | 'weekdays' | 'weekends' | 'custom'
+  custom_days?: string[]
+}
+
+interface StillSandsSettings {
+  enabled: boolean
+  finish_pattern: boolean
+  control_wled: boolean
+  timezone: string
+  time_slots: TimeSlot[]
+}
+
+interface AutoPlaySettings {
+  enabled: boolean
+  playlist: string
+  run_mode: 'single' | 'loop'
+  pause_time: number
+  clear_pattern: string
+  shuffle: boolean
+}
+
+interface LedConfig {
+  provider: 'none' | 'wled' | 'dw_leds'
+  wled_ip?: string
+  num_leds?: number
+  gpio_pin?: number
+  pixel_order?: string
+}
+
+interface MqttConfig {
+  enabled: boolean
+  broker?: string
+  port?: number
+  username?: string
+  password?: string
+  device_name?: string
+  device_id?: string
+  client_id?: string
+  discovery_prefix?: string
+}
+
+export function SettingsPage() {
+  const [searchParams, setSearchParams] = useSearchParams()
+  const sectionParam = searchParams.get('section')
+
+  // Connection state
+  const [ports, setPorts] = useState<string[]>([])
+  const [selectedPort, setSelectedPort] = useState('')
+  const [isConnected, setIsConnected] = useState(false)
+  const [connectionStatus, setConnectionStatus] = useState('Disconnected')
+
+  // Settings state
+  const [settings, setSettings] = useState<Settings>({})
+  const [ledConfig, setLedConfig] = useState<LedConfig>({ provider: 'none', gpio_pin: 18 })
+  const [numLedsInput, setNumLedsInput] = useState('60')
+  const [mqttConfig, setMqttConfig] = useState<MqttConfig>({ enabled: false })
+
+  // UI state
+  const [isLoading, setIsLoading] = useState<string | null>(null)
+
+  // Accordion state - controlled by URL params
+  const [openSections, setOpenSections] = useState<string[]>(() => {
+    if (sectionParam) return [sectionParam]
+    return ['connection']
+  })
+
+  // Track which sections have been loaded (for lazy loading)
+  const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set())
+
+  // Auto-play state
+  const [autoPlaySettings, setAutoPlaySettings] = useState<AutoPlaySettings>({
+    enabled: false,
+    playlist: '',
+    run_mode: 'loop',
+    pause_time: 5,
+    clear_pattern: 'adaptive',
+    shuffle: false,
+  })
+  const [autoPlayPauseUnit, setAutoPlayPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
+  const [autoPlayPauseValue, setAutoPlayPauseValue] = useState(5)
+  const [autoPlayPauseInput, setAutoPlayPauseInput] = useState('5')
+  const [playlists, setPlaylists] = useState<string[]>([])
+
+  // Convert pause time from seconds to value + unit for display
+  const secondsToDisplayPause = (seconds: number): { value: number; unit: 'sec' | 'min' | 'hr' } => {
+    if (seconds >= 3600 && seconds % 3600 === 0) {
+      return { value: seconds / 3600, unit: 'hr' }
+    } else if (seconds >= 60 && seconds % 60 === 0) {
+      return { value: seconds / 60, unit: 'min' }
+    }
+    return { value: seconds, unit: 'sec' }
+  }
+
+  // Convert display value + unit to seconds
+  const displayPauseToSeconds = (value: number, unit: 'sec' | 'min' | 'hr'): number => {
+    switch (unit) {
+      case 'hr': return value * 3600
+      case 'min': return value * 60
+      default: return value
+    }
+  }
+
+  // Still Sands state
+  const [stillSandsSettings, setStillSandsSettings] = useState<StillSandsSettings>({
+    enabled: false,
+    finish_pattern: false,
+    control_wled: false,
+    timezone: '',
+    time_slots: [],
+  })
+
+  // Pattern search state for clearing patterns
+  const [patternFiles, setPatternFiles] = useState<string[]>([])
+
+  // Version state
+  const [versionInfo, setVersionInfo] = useState<{
+    current: string
+    latest: string
+    update_available: boolean
+  } | null>(null)
+
+  // Helper to scroll to element with header offset
+  const scrollToSection = (sectionId: string) => {
+    const element = document.getElementById(`section-${sectionId}`)
+    if (element) {
+      const headerHeight = 80 // Header height + some padding
+      const elementTop = element.getBoundingClientRect().top + window.scrollY
+      window.scrollTo({ top: elementTop - headerHeight, behavior: 'smooth' })
+    }
+  }
+
+  // Scroll to section and clear URL param after navigation
+  useEffect(() => {
+    if (sectionParam) {
+      // Scroll to the section after a short delay to allow render
+      setTimeout(() => {
+        scrollToSection(sectionParam)
+        // Clear the search param from URL
+        setSearchParams({}, { replace: true })
+      }, 100)
+    }
+  }, [sectionParam, setSearchParams])
+
+  // Load section data when expanded (lazy loading)
+  const loadSectionData = async (section: string) => {
+    if (loadedSections.has(section)) return
+
+    setLoadedSections((prev) => new Set(prev).add(section))
+
+    switch (section) {
+      case 'connection':
+        await fetchPorts()
+        // Also load settings for preferred port
+        if (!loadedSections.has('_settings')) {
+          setLoadedSections((prev) => new Set(prev).add('_settings'))
+          await fetchSettings()
+        }
+        break
+      case 'application':
+      case 'mqtt':
+      case 'autoplay':
+      case 'stillsands':
+      case 'machine':
+      case 'homing':
+      case 'clearing':
+        // These all share settings data
+        if (!loadedSections.has('_settings')) {
+          setLoadedSections((prev) => new Set(prev).add('_settings'))
+          await fetchSettings()
+        }
+        if ((section === 'autoplay' || section === 'clearing') && !loadedSections.has('_playlists')) {
+          setLoadedSections((prev) => new Set(prev).add('_playlists'))
+          await fetchPlaylists()
+        }
+        if (section === 'clearing' && !loadedSections.has('_patterns')) {
+          setLoadedSections((prev) => new Set(prev).add('_patterns'))
+          await fetchPatternFiles()
+        }
+        break
+      case 'led':
+        await fetchLedConfig()
+        break
+      case 'version':
+        await fetchVersionInfo()
+        break
+    }
+  }
+
+  const fetchPatternFiles = async () => {
+    try {
+      const data = await apiClient.get<string[]>('/list_theta_rho_files')
+      // Response is a flat array of file paths
+      setPatternFiles(Array.isArray(data) ? data : [])
+    } catch (error) {
+      console.error('Error fetching pattern files:', error)
+    }
+  }
+
+  const fetchVersionInfo = async () => {
+    try {
+      const data = await apiClient.get<{ current: string; latest: string; update_available: boolean }>('/api/version')
+      setVersionInfo(data)
+    } catch (error) {
+      console.error('Failed to fetch version info:', error)
+    }
+  }
+
+  // Handle accordion open/close and trigger data loading
+  const handleAccordionChange = (values: string[]) => {
+    // Find newly opened section
+    const newlyOpened = values.find((v) => !openSections.includes(v))
+
+    setOpenSections(values)
+
+    // Load data for newly opened sections
+    values.forEach((section) => {
+      if (!loadedSections.has(section)) {
+        loadSectionData(section)
+      }
+    })
+
+    // Scroll newly opened section into view
+    if (newlyOpened) {
+      setTimeout(() => {
+        scrollToSection(newlyOpened)
+      }, 100)
+    }
+  }
+
+  // Load initial section data
+  useEffect(() => {
+    openSections.forEach((section) => {
+      loadSectionData(section)
+    })
+  }, [])
+
+  const fetchPorts = async () => {
+    try {
+      // Fetch available ports first
+      const portsData = await apiClient.get<string[]>('/list_serial_ports')
+      const availablePorts = portsData || []
+      setPorts(availablePorts)
+
+      // Fetch connection status
+      const statusData = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
+      setIsConnected(statusData.connected || false)
+      setConnectionStatus(statusData.connected ? 'Connected' : 'Disconnected')
+
+      // Only set selectedPort if it exists in the available ports list
+      // This prevents race conditions where stale port data from a different
+      // backend (e.g., Mac port on a Pi) could be set
+      if (statusData.port && availablePorts.includes(statusData.port)) {
+        setSelectedPort(statusData.port)
+      } else if (statusData.port && !availablePorts.includes(statusData.port)) {
+        // Port from status doesn't exist on this machine - likely stale data
+        console.warn(`Port ${statusData.port} from status not in available ports, ignoring`)
+        setSelectedPort('')
+      }
+    } catch (error) {
+      console.error('Error fetching ports:', error)
+    }
+  }
+
+  // Always fetch ports on mount since connection is the default section
+  useEffect(() => {
+    fetchPorts()
+  }, [])
+
+  // Refetch when backend reconnects
+  useOnBackendConnected(() => {
+    fetchPorts()
+  })
+
+  const fetchSettings = async () => {
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const data = await apiClient.get<Record<string, any>>('/api/settings')
+      // Map the nested API response to our flat Settings interface
+      setSettings({
+        app_name: data.app?.name,
+        custom_logo: data.app?.custom_logo,
+        preferred_port: data.connection?.preferred_port,
+        // Machine settings
+        table_type_override: data.machine?.table_type_override,
+        detected_table_type: data.machine?.detected_table_type,
+        effective_table_type: data.machine?.effective_table_type,
+        gear_ratio: data.machine?.gear_ratio,
+        x_steps_per_mm: data.machine?.x_steps_per_mm,
+        y_steps_per_mm: data.machine?.y_steps_per_mm,
+        available_table_types: data.machine?.available_table_types,
+        // Homing settings
+        homing_mode: data.homing?.mode,
+        angular_offset: data.homing?.angular_offset_degrees,
+        auto_home_enabled: data.homing?.auto_home_enabled,
+        auto_home_after_patterns: data.homing?.auto_home_after_patterns,
+        hard_reset_theta: data.homing?.hard_reset_theta,
+        // Pattern clearing settings
+        clear_pattern_speed: data.patterns?.clear_pattern_speed,
+        custom_clear_from_in: data.patterns?.custom_clear_from_in,
+        custom_clear_from_out: data.patterns?.custom_clear_from_out,
+      })
+      // Set auto-play settings
+      if (data.auto_play) {
+        const pauseSeconds = data.auto_play.pause_time ?? 300 // Default 5 minutes
+        const { value, unit } = secondsToDisplayPause(pauseSeconds)
+        setAutoPlayPauseValue(value)
+        setAutoPlayPauseInput(String(value))
+        setAutoPlayPauseUnit(unit)
+        setAutoPlaySettings({
+          enabled: data.auto_play.enabled || false,
+          playlist: data.auto_play.playlist || '',
+          run_mode: data.auto_play.run_mode || 'loop',
+          pause_time: pauseSeconds,
+          clear_pattern: data.auto_play.clear_pattern || 'adaptive',
+          shuffle: data.auto_play.shuffle || false,
+        })
+      }
+      // Set still sands settings
+      if (data.scheduled_pause) {
+        setStillSandsSettings({
+          enabled: data.scheduled_pause.enabled || false,
+          finish_pattern: data.scheduled_pause.finish_pattern || false,
+          control_wled: data.scheduled_pause.control_wled || false,
+          timezone: data.scheduled_pause.timezone || '',
+          time_slots: data.scheduled_pause.time_slots || [],
+        })
+      }
+      // Set MQTT config from the same response
+      if (data.mqtt) {
+        setMqttConfig({
+          enabled: data.mqtt.enabled || false,
+          broker: data.mqtt.broker,
+          port: data.mqtt.port,
+          username: data.mqtt.username,
+          device_name: data.mqtt.device_name,
+          device_id: data.mqtt.device_id,
+          client_id: data.mqtt.client_id,
+          discovery_prefix: data.mqtt.discovery_prefix,
+        })
+      }
+    } catch (error) {
+      console.error('Error fetching settings:', error)
+    }
+  }
+
+  const fetchLedConfig = async () => {
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const data = await apiClient.get<Record<string, any>>('/get_led_config')
+      setLedConfig({
+        provider: data.provider || 'none',
+        wled_ip: data.wled_ip,
+        num_leds: data.dw_led_num_leds,
+        gpio_pin: data.dw_led_gpio_pin,
+        pixel_order: data.dw_led_pixel_order,
+      })
+      setNumLedsInput(String(data.dw_led_num_leds || 60))
+    } catch (error) {
+      console.error('Error fetching LED config:', error)
+    }
+  }
+
+  const fetchPlaylists = async () => {
+    try {
+      const data = await apiClient.get('/list_all_playlists')
+      // Backend returns array directly, not { playlists: [...] }
+      setPlaylists(Array.isArray(data) ? data : [])
+    } catch (error) {
+      console.error('Error fetching playlists:', error)
+    }
+  }
+
+  const handleConnect = async () => {
+    if (!selectedPort) {
+      toast.error('Please select a port')
+      return
+    }
+    setIsLoading('connect')
+    try {
+      const data = await apiClient.post<{ success?: boolean; message?: string }>('/connect', { port: selectedPort })
+      if (data.success) {
+        setIsConnected(true)
+        setConnectionStatus(`Connected to ${selectedPort}`)
+        toast.success('Connected successfully')
+      } else {
+        throw new Error(data.message || 'Connection failed')
+      }
+    } catch (error) {
+      toast.error('Failed to connect')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleDisconnect = async () => {
+    setIsLoading('disconnect')
+    try {
+      const data = await apiClient.post<{ success?: boolean }>('/disconnect')
+      if (data.success) {
+        setIsConnected(false)
+        setConnectionStatus('Disconnected')
+        toast.success('Disconnected')
+      }
+    } catch (error) {
+      toast.error('Failed to disconnect')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSavePreferredPort = async () => {
+    setIsLoading('preferredPort')
+    try {
+      // Send the actual value: __auto__, __none__, or specific port
+      const portValue = settings.preferred_port || '__auto__'
+      await apiClient.patch('/api/settings', {
+        connection: { preferred_port: portValue },
+      })
+      if (!settings.preferred_port || settings.preferred_port === '__auto__') {
+        toast.success('Auto-connect: Auto (first available port)')
+      } else if (settings.preferred_port === '__none__') {
+        toast.success('Auto-connect: Disabled')
+      } else {
+        toast.success(`Auto-connect: ${settings.preferred_port}`)
+      }
+    } catch (error) {
+      toast.error('Failed to save auto-connect setting')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveAppName = async () => {
+    setIsLoading('appName')
+    try {
+      await apiClient.patch('/api/settings', { app: { name: settings.app_name } })
+      toast.success('App name saved. Refresh to see changes.')
+    } catch (error) {
+      toast.error('Failed to save app name')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  // Update favicon links in the document head and notify Layout to refresh
+  const updateBranding = (customLogo: string | null) => {
+    const timestamp = Date.now() // Cache buster
+
+    // Update favicon links (use apiClient.getAssetUrl for multi-table support)
+    const faviconIco = document.getElementById('favicon-ico') as HTMLLinkElement
+    const appleTouchIcon = document.getElementById('apple-touch-icon') as HTMLLinkElement
+
+    if (customLogo) {
+      if (faviconIco) faviconIco.href = apiClient.getAssetUrl(`/static/custom/favicon.ico?v=${timestamp}`)
+      if (appleTouchIcon) appleTouchIcon.href = apiClient.getAssetUrl(`/static/custom/${customLogo}?v=${timestamp}`)
+    } else {
+      if (faviconIco) faviconIco.href = apiClient.getAssetUrl(`/static/favicon.ico?v=${timestamp}`)
+      if (appleTouchIcon) appleTouchIcon.href = apiClient.getAssetUrl(`/static/apple-touch-icon.png?v=${timestamp}`)
+    }
+
+    // Dispatch event for Layout to update header logo
+    window.dispatchEvent(new CustomEvent('branding-updated'))
+  }
+
+  const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0]
+    if (!file) return
+
+    setIsLoading('logo')
+    try {
+      const data = await apiClient.uploadFile('/api/upload-logo', file, 'file') as { filename: string }
+      setSettings({ ...settings, custom_logo: data.filename })
+      updateBranding(data.filename)
+      toast.success('Logo uploaded!')
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : 'Failed to upload logo')
+    } finally {
+      setIsLoading(null)
+      // Reset the input
+      e.target.value = ''
+    }
+  }
+
+  const handleDeleteLogo = async () => {
+    if (!confirm('Remove custom logo and revert to default?')) return
+
+    setIsLoading('logo')
+    try {
+      await apiClient.delete('/api/custom-logo')
+      setSettings({ ...settings, custom_logo: undefined })
+      updateBranding(null)
+      toast.success('Logo removed!')
+    } catch (error) {
+      toast.error('Failed to remove logo')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveLedConfig = async () => {
+    setIsLoading('led')
+    try {
+      // Use the /set_led_config endpoint (deprecated but still works)
+      await apiClient.post('/set_led_config', {
+        provider: ledConfig.provider,
+        ip_address: ledConfig.wled_ip,
+        num_leds: ledConfig.num_leds,
+        gpio_pin: ledConfig.gpio_pin,
+        pixel_order: ledConfig.pixel_order,
+      })
+      toast.success('LED configuration saved')
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : 'Failed to save LED config')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveMqttConfig = async () => {
+    setIsLoading('mqtt')
+    try {
+      await apiClient.patch('/api/settings', {
+        mqtt: {
+          enabled: mqttConfig.enabled,
+          broker: mqttConfig.broker,
+          port: mqttConfig.port,
+          username: mqttConfig.username,
+          password: mqttConfig.password,
+          device_name: mqttConfig.device_name,
+          device_id: mqttConfig.device_id,
+          client_id: mqttConfig.client_id,
+          discovery_prefix: mqttConfig.discovery_prefix,
+        },
+      })
+      toast.success('MQTT configuration saved. Restart required.')
+    } catch (error) {
+      toast.error('Failed to save MQTT config')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleTestMqttConnection = async () => {
+    if (!mqttConfig.broker) {
+      toast.error('Please enter a broker address')
+      return
+    }
+    setIsLoading('mqttTest')
+    try {
+      const data = await apiClient.post<{ success?: boolean; error?: string }>('/api/mqtt-test', {
+        broker: mqttConfig.broker,
+        port: mqttConfig.port || 1883,
+        username: mqttConfig.username || '',
+        password: mqttConfig.password || '',
+      })
+      if (data.success) {
+        toast.success('MQTT connection successful!')
+      } else {
+        toast.error(data.error || 'Connection failed')
+      }
+    } catch (error) {
+      toast.error('Failed to test MQTT connection')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveMachineSettings = async () => {
+    setIsLoading('machine')
+    try {
+      await apiClient.patch('/api/settings', {
+        machine: {
+          table_type_override: settings.table_type_override || '',
+        },
+      })
+      toast.success('Machine settings saved')
+    } catch (error) {
+      toast.error('Failed to save machine settings')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveHomingConfig = async () => {
+    setIsLoading('homing')
+    try {
+      await apiClient.patch('/api/settings', {
+        homing: {
+          mode: settings.homing_mode,
+          angular_offset_degrees: settings.angular_offset,
+          auto_home_enabled: settings.auto_home_enabled,
+          auto_home_after_patterns: settings.auto_home_after_patterns,
+          hard_reset_theta: settings.hard_reset_theta,
+        },
+      })
+      toast.success('Homing configuration saved')
+    } catch (error) {
+      toast.error('Failed to save homing configuration')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveClearingSettings = async () => {
+    setIsLoading('clearing')
+    try {
+      await apiClient.patch('/api/settings', {
+        patterns: {
+          // Send 0 to indicate "reset to default" - backend interprets 0 or negative as None
+          clear_pattern_speed: settings.clear_pattern_speed ?? 0,
+          custom_clear_from_in: settings.custom_clear_from_in || null,
+          custom_clear_from_out: settings.custom_clear_from_out || null,
+        },
+      })
+      toast.success('Clearing settings saved')
+    } catch (error) {
+      toast.error('Failed to save clearing settings')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveAutoPlaySettings = async () => {
+    setIsLoading('autoplay')
+    try {
+      // Convert pause value + unit to seconds
+      const pauseTimeSeconds = displayPauseToSeconds(autoPlayPauseValue, autoPlayPauseUnit)
+      await apiClient.patch('/api/settings', {
+        auto_play: {
+          ...autoPlaySettings,
+          pause_time: pauseTimeSeconds,
+        },
+      })
+      toast.success('Auto-play settings saved')
+    } catch (error) {
+      toast.error('Failed to save auto-play settings')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveStillSandsSettings = async () => {
+    setIsLoading('stillsands')
+    try {
+      await apiClient.patch('/api/settings', {
+        scheduled_pause: stillSandsSettings,
+      })
+      toast.success('Still Sands settings saved')
+    } catch (error) {
+      toast.error('Failed to save Still Sands settings')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const addTimeSlot = () => {
+    setStillSandsSettings({
+      ...stillSandsSettings,
+      time_slots: [
+        ...stillSandsSettings.time_slots,
+        { start_time: '22:00', end_time: '06:00', days: 'daily', custom_days: [] },
+      ],
+    })
+  }
+
+  const removeTimeSlot = (index: number) => {
+    setStillSandsSettings({
+      ...stillSandsSettings,
+      time_slots: stillSandsSettings.time_slots.filter((_, i) => i !== index),
+    })
+  }
+
+  const updateTimeSlot = (index: number, updates: Partial<TimeSlot>) => {
+    const newSlots = [...stillSandsSettings.time_slots]
+    newSlots[index] = { ...newSlots[index], ...updates }
+    setStillSandsSettings({ ...stillSandsSettings, time_slots: newSlots })
+  }
+
+  return (
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
+      {/* Page Header */}
+      <div className="space-y-0.5 sm:space-y-1 pl-1">
+        <h1 className="text-xl font-semibold tracking-tight">Settings</h1>
+        <p className="text-xs text-muted-foreground">
+          Configure your sand table
+        </p>
+      </div>
+
+      <Separator />
+
+      <Accordion
+        type="multiple"
+        value={openSections}
+        onValueChange={handleAccordionChange}
+        className="space-y-3"
+      >
+        {/* Device Connection */}
+        <AccordionItem value="connection" id="section-connection" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                usb
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Device Connection</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Serial port configuration
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* Connection Status */}
+            <div className="flex items-center justify-between p-4 rounded-lg border">
+              <div className="flex items-center gap-3">
+                <div className={`w-10 h-10 flex items-center justify-center rounded-lg ${isConnected ? 'bg-green-100 dark:bg-green-900' : 'bg-muted'}`}>
+                  <span className={`material-icons ${isConnected ? 'text-green-600' : 'text-muted-foreground'}`}>
+                    {isConnected ? 'usb' : 'usb_off'}
+                  </span>
+                </div>
+                <div>
+                  <p className="font-medium">Status</p>
+                  <p className={`text-sm ${isConnected ? 'text-green-600' : 'text-destructive'}`}>
+                    {connectionStatus}
+                  </p>
+                </div>
+              </div>
+              {isConnected && (
+                <Button
+                  variant="destructive"
+                  size="sm"
+                  onClick={handleDisconnect}
+                  disabled={isLoading === 'disconnect'}
+                >
+                  Disconnect
+                </Button>
+              )}
+            </div>
+
+            {/* Port Selection */}
+            <div className="space-y-3">
+              <Label>Available Serial Ports</Label>
+              <div className="flex gap-3">
+                <Select value={selectedPort} onValueChange={setSelectedPort}>
+                  <SelectTrigger className="flex-1">
+                    <SelectValue placeholder="Select a port..." />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {ports.length === 0 ? (
+                      <div className="py-6 text-center text-sm text-muted-foreground">
+                        No serial ports found
+                      </div>
+                    ) : (
+                      ports.map((port) => (
+                        <SelectItem key={port} value={port}>
+                          {port}
+                        </SelectItem>
+                      ))
+                    )}
+                  </SelectContent>
+                </Select>
+                <Button
+                  onClick={handleConnect}
+                  disabled={isLoading === 'connect' || !selectedPort || isConnected}
+                  className="gap-2"
+                >
+                  {isLoading === 'connect' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">cable</span>
+                  )}
+                  Connect
+                </Button>
+              </div>
+              <p className="text-xs text-muted-foreground">
+                Select a port and click 'Connect' to establish a connection.
+              </p>
+            </div>
+
+            <Separator />
+
+            {/* Preferred Port for Auto-Connect */}
+            <div className="space-y-3">
+              <Label>Auto-Connect</Label>
+              <div className="flex gap-3">
+                <Select
+                  value={settings.preferred_port || '__auto__'}
+                  onValueChange={(value) =>
+                    setSettings({ ...settings, preferred_port: value === '__auto__' ? undefined : value })
+                  }
+                >
+                  <SelectTrigger className="flex-1">
+                    <SelectValue placeholder="Select auto-connect option..." />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="__auto__">Auto (pick first available)</SelectItem>
+                    <SelectItem value="__none__">Disabled (no auto-connect)</SelectItem>
+                    {ports.length > 0 && (
+                      <>
+                        <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">Available Ports</div>
+                        {ports.map((port) => (
+                          <SelectItem key={port} value={port}>
+                            {port}
+                          </SelectItem>
+                        ))}
+                      </>
+                    )}
+                  </SelectContent>
+                </Select>
+                <Button
+                  onClick={handleSavePreferredPort}
+                  disabled={isLoading === 'preferredPort'}
+                  className="gap-2"
+                >
+                  {isLoading === 'preferredPort' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">save</span>
+                  )}
+                  Save
+                </Button>
+              </div>
+              <p className="text-xs text-muted-foreground">
+                Choose how the system connects on startup: Auto picks the first available port, Disabled requires manual connection, or select a specific port.
+              </p>
+            </div>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Machine Settings */}
+        <AccordionItem value="machine" id="section-machine" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                precision_manufacturing
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Machine Settings</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Table type and hardware configuration
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* Hardware Parameters */}
+            <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
+              <div className="p-3 rounded-lg bg-muted/50">
+                <p className="text-xs text-muted-foreground">Detected Type</p>
+                <p className="font-medium text-sm">{settings.detected_table_type || 'Unknown'}</p>
+              </div>
+              <div className="p-3 rounded-lg bg-muted/50">
+                <p className="text-xs text-muted-foreground">Gear Ratio</p>
+                <p className="font-medium text-sm">{settings.gear_ratio ?? '—'}</p>
+              </div>
+              <div className="p-3 rounded-lg bg-muted/50">
+                <p className="text-xs text-muted-foreground">X Steps/mm</p>
+                <p className="font-medium text-sm">{settings.x_steps_per_mm ?? '—'}</p>
+              </div>
+              <div className="p-3 rounded-lg bg-muted/50">
+                <p className="text-xs text-muted-foreground">Y Steps/mm</p>
+                <p className="font-medium text-sm">{settings.y_steps_per_mm ?? '—'}</p>
+              </div>
+            </div>
+
+            {/* Table Type Override */}
+            <div className="space-y-3">
+              <Label>Table Type Override</Label>
+              <div className="flex gap-3">
+                <Select
+                  value={settings.table_type_override || 'auto'}
+                  onValueChange={(value) =>
+                    setSettings({ ...settings, table_type_override: value === 'auto' ? undefined : value })
+                  }
+                >
+                  <SelectTrigger className="flex-1">
+                    <SelectValue placeholder="Auto-detect (use detected type)" />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="auto">Auto-detect (use detected type)</SelectItem>
+                    {settings.available_table_types?.map((type) => (
+                      <SelectItem key={type.value} value={type.value}>
+                        {type.label}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+                <Button
+                  onClick={handleSaveMachineSettings}
+                  disabled={isLoading === 'machine'}
+                  className="gap-2"
+                >
+                  {isLoading === 'machine' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">save</span>
+                  )}
+                  Save
+                </Button>
+              </div>
+              <p className="text-xs text-muted-foreground">
+                Override the automatically detected table type. This affects gear ratio calculations and homing behavior.
+              </p>
+            </div>
+
+            <Alert className="flex items-start">
+              <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+              <AlertDescription>
+                Table type is normally detected automatically from GRBL settings. Use override if auto-detection is incorrect for your hardware.
+              </AlertDescription>
+            </Alert>
+
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Homing Configuration */}
+        <AccordionItem value="homing" id="section-homing" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                home
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Homing Configuration</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Homing mode and auto-home settings
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* Homing Mode Selection */}
+            <div className="space-y-3">
+              <Label>Homing Mode</Label>
+              <RadioGroup
+                value={String(settings.homing_mode || 0)}
+                onValueChange={(value) =>
+                  setSettings({ ...settings, homing_mode: parseInt(value) })
+                }
+                className="space-y-3"
+              >
+                <div className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted/50">
+                  <RadioGroupItem value="0" id="homing-crash" className="mt-0.5" />
+                  <div className="flex-1">
+                    <Label htmlFor="homing-crash" className="font-medium cursor-pointer">
+                      Crash Homing
+                    </Label>
+                    <p className="text-xs text-muted-foreground mt-1">
+                      Y axis moves until physical stop, then theta and rho set to 0
+                    </p>
+                  </div>
+                </div>
+                <div className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted/50">
+                  <RadioGroupItem value="1" id="homing-sensor" className="mt-0.5" />
+                  <div className="flex-1">
+                    <Label htmlFor="homing-sensor" className="font-medium cursor-pointer">
+                      Sensor Homing
+                    </Label>
+                    <p className="text-xs text-muted-foreground mt-1">
+                      Homes both X and Y axes using sensors
+                    </p>
+                  </div>
+                </div>
+              </RadioGroup>
+            </div>
+
+            {/* Sensor Offset (only visible for sensor mode) */}
+            {settings.homing_mode === 1 && (
+              <div className="space-y-3">
+                <Label htmlFor="angular-offset">Sensor Offset (degrees)</Label>
+                <Input
+                  id="angular-offset"
+                  type="number"
+                  min="0"
+                  max="360"
+                  step="0.1"
+                  value={settings.angular_offset ?? ''}
+                  onChange={(e) =>
+                    setSettings({
+                      ...settings,
+                      angular_offset: e.target.value === '' ? undefined : parseFloat(e.target.value),
+                    })
+                  }
+                  placeholder="0.0"
+                />
+                <p className="text-xs text-muted-foreground">
+                  Set the angle (in degrees) where your radial arm should be offset. Choose a value so the radial arm points East.
+                </p>
+              </div>
+            )}
+
+            {/* Auto-Home During Playlists */}
+            <div className="p-4 rounded-lg border space-y-3">
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="font-medium flex items-center gap-2">
+                    <span className="material-icons-outlined text-base">autorenew</span>
+                    Auto-Home During Playlists
+                  </p>
+                  <p className="text-xs text-muted-foreground mt-1">
+                    Perform homing after a set number of patterns to maintain accuracy
+                  </p>
+                </div>
+                <Switch
+                  checked={settings.auto_home_enabled || false}
+                  onCheckedChange={(checked) =>
+                    setSettings({ ...settings, auto_home_enabled: checked })
+                  }
+                />
+              </div>
+
+              {settings.auto_home_enabled && (
+                <div className="space-y-3">
+                  <Label htmlFor="auto-home-patterns">Home after every X patterns</Label>
+                  <Input
+                    id="auto-home-patterns"
+                    type="number"
+                    min="1"
+                    max="100"
+                    value={settings.auto_home_after_patterns || 5}
+                    onChange={(e) =>
+                      setSettings({
+                        ...settings,
+                        auto_home_after_patterns: parseInt(e.target.value) || 5,
+                      })
+                    }
+                  />
+                  <p className="text-xs text-muted-foreground">
+                    Homing occurs after each main pattern completes (clear patterns don't count).
+                  </p>
+                </div>
+              )}
+            </div>
+
+            {/* Machine Reset on Theta Normalization */}
+            <div className="p-4 rounded-lg border space-y-3">
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="font-medium flex items-center gap-2">
+                    <span className="material-icons-outlined text-base">restart_alt</span>
+                    Reset Machine on Theta Normalization
+                  </p>
+                  <p className="text-xs text-muted-foreground mt-1">
+                    Also reset the machine controller when normalizing theta
+                  </p>
+                </div>
+                <Switch
+                  checked={settings.hard_reset_theta || false}
+                  onCheckedChange={(checked) =>
+                    setSettings({ ...settings, hard_reset_theta: checked })
+                  }
+                />
+              </div>
+              <p className="text-xs text-muted-foreground">
+                When disabled (default), theta normalization only adjusts the angle mathematically.
+                When enabled, also resets the machine controller to clear position counters.
+              </p>
+            </div>
+
+            <Button
+              onClick={handleSaveHomingConfig}
+              disabled={isLoading === 'homing'}
+              className="gap-2"
+            >
+              {isLoading === 'homing' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Homing Configuration
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Application Settings */}
+        <AccordionItem value="application" id="section-application" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                tune
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Application Settings</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Customize app name and branding
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* Custom Logo */}
+            <div className="space-y-3">
+              <Label>Custom Logo</Label>
+              <div className="flex flex-col sm:flex-row sm:items-center gap-4 p-4 rounded-lg border">
+                <div className="flex items-center gap-4">
+                  <div className="w-16 h-16 rounded-full overflow-hidden border bg-background flex items-center justify-center shrink-0">
+                    {settings.custom_logo ? (
+                      <img
+                        src={apiClient.getAssetUrl(`/static/custom/${settings.custom_logo}`)}
+                        alt="Custom Logo"
+                        className="w-full h-full object-cover"
+                      />
+                    ) : (
+                      <img
+                        src={apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
+                        alt="Default Logo"
+                        className="w-full h-full object-cover"
+                      />
+                    )}
+                  </div>
+                  <div className="flex-1">
+                    <p className="font-medium">
+                      {settings.custom_logo ? 'Custom logo active' : 'Using default logo'}
+                    </p>
+                    <p className="text-sm text-muted-foreground">
+                      PNG, JPG, GIF, WebP or SVG (max 5MB)
+                    </p>
+                  </div>
+                </div>
+                <div className="flex gap-2 sm:ml-auto">
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    className="gap-2"
+                    disabled={isLoading === 'logo'}
+                    onClick={() => document.getElementById('logo-upload')?.click()}
+                  >
+                    {isLoading === 'logo' ? (
+                      <span className="material-icons-outlined animate-spin text-base">sync</span>
+                    ) : (
+                      <span className="material-icons-outlined text-base">upload</span>
+                    )}
+                    Upload
+                  </Button>
+                  {settings.custom_logo && (
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      className="gap-2 text-destructive hover:text-destructive"
+                      disabled={isLoading === 'logo'}
+                      onClick={handleDeleteLogo}
+                    >
+                      <span className="material-icons-outlined text-base">delete</span>
+                    </Button>
+                  )}
+                </div>
+                <input
+                  id="logo-upload"
+                  type="file"
+                  accept=".png,.jpg,.jpeg,.gif,.webp,.svg"
+                  className="hidden"
+                  onChange={handleLogoUpload}
+                />
+              </div>
+              <p className="text-xs text-muted-foreground">
+                A favicon will be automatically generated from your logo.
+              </p>
+            </div>
+
+            <Separator />
+
+            {/* App Name */}
+            <div className="space-y-3">
+              <Label htmlFor="appName">Application Name</Label>
+              <div className="flex gap-3">
+                <div className="relative flex-1">
+                  <Input
+                    id="appName"
+                    value={settings.app_name || ''}
+                    onChange={(e) =>
+                      setSettings({ ...settings, app_name: e.target.value })
+                    }
+                    placeholder="e.g., Dune Weaver"
+                  />
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
+                    onClick={() => setSettings({ ...settings, app_name: 'Dune Weaver' })}
+                  >
+                    <span className="material-icons text-base">restart_alt</span>
+                  </Button>
+                </div>
+                <Button
+                  onClick={handleSaveAppName}
+                  disabled={isLoading === 'appName'}
+                  className="gap-2"
+                >
+                  {isLoading === 'appName' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">save</span>
+                  )}
+                  Save
+                </Button>
+              </div>
+              <p className="text-xs text-muted-foreground">
+                This name appears in the browser tab and header.
+              </p>
+            </div>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Pattern Clearing */}
+        <AccordionItem value="clearing" id="section-clearing" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                cleaning_services
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Pattern Clearing</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Customize clearing speed and patterns
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            <p className="text-sm text-muted-foreground">
+              Customize the clearing behavior used when transitioning between patterns.
+            </p>
+
+            {/* Clearing Speed */}
+            <div className="p-4 rounded-lg border space-y-3">
+              <h4 className="font-medium">Clearing Speed</h4>
+              <p className="text-sm text-muted-foreground">
+                Set a custom speed for clearing patterns. Leave empty to use the default pattern speed.
+              </p>
+              <div className="space-y-3">
+                <Label htmlFor="clear-speed">Speed (steps per minute)</Label>
+                <Input
+                  id="clear-speed"
+                  type="number"
+                  min="50"
+                  max="2000"
+                  step="50"
+                  value={settings.clear_pattern_speed || ''}
+                  onChange={(e) =>
+                    setSettings({
+                      ...settings,
+                      clear_pattern_speed: e.target.value ? parseInt(e.target.value) : undefined,
+                    })
+                  }
+                  placeholder="Default (use pattern speed)"
+                />
+              </div>
+            </div>
+
+            {/* Custom Clear Patterns */}
+            <div className="p-4 rounded-lg border space-y-3">
+              <h4 className="font-medium">Custom Clear Patterns</h4>
+              <p className="text-sm text-muted-foreground">
+                Choose specific patterns to use when clearing. Leave empty for default behavior.
+              </p>
+
+              <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                <div className="space-y-3">
+                  <Label htmlFor="clear-from-in">Clear From Center Pattern</Label>
+                  <SearchableSelect
+                    value={settings.custom_clear_from_in || '__default__'}
+                    onValueChange={(value) =>
+                      setSettings({ ...settings, custom_clear_from_in: value === '__default__' ? undefined : value })
+                    }
+                    options={[
+                      { value: '__default__', label: 'Default (built-in)' },
+                      ...patternFiles.map((file) => ({ value: file, label: file })),
+                    ]}
+                    placeholder="Default (built-in)"
+                    searchPlaceholder="Search patterns..."
+                    emptyMessage="No patterns found"
+                  />
+                  <p className="text-xs text-muted-foreground">
+                    Pattern used when clearing from center outward.
+                  </p>
+                </div>
+
+                <div className="space-y-3">
+                  <Label htmlFor="clear-from-out">Clear From Perimeter Pattern</Label>
+                  <SearchableSelect
+                    value={settings.custom_clear_from_out || '__default__'}
+                    onValueChange={(value) =>
+                      setSettings({ ...settings, custom_clear_from_out: value === '__default__' ? undefined : value })
+                    }
+                    options={[
+                      { value: '__default__', label: 'Default (built-in)' },
+                      ...patternFiles.map((file) => ({ value: file, label: file })),
+                    ]}
+                    placeholder="Default (built-in)"
+                    searchPlaceholder="Search patterns..."
+                    emptyMessage="No patterns found"
+                  />
+                  <p className="text-xs text-muted-foreground">
+                    Pattern used when clearing from perimeter inward.
+                  </p>
+                </div>
+              </div>
+            </div>
+
+            <Button
+              onClick={handleSaveClearingSettings}
+              disabled={isLoading === 'clearing'}
+              className="gap-2"
+            >
+              {isLoading === 'clearing' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Clearing Settings
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* LED Controller Configuration */}
+        <AccordionItem value="led" id="section-led" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                lightbulb
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">LED Controller</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  WLED or local GPIO LED control
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* LED Provider Selection */}
+            <div className="space-y-3">
+              <Label>LED Provider</Label>
+              <RadioGroup
+                value={ledConfig.provider}
+                onValueChange={(value) =>
+                  setLedConfig({ ...ledConfig, provider: value as LedConfig['provider'] })
+                }
+                className="flex gap-4"
+              >
+                <div className="flex items-center space-x-2">
+                  <RadioGroupItem value="none" id="led-none" />
+                  <Label htmlFor="led-none" className="font-normal">None</Label>
+                </div>
+                <div className="flex items-center space-x-2">
+                  <RadioGroupItem value="wled" id="led-wled" />
+                  <Label htmlFor="led-wled" className="font-normal">WLED</Label>
+                </div>
+                <div className="flex items-center space-x-2">
+                  <RadioGroupItem value="dw_leds" id="led-dw" />
+                  <Label htmlFor="led-dw" className="font-normal">DW LEDs (GPIO)</Label>
+                </div>
+              </RadioGroup>
+            </div>
+
+            {/* WLED Config */}
+            {ledConfig.provider === 'wled' && (
+              <div className="space-y-3 p-4 rounded-lg border">
+                <Label htmlFor="wledIp">WLED IP Address</Label>
+                <Input
+                  id="wledIp"
+                  value={ledConfig.wled_ip || ''}
+                  onChange={(e) =>
+                    setLedConfig({ ...ledConfig, wled_ip: e.target.value })
+                  }
+                  placeholder="e.g., 192.168.1.100"
+                />
+                <p className="text-xs text-muted-foreground">
+                  Enter the IP address of your WLED controller
+                </p>
+              </div>
+            )}
+
+            {/* DW LEDs Config */}
+            {ledConfig.provider === 'dw_leds' && (
+              <div className="space-y-3 p-4 rounded-lg border">
+                <Alert className="flex items-start">
+                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                  <AlertDescription>
+                    Supports WS2812, WS2812B, SK6812 and other WS281x LED strips
+                  </AlertDescription>
+                </Alert>
+
+                <div className="grid grid-cols-2 gap-4">
+                  <div className="space-y-3">
+                    <Label htmlFor="numLeds">Number of LEDs</Label>
+                    <Input
+                      id="numLeds"
+                      type="text"
+                      inputMode="numeric"
+                      value={numLedsInput}
+                      onChange={(e) => {
+                        const val = e.target.value.replace(/[^0-9]/g, '')
+                        setNumLedsInput(val)
+                      }}
+                      onBlur={() => {
+                        const num = Math.min(1000, Math.max(1, parseInt(numLedsInput) || 60))
+                        setLedConfig({ ...ledConfig, num_leds: num })
+                        setNumLedsInput(String(num))
+                      }}
+                      onKeyDown={(e) => {
+                        if (e.key === 'Enter') {
+                          const num = Math.min(1000, Math.max(1, parseInt(numLedsInput) || 60))
+                          setLedConfig({ ...ledConfig, num_leds: num })
+                          setNumLedsInput(String(num))
+                        }
+                      }}
+                    />
+                  </div>
+                  <div className="space-y-3">
+                    <Label htmlFor="gpioPin">GPIO Pin</Label>
+                    <Select
+                      value={String(ledConfig.gpio_pin || 18)}
+                      onValueChange={(value) =>
+                        setLedConfig({ ...ledConfig, gpio_pin: parseInt(value) })
+                      }
+                    >
+                      <SelectTrigger>
+                        <SelectValue />
+                      </SelectTrigger>
+                      <SelectContent>
+                        <SelectItem value="12">GPIO 12 (PWM0)</SelectItem>
+                        <SelectItem value="13">GPIO 13 (PWM1)</SelectItem>
+                        <SelectItem value="18">GPIO 18 (PWM0)</SelectItem>
+                        <SelectItem value="19">GPIO 19 (PWM1)</SelectItem>
+                      </SelectContent>
+                    </Select>
+                  </div>
+                </div>
+
+                <div className="space-y-3">
+                  <Label htmlFor="pixelOrder">Pixel Color Order</Label>
+                  <Select
+                    value={ledConfig.pixel_order || 'RGB'}
+                    onValueChange={(value) =>
+                      setLedConfig({ ...ledConfig, pixel_order: value })
+                    }
+                  >
+                    <SelectTrigger>
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectGroup>
+                        <SelectLabel>RGB Strips (3-channel)</SelectLabel>
+                        <SelectItem value="RGB">RGB - WS2815/WS2811</SelectItem>
+                        <SelectItem value="GRB">GRB - WS2812/WS2812B</SelectItem>
+                        <SelectItem value="BGR">BGR - Some WS2811 variants</SelectItem>
+                        <SelectItem value="RBG">RBG - Rare variant</SelectItem>
+                        <SelectItem value="GBR">GBR - Rare variant</SelectItem>
+                        <SelectItem value="BRG">BRG - Rare variant</SelectItem>
+                      </SelectGroup>
+                      <SelectGroup>
+                        <SelectLabel>RGBW Strips (4-channel)</SelectLabel>
+                        <SelectItem value="GRBW">GRBW - SK6812 RGBW</SelectItem>
+                        <SelectItem value="RGBW">RGBW - SK6812 variant</SelectItem>
+                      </SelectGroup>
+                    </SelectContent>
+                  </Select>
+                </div>
+              </div>
+            )}
+
+            <Button
+              onClick={handleSaveLedConfig}
+              disabled={isLoading === 'led'}
+              className="gap-2"
+            >
+              {isLoading === 'led' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save LED Configuration
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Home Assistant Integration */}
+        <AccordionItem value="mqtt" id="section-mqtt" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                home
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Home Assistant Integration</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  MQTT configuration for smart home control
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* Enable Toggle */}
+            <div className="flex items-center justify-between p-4 rounded-lg border">
+              <div>
+                <p className="font-medium">Enable MQTT</p>
+                <p className="text-sm text-muted-foreground">
+                  Connect to Home Assistant via MQTT
+                </p>
+              </div>
+              <Switch
+                checked={mqttConfig.enabled}
+                onCheckedChange={(checked) =>
+                  setMqttConfig({ ...mqttConfig, enabled: checked })
+                }
+              />
+            </div>
+
+            {mqttConfig.enabled && (
+              <div className="space-y-3">
+                {/* Broker Settings */}
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div className="space-y-3">
+                    <Label htmlFor="mqttBroker">
+                      Broker Address <span className="text-destructive">*</span>
+                    </Label>
+                    <Input
+                      id="mqttBroker"
+                      value={mqttConfig.broker || ''}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, broker: e.target.value })
+                      }
+                      placeholder="e.g., 192.168.1.100"
+                    />
+                  </div>
+                  <div className="space-y-3">
+                    <Label htmlFor="mqttPort">Port</Label>
+                    <Input
+                      id="mqttPort"
+                      type="number"
+                      value={mqttConfig.port || 1883}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, port: parseInt(e.target.value) })
+                      }
+                      placeholder="1883"
+                    />
+                  </div>
+                </div>
+
+                {/* Authentication */}
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div className="space-y-3">
+                    <Label htmlFor="mqttUser">Username</Label>
+                    <Input
+                      id="mqttUser"
+                      value={mqttConfig.username || ''}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, username: e.target.value })
+                      }
+                      placeholder="Optional"
+                    />
+                  </div>
+                  <div className="space-y-3">
+                    <Label htmlFor="mqttPass">Password</Label>
+                    <Input
+                      id="mqttPass"
+                      type="password"
+                      value={mqttConfig.password || ''}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, password: e.target.value })
+                      }
+                      placeholder="Optional"
+                    />
+                  </div>
+                </div>
+
+                <Separator />
+
+                {/* Device Settings */}
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div className="space-y-3">
+                    <Label htmlFor="mqttDeviceName">Device Name</Label>
+                    <Input
+                      id="mqttDeviceName"
+                      value={mqttConfig.device_name || 'Dune Weaver'}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, device_name: e.target.value })
+                      }
+                    />
+                  </div>
+                  <div className="space-y-3">
+                    <Label htmlFor="mqttDeviceId">Device ID</Label>
+                    <Input
+                      id="mqttDeviceId"
+                      value={mqttConfig.device_id || 'dune_weaver'}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, device_id: e.target.value })
+                      }
+                    />
+                  </div>
+                </div>
+
+                <Alert className="flex items-start">
+                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                  <AlertDescription>
+                    MQTT configuration changes require a restart to take effect.
+                  </AlertDescription>
+                </Alert>
+              </div>
+            )}
+
+            <div className="flex flex-wrap gap-3">
+              <Button
+                onClick={handleSaveMqttConfig}
+                disabled={isLoading === 'mqtt'}
+                className="gap-2"
+              >
+                {isLoading === 'mqtt' ? (
+                  <span className="material-icons-outlined animate-spin">sync</span>
+                ) : (
+                  <span className="material-icons-outlined">save</span>
+                )}
+                Save MQTT Configuration
+              </Button>
+              {mqttConfig.enabled && mqttConfig.broker && (
+                <Button
+                  variant="secondary"
+                  onClick={handleTestMqttConnection}
+                  disabled={isLoading === 'mqttTest'}
+                  className="gap-2"
+                >
+                  {isLoading === 'mqttTest' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">wifi_tethering</span>
+                  )}
+                  Test Connection
+                </Button>
+              )}
+            </div>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Auto-play on Boot */}
+        <AccordionItem value="autoplay" id="section-autoplay" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                play_circle
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Auto-play on Boot</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Start a playlist automatically on startup
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            <div className="flex items-center justify-between p-4 rounded-lg border">
+              <div>
+                <p className="font-medium">Enable Auto-play</p>
+                <p className="text-sm text-muted-foreground">
+                  Automatically start playing when the system boots
+                </p>
+              </div>
+              <Switch
+                checked={autoPlaySettings.enabled}
+                onCheckedChange={(checked) =>
+                  setAutoPlaySettings({ ...autoPlaySettings, enabled: checked })
+                }
+              />
+            </div>
+
+            {autoPlaySettings.enabled && (
+              <div className="space-y-3 p-4 rounded-lg border">
+                <div className="space-y-3">
+                  <Label>Startup Playlist</Label>
+                  <Select
+                    value={autoPlaySettings.playlist || undefined}
+                    onValueChange={(value) =>
+                      setAutoPlaySettings({ ...autoPlaySettings, playlist: value })
+                    }
+                  >
+                    <SelectTrigger>
+                      <SelectValue placeholder="Select a playlist..." />
+                    </SelectTrigger>
+                    <SelectContent>
+                      {playlists.length === 0 ? (
+                        <div className="py-6 text-center text-sm text-muted-foreground">
+                          No playlists found
+                        </div>
+                      ) : (
+                        playlists.map((playlist) => (
+                          <SelectItem key={playlist} value={playlist}>
+                            {playlist}
+                          </SelectItem>
+                        ))
+                      )}
+                    </SelectContent>
+                  </Select>
+                  <p className="text-xs text-muted-foreground">
+                    Choose which playlist to play when the system starts.
+                  </p>
+                </div>
+
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div className="space-y-3">
+                    <Label>Run Mode</Label>
+                    <Select
+                      value={autoPlaySettings.run_mode}
+                      onValueChange={(value) =>
+                        setAutoPlaySettings({
+                          ...autoPlaySettings,
+                          run_mode: value as 'single' | 'loop',
+                        })
+                      }
+                    >
+                      <SelectTrigger>
+                        <SelectValue />
+                      </SelectTrigger>
+                      <SelectContent>
+                        <SelectItem value="single">Single (play once)</SelectItem>
+                        <SelectItem value="loop">Loop (repeat forever)</SelectItem>
+                      </SelectContent>
+                    </Select>
+                  </div>
+                  <div className="space-y-3">
+                    <Label>Pause Between Patterns</Label>
+                    <div className="flex gap-2">
+                      <Input
+                        type="text"
+                        inputMode="numeric"
+                        value={autoPlayPauseInput}
+                        onChange={(e) => {
+                          const val = e.target.value.replace(/[^0-9]/g, '')
+                          setAutoPlayPauseInput(val)
+                        }}
+                        onBlur={() => {
+                          const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
+                          setAutoPlayPauseValue(num)
+                          setAutoPlayPauseInput(String(num))
+                        }}
+                        onKeyDown={(e) => {
+                          if (e.key === 'Enter') {
+                            const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
+                            setAutoPlayPauseValue(num)
+                            setAutoPlayPauseInput(String(num))
+                          }
+                        }}
+                        className="w-20"
+                      />
+                      <Select
+                        value={autoPlayPauseUnit}
+                        onValueChange={(v) => setAutoPlayPauseUnit(v as 'sec' | 'min' | 'hr')}
+                      >
+                        <SelectTrigger className="w-20">
+                          <SelectValue />
+                        </SelectTrigger>
+                        <SelectContent>
+                          <SelectItem value="sec">sec</SelectItem>
+                          <SelectItem value="min">min</SelectItem>
+                          <SelectItem value="hr">hr</SelectItem>
+                        </SelectContent>
+                      </Select>
+                    </div>
+                  </div>
+                </div>
+
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div className="space-y-3">
+                    <Label>Clear Pattern</Label>
+                    <Select
+                      value={autoPlaySettings.clear_pattern}
+                      onValueChange={(value) =>
+                        setAutoPlaySettings({ ...autoPlaySettings, clear_pattern: value })
+                      }
+                    >
+                      <SelectTrigger>
+                        <SelectValue />
+                      </SelectTrigger>
+                      <SelectContent>
+                        <SelectItem value="none">None</SelectItem>
+                        <SelectItem value="adaptive">Adaptive</SelectItem>
+                        <SelectItem value="clear_from_in">Clear From Center</SelectItem>
+                        <SelectItem value="clear_from_out">Clear From Perimeter</SelectItem>
+                        <SelectItem value="clear_sideway">Clear Sideways</SelectItem>
+                        <SelectItem value="random">Random</SelectItem>
+                      </SelectContent>
+                    </Select>
+                    <p className="text-xs text-muted-foreground">
+                      Pattern to run before each main pattern.
+                    </p>
+                  </div>
+
+                  <div className="flex items-center justify-between">
+                    <div className="flex-1">
+                      <p className="text-sm font-medium">Shuffle Playlist</p>
+                      <p className="text-xs text-muted-foreground">
+                        Randomize pattern order
+                      </p>
+                    </div>
+                    <Switch
+                      checked={autoPlaySettings.shuffle}
+                      onCheckedChange={(checked) =>
+                        setAutoPlaySettings({ ...autoPlaySettings, shuffle: checked })
+                      }
+                    />
+                  </div>
+                </div>
+              </div>
+            )}
+
+            <Button
+              onClick={handleSaveAutoPlaySettings}
+              disabled={isLoading === 'autoplay'}
+              className="gap-2"
+            >
+              {isLoading === 'autoplay' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Auto-play Settings
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Still Sands */}
+        <AccordionItem value="stillsands" id="section-stillsands" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                bedtime
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Still Sands</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Schedule quiet periods for your table
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            <div className="flex items-center justify-between p-4 rounded-lg border">
+              <div>
+                <p className="font-medium">Enable Still Sands</p>
+                <p className="text-sm text-muted-foreground">
+                  Pause the table during specified time periods
+                </p>
+              </div>
+              <Switch
+                checked={stillSandsSettings.enabled}
+                onCheckedChange={(checked) =>
+                  setStillSandsSettings({ ...stillSandsSettings, enabled: checked })
+                }
+              />
+            </div>
+
+            {stillSandsSettings.enabled && (
+              <div className="space-y-3">
+                {/* Options */}
+                <div className="p-4 rounded-lg border space-y-3">
+                  <div className="flex items-center justify-between">
+                    <div className="flex items-center gap-2">
+                      <span className="material-icons-outlined text-base text-muted-foreground">
+                        hourglass_bottom
+                      </span>
+                      <div>
+                        <p className="text-sm font-medium">Finish Current Pattern</p>
+                        <p className="text-xs text-muted-foreground">
+                          Let the current pattern complete before entering still mode
+                        </p>
+                      </div>
+                    </div>
+                    <Switch
+                      checked={stillSandsSettings.finish_pattern}
+                      onCheckedChange={(checked) =>
+                        setStillSandsSettings({ ...stillSandsSettings, finish_pattern: checked })
+                      }
+                    />
+                  </div>
+
+                  <Separator />
+
+                  <div className="flex items-center justify-between">
+                    <div className="flex items-center gap-2">
+                      <span className="material-icons-outlined text-base text-muted-foreground">
+                        lightbulb
+                      </span>
+                      <div>
+                        <p className="text-sm font-medium">Control LED Lights</p>
+                        <p className="text-xs text-muted-foreground">
+                          Turn off LED lights during still periods
+                        </p>
+                      </div>
+                    </div>
+                    <Switch
+                      checked={stillSandsSettings.control_wled}
+                      onCheckedChange={(checked) =>
+                        setStillSandsSettings({ ...stillSandsSettings, control_wled: checked })
+                      }
+                    />
+                  </div>
+
+                  {/* Timezone */}
+                  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 pt-3 border-t">
+                    <div className="flex items-center gap-3">
+                      <span className="material-icons-outlined text-muted-foreground">
+                        schedule
+                      </span>
+                      <div>
+                        <p className="text-sm font-medium">Timezone</p>
+                        <p className="text-xs text-muted-foreground">
+                          Select a timezone for scheduling
+                        </p>
+                      </div>
+                    </div>
+                    <SearchableSelect
+                      value={stillSandsSettings.timezone || ''}
+                      onValueChange={(value) =>
+                        setStillSandsSettings({ ...stillSandsSettings, timezone: value })
+                      }
+                      placeholder="System Default"
+                      searchPlaceholder="Search timezones..."
+                      className="w-full sm:w-[200px]"
+                      options={[
+                        { value: '', label: 'System Default' },
+                        { value: 'Etc/GMT+12', label: 'UTC-12' },
+                        { value: 'Etc/GMT+11', label: 'UTC-11' },
+                        { value: 'Etc/GMT+10', label: 'UTC-10' },
+                        { value: 'Etc/GMT+9', label: 'UTC-9' },
+                        { value: 'Etc/GMT+8', label: 'UTC-8' },
+                        { value: 'Etc/GMT+7', label: 'UTC-7' },
+                        { value: 'Etc/GMT+6', label: 'UTC-6' },
+                        { value: 'Etc/GMT+5', label: 'UTC-5' },
+                        { value: 'Etc/GMT+4', label: 'UTC-4' },
+                        { value: 'Etc/GMT+3', label: 'UTC-3' },
+                        { value: 'Etc/GMT+2', label: 'UTC-2' },
+                        { value: 'Etc/GMT+1', label: 'UTC-1' },
+                        { value: 'UTC', label: 'UTC' },
+                        { value: 'Etc/GMT-1', label: 'UTC+1' },
+                        { value: 'Etc/GMT-2', label: 'UTC+2' },
+                        { value: 'Etc/GMT-3', label: 'UTC+3' },
+                        { value: 'Etc/GMT-4', label: 'UTC+4' },
+                        { value: 'Etc/GMT-5', label: 'UTC+5' },
+                        { value: 'Etc/GMT-6', label: 'UTC+6' },
+                        { value: 'Etc/GMT-7', label: 'UTC+7' },
+                        { value: 'Etc/GMT-8', label: 'UTC+8' },
+                        { value: 'Etc/GMT-9', label: 'UTC+9' },
+                        { value: 'Etc/GMT-10', label: 'UTC+10' },
+                        { value: 'Etc/GMT-11', label: 'UTC+11' },
+                        { value: 'Etc/GMT-12', label: 'UTC+12' },
+                        { value: 'America/New_York', label: 'America/New_York (Eastern)' },
+                        { value: 'America/Chicago', label: 'America/Chicago (Central)' },
+                        { value: 'America/Denver', label: 'America/Denver (Mountain)' },
+                        { value: 'America/Los_Angeles', label: 'America/Los_Angeles (Pacific)' },
+                        { value: 'Europe/London', label: 'Europe/London' },
+                        { value: 'Europe/Paris', label: 'Europe/Paris' },
+                        { value: 'Europe/Berlin', label: 'Europe/Berlin' },
+                        { value: 'Asia/Tokyo', label: 'Asia/Tokyo' },
+                        { value: 'Asia/Shanghai', label: 'Asia/Shanghai' },
+                        { value: 'Asia/Singapore', label: 'Asia/Singapore' },
+                        { value: 'Australia/Sydney', label: 'Australia/Sydney' },
+                      ]}
+                    />
+                  </div>
+                </div>
+
+                {/* Time Slots */}
+                <div className="p-4 rounded-lg border space-y-3">
+                  <div className="flex items-center justify-between">
+                    <h4 className="font-medium">Still Periods</h4>
+                    <Button onClick={addTimeSlot} size="sm" variant="secondary" className="gap-1">
+                      <span className="material-icons text-base">add</span>
+                      Add Period
+                    </Button>
+                  </div>
+
+                  <p className="text-sm text-muted-foreground">
+                    Define time periods when the sands should rest.
+                  </p>
+
+                  {stillSandsSettings.time_slots.length === 0 ? (
+                    <div className="text-center py-6 text-muted-foreground">
+                      <span className="material-icons text-3xl mb-2">schedule</span>
+                      <p className="text-sm">No still periods configured</p>
+                      <p className="text-xs">Click "Add Period" to create one</p>
+                    </div>
+                  ) : (
+                    <div className="space-y-3">
+                      {stillSandsSettings.time_slots.map((slot, index) => (
+                        <div
+                          key={index}
+                          className="p-3 border rounded-lg bg-muted/50 space-y-3 overflow-hidden"
+                        >
+                          <div className="flex items-center justify-between -mr-1">
+                            <span className="text-sm font-medium">Period {index + 1}</span>
+                            <Button
+                              variant="ghost"
+                              size="icon"
+                              onClick={() => removeTimeSlot(index)}
+                              className="h-7 w-7 text-destructive hover:text-destructive"
+                            >
+                              <span className="material-icons text-lg">delete</span>
+                            </Button>
+                          </div>
+
+                          <div className="grid grid-cols-[1fr_1fr] gap-2">
+                            <div className="space-y-1.5 min-w-0 overflow-hidden">
+                              <Label className="text-xs">Start Time</Label>
+                              <Input
+                                type="time"
+                                value={slot.start_time}
+                                onChange={(e) =>
+                                  updateTimeSlot(index, { start_time: e.target.value })
+                                }
+                                className="text-xs w-full"
+                              />
+                            </div>
+                            <div className="space-y-1.5 min-w-0 overflow-hidden">
+                              <Label className="text-xs">End Time</Label>
+                              <Input
+                                type="time"
+                                value={slot.end_time}
+                                onChange={(e) =>
+                                  updateTimeSlot(index, { end_time: e.target.value })
+                                }
+                                className="text-xs w-full"
+                              />
+                            </div>
+                          </div>
+
+                          <div className="space-y-1.5">
+                            <Label className="text-xs">Days</Label>
+                            <Select
+                              value={slot.days}
+                              onValueChange={(value) =>
+                                updateTimeSlot(index, {
+                                  days: value as TimeSlot['days'],
+                                  ...(value !== 'custom' ? { custom_days: [] } : {}),
+                                })
+                              }
+                            >
+                              <SelectTrigger>
+                                <SelectValue />
+                              </SelectTrigger>
+                              <SelectContent>
+                                <SelectItem value="daily">Daily</SelectItem>
+                                <SelectItem value="weekdays">Weekdays</SelectItem>
+                                <SelectItem value="weekends">Weekends</SelectItem>
+                                <SelectItem value="custom">Custom</SelectItem>
+                              </SelectContent>
+                            </Select>
+                          </div>
+
+                          {slot.days === 'custom' && (
+                            <div className="space-y-1.5">
+                              <Label className="text-xs">Select Days</Label>
+                              <div className="flex flex-wrap gap-1.5">
+                                {[
+                                  { key: 'monday', label: 'Mon' },
+                                  { key: 'tuesday', label: 'Tue' },
+                                  { key: 'wednesday', label: 'Wed' },
+                                  { key: 'thursday', label: 'Thu' },
+                                  { key: 'friday', label: 'Fri' },
+                                  { key: 'saturday', label: 'Sat' },
+                                  { key: 'sunday', label: 'Sun' },
+                                ].map((day) => {
+                                  const isSelected = slot.custom_days?.includes(day.key)
+                                  return (
+                                    <button
+                                      key={day.key}
+                                      type="button"
+                                      onClick={() => {
+                                        const currentDays = slot.custom_days || []
+                                        const newDays = isSelected
+                                          ? currentDays.filter((d) => d !== day.key)
+                                          : [...currentDays, day.key]
+                                        updateTimeSlot(index, { custom_days: newDays })
+                                      }}
+                                      className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
+                                        isSelected
+                                          ? 'bg-primary text-primary-foreground border-primary'
+                                          : 'bg-background text-muted-foreground border-input hover:bg-accent'
+                                      }`}
+                                    >
+                                      {day.label}
+                                    </button>
+                                  )
+                                })}
+                              </div>
+                            </div>
+                          )}
+                        </div>
+                      ))}
+                    </div>
+                  )}
+                </div>
+
+                <Alert className="flex items-start">
+                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                  <AlertDescription>
+                    Times are based on the timezone selected above (or system default). Still
+                    periods that span midnight (e.g., 22:00 to 06:00) are supported. Patterns
+                    resume automatically when still periods end.
+                  </AlertDescription>
+                </Alert>
+              </div>
+            )}
+
+            <Button
+              onClick={handleSaveStillSandsSettings}
+              disabled={isLoading === 'stillsands'}
+              className="gap-2"
+            >
+              {isLoading === 'stillsands' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Still Sands Settings
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Software Version */}
+        <AccordionItem value="version" id="section-version" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                info
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Software Version</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Updates and system information
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-3">
+            <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
+              <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
+                <span className="material-icons text-muted-foreground">terminal</span>
+              </div>
+              <div className="flex-1">
+                <p className="font-medium">Current Version</p>
+                <p className="text-sm text-muted-foreground">
+                  {versionInfo?.current ? `v${versionInfo.current}` : 'Loading...'}
+                </p>
+              </div>
+            </div>
+
+            <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
+              <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
+                <span className="material-icons text-muted-foreground">system_update</span>
+              </div>
+              <div className="flex-1">
+                <p className="font-medium">Latest Version</p>
+                <p className={`text-sm ${versionInfo?.update_available ? 'text-green-600 dark:text-green-400 font-medium' : 'text-muted-foreground'}`}>
+                  {versionInfo?.latest ? (
+                    <a
+                      href={`https://github.com/tuanchris/dune-weaver/releases/tag/v${versionInfo.latest}`}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="underline underline-offset-2 hover:opacity-80 transition-opacity"
+                    >
+                      v{versionInfo.latest}
+                    </a>
+                  ) : 'Checking...'}
+                  {versionInfo?.update_available && ' (Update available!)'}
+                </p>
+              </div>
+            </div>
+
+            {versionInfo?.update_available && (
+              <Alert className="flex items-start">
+                <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                <AlertDescription>
+                  To update, SSH into your Raspberry Pi and run <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">dw update</code>
+                </AlertDescription>
+              </Alert>
+            )}
+          </AccordionContent>
+        </AccordionItem>
+      </Accordion>
+    </div>
+  )
+}

+ 912 - 0
frontend/src/pages/TableControlPage.tsx

@@ -0,0 +1,912 @@
+import { useState, useEffect, useRef } from 'react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardHeader,
+  CardTitle,
+} from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Separator } from '@/components/ui/separator'
+import { Badge } from '@/components/ui/badge'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+  DialogFooter,
+} from '@/components/ui/dialog'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { apiClient } from '@/lib/apiClient'
+
+export function TableControlPage() {
+  const [speedInput, setSpeedInput] = useState('')
+  const [currentSpeed, setCurrentSpeed] = useState<number | null>(null)
+  const [currentTheta, setCurrentTheta] = useState(0)
+  const [isLoading, setIsLoading] = useState<string | null>(null)
+  const [isPatternRunning, setIsPatternRunning] = useState(false)
+
+  // Serial terminal state
+  const [serialPorts, setSerialPorts] = useState<string[]>([])
+  const [selectedSerialPort, setSelectedSerialPort] = useState('')
+  const [serialConnected, setSerialConnected] = useState(false)
+  const [serialCommand, setSerialCommand] = useState('')
+  const [serialHistory, setSerialHistory] = useState<Array<{ type: 'cmd' | 'resp' | 'error'; text: string; time: string }>>([])
+  const [serialLoading, setSerialLoading] = useState(false)
+  const serialOutputRef = useRef<HTMLDivElement>(null)
+  const serialInputRef = useRef<HTMLInputElement>(null)
+
+  // Connect to status WebSocket to get current speed and playback status
+  useEffect(() => {
+    let ws: WebSocket | null = null
+    let shouldReconnect = true
+
+    const connect = () => {
+      if (!shouldReconnect) return
+
+      // Don't interrupt an existing connection that's still connecting
+      if (ws) {
+        if (ws.readyState === WebSocket.CONNECTING) {
+          return // Already connecting, wait for it
+        }
+        if (ws.readyState === WebSocket.OPEN) {
+          ws.close()
+        }
+        ws = null
+      }
+
+      ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
+
+      ws.onopen = () => {
+        if (!shouldReconnect) {
+          // Component unmounted while connecting - close the WebSocket now
+          ws?.close()
+        }
+      }
+
+      ws.onmessage = (event) => {
+        if (!shouldReconnect) return
+        try {
+          const message = JSON.parse(event.data)
+          if (message.type === 'status_update' && message.data) {
+            if (message.data.speed !== null && message.data.speed !== undefined) {
+              setCurrentSpeed(message.data.speed)
+            }
+            // Track if a pattern is running or paused
+            setIsPatternRunning(message.data.is_running || message.data.is_paused)
+          }
+        } catch (error) {
+          console.error('Failed to parse status:', error)
+        }
+      }
+    }
+
+    connect()
+
+    // Reconnect when table changes
+    const unsubscribe = apiClient.onBaseUrlChange(() => {
+      connect()
+    })
+
+    return () => {
+      shouldReconnect = false
+      unsubscribe()
+      if (ws) {
+        // Only close if already OPEN - CONNECTING WebSockets will close in onopen
+        if (ws.readyState === WebSocket.OPEN) {
+          ws.close()
+        }
+        ws = null
+      }
+    }
+  }, [])
+
+  const handleAction = async (
+    action: string,
+    endpoint: string,
+    body?: object
+  ) => {
+    setIsLoading(action)
+    try {
+      const data = await apiClient.post<{ success?: boolean; detail?: string }>(endpoint, body)
+      if (data.success !== false) {
+        return { success: true, data }
+      }
+      throw new Error(data.detail || 'Action failed')
+    } catch (error) {
+      console.error(`Error with ${action}:`, error)
+      throw error
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  // Helper to check if pattern is running and show warning
+  const checkPatternRunning = (actionName: string): boolean => {
+    if (isPatternRunning) {
+      toast.error(`Cannot ${actionName} while a pattern is running. Stop the pattern first.`, {
+        action: {
+          label: 'Stop',
+          onClick: () => handleStop(),
+        },
+      })
+      return true
+    }
+    return false
+  }
+
+  const handleHome = async () => {
+    try {
+      await handleAction('home', '/send_home')
+      toast.success('Moving to home position...')
+    } catch {
+      toast.error('Failed to move to home position')
+    }
+  }
+
+  const handleStop = async () => {
+    try {
+      await handleAction('stop', '/stop_execution')
+      toast.success('Execution stopped')
+    } catch {
+      // Normal stop failed, try force stop
+      try {
+        await handleAction('stop', '/force_stop')
+        toast.success('Force stopped')
+      } catch {
+        toast.error('Failed to stop execution')
+      }
+    }
+  }
+
+  const handleReset = async () => {
+    try {
+      await handleAction('reset', '/soft_reset')
+      toast.success('Reset sent. Please home the table.')
+    } catch {
+      toast.error('Failed to send reset command')
+    }
+  }
+
+  const handleMoveToCenter = async () => {
+    if (checkPatternRunning('move to center')) return
+    try {
+      await handleAction('center', '/move_to_center')
+      toast.success('Moving to center...')
+    } catch {
+      toast.error('Failed to move to center')
+    }
+  }
+
+  const handleMoveToPerimeter = async () => {
+    if (checkPatternRunning('move to perimeter')) return
+    try {
+      await handleAction('perimeter', '/move_to_perimeter')
+      toast.success('Moving to perimeter...')
+    } catch {
+      toast.error('Failed to move to perimeter')
+    }
+  }
+
+  const handleSetSpeed = async () => {
+    const speed = parseFloat(speedInput)
+    if (isNaN(speed) || speed <= 0) {
+      toast.error('Please enter a valid speed value')
+      return
+    }
+    try {
+      await handleAction('speed', '/set_speed', { speed })
+      setCurrentSpeed(speed)
+      toast.success(`Speed set to ${speed} mm/s`)
+      setSpeedInput('')
+    } catch {
+      toast.error('Failed to set speed')
+    }
+  }
+
+  const handleClearPattern = async (patternFile: string, label: string) => {
+    try {
+      await handleAction(patternFile, '/run_theta_rho', {
+        file_name: patternFile,
+        pre_execution: 'none',
+      })
+      toast.success(`Running ${label}...`)
+    } catch (error) {
+      if (error instanceof Error && error.message.includes('409')) {
+        toast.error('Another pattern is already running')
+      } else {
+        toast.error(`Failed to run ${label}`)
+      }
+    }
+  }
+
+  const handleRotate = async (degrees: number) => {
+    if (checkPatternRunning('align')) return
+    try {
+      const radians = degrees * (Math.PI / 180)
+      const newTheta = currentTheta + radians
+      await handleAction('rotate', '/send_coordinate', { theta: newTheta, rho: 1 })
+      setCurrentTheta(newTheta)
+      toast.info(`Rotated ${degrees}°`)
+    } catch {
+      toast.error('Failed to rotate')
+    }
+  }
+
+  // Serial terminal functions
+  const fetchSerialPorts = async () => {
+    try {
+      const data = await apiClient.get<string[]>('/list_serial_ports')
+      setSerialPorts(Array.isArray(data) ? data : [])
+    } catch {
+      toast.error('Failed to fetch serial ports')
+    }
+  }
+
+  const fetchMainConnectionStatus = async () => {
+    try {
+      // Fetch available ports first to validate against
+      const portsData = await apiClient.get<string[]>('/list_serial_ports')
+      const availablePorts = Array.isArray(portsData) ? portsData : []
+
+      const data = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
+      if (data.connected && data.port) {
+        // Only set port if it exists in available ports
+        // This prevents race conditions where stale port data from a different
+        // backend (e.g., Mac port on a Pi) could be set and auto-connected
+        if (availablePorts.includes(data.port)) {
+          setSelectedSerialPort(data.port)
+        } else {
+          console.warn(`Port ${data.port} from status not in available ports, ignoring`)
+        }
+      }
+    } catch {
+      // Ignore errors
+    }
+  }
+
+  const handleSerialConnect = async (silent = false) => {
+    if (!selectedSerialPort) {
+      if (!silent) toast.error('Please select a serial port')
+      return
+    }
+    setSerialLoading(true)
+    try {
+      await apiClient.post('/api/debug-serial/open', { port: selectedSerialPort })
+      setSerialConnected(true)
+      addSerialHistory('resp', `Connected to ${selectedSerialPort}`)
+      if (!silent) toast.success(`Connected to ${selectedSerialPort}`)
+    } catch (error) {
+      const errorMsg = error instanceof Error ? error.message : 'Unknown error'
+      addSerialHistory('error', `Failed to connect: ${errorMsg}`)
+      if (!silent) toast.error('Failed to connect to serial port')
+    } finally {
+      setSerialLoading(false)
+    }
+  }
+
+  const handleSerialDisconnect = async () => {
+    setSerialLoading(true)
+    try {
+      await apiClient.post('/api/debug-serial/close', { port: selectedSerialPort })
+      setSerialConnected(false)
+      addSerialHistory('resp', 'Disconnected')
+      toast.success('Disconnected from serial port')
+    } catch {
+      toast.error('Failed to disconnect')
+    } finally {
+      setSerialLoading(false)
+    }
+  }
+
+  const addSerialHistory = (type: 'cmd' | 'resp' | 'error', text: string) => {
+    const time = new Date().toLocaleTimeString()
+    setSerialHistory((prev) => [...prev.slice(-200), { type, text, time }])
+    setTimeout(() => {
+      if (serialOutputRef.current) {
+        serialOutputRef.current.scrollTop = serialOutputRef.current.scrollHeight
+      }
+    }, 10)
+  }
+
+  const handleSerialSend = async () => {
+    if (!serialCommand.trim() || !serialConnected || serialLoading) return
+
+    const cmd = serialCommand.trim()
+    setSerialCommand('')
+    setSerialLoading(true)
+    addSerialHistory('cmd', cmd)
+
+    try {
+      const data = await apiClient.post<{ responses?: string[]; detail?: string }>('/api/debug-serial/send', { port: selectedSerialPort, command: cmd })
+      if (data.responses) {
+        if (data.responses.length > 0) {
+          data.responses.forEach((line: string) => addSerialHistory('resp', line))
+        } else {
+          addSerialHistory('resp', '(no response)')
+        }
+      } else if (data.detail) {
+        addSerialHistory('error', data.detail || 'Command failed')
+      }
+    } catch (error) {
+      addSerialHistory('error', `Error: ${error}`)
+    } finally {
+      setSerialLoading(false)
+      setTimeout(() => serialInputRef.current?.focus(), 0)
+    }
+  }
+
+  const handleSerialReset = async () => {
+    if (!serialConnected || serialLoading) return
+
+    setSerialLoading(true)
+    addSerialHistory('cmd', '[Soft Reset]')
+
+    try {
+      // Send soft reset command (backend auto-detects: $Bye for FluidNC, Ctrl+X for GRBL)
+      const data = await apiClient.post<{ responses?: string[]; detail?: string }>('/api/debug-serial/send', { port: selectedSerialPort, command: '\x18' })
+      if (data.responses && data.responses.length > 0) {
+        data.responses.forEach((line: string) => addSerialHistory('resp', line))
+      } else {
+        addSerialHistory('resp', 'Reset sent')
+      }
+      toast.success('Reset command sent')
+    } catch (error) {
+      addSerialHistory('error', `Reset failed: ${error}`)
+      toast.error('Failed to send reset')
+    } finally {
+      setSerialLoading(false)
+    }
+  }
+
+  const handleSerialKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === 'Enter' && !e.shiftKey) {
+      e.preventDefault()
+      if (!serialLoading) {
+        handleSerialSend()
+      }
+    }
+  }
+
+  // Fetch serial ports and main connection status on mount
+  useEffect(() => {
+    fetchSerialPorts()
+    fetchMainConnectionStatus()
+  }, [])
+
+  return (
+    <TooltipProvider>
+      <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
+        {/* Page Header */}
+        <div className="space-y-0.5 sm:space-y-1 pl-1">
+          <h1 className="text-xl font-semibold tracking-tight">Table Control</h1>
+          <p className="text-xs text-muted-foreground">
+            Manual controls for your sand table
+          </p>
+        </div>
+
+        <Separator />
+
+        {/* Main Controls Grid - 2x2 */}
+        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+          {/* Primary Actions */}
+          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
+            <CardHeader className="pb-3">
+              <CardTitle className="text-lg">Primary Actions</CardTitle>
+              <CardDescription>Calibrate or stop the table</CardDescription>
+            </CardHeader>
+            <CardContent>
+              <div className="grid grid-cols-3 gap-3">
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleHome}
+                      disabled={isLoading === 'home'}
+                      variant="primary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'home' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">home</span>
+                      )}
+                      <span className="text-xs">Home</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Return to home position</TooltipContent>
+                </Tooltip>
+
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleStop}
+                      disabled={isLoading === 'stop'}
+                      variant="destructive"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'stop' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">stop_circle</span>
+                      )}
+                      <span className="text-xs">Stop</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Gracefully stop</TooltipContent>
+                </Tooltip>
+
+                <Dialog>
+                  <Tooltip>
+                    <TooltipTrigger asChild>
+                      <DialogTrigger asChild>
+                        <Button
+                          disabled={isLoading === 'reset'}
+                          variant="secondary"
+                          className="h-16 gap-1 flex-col items-center justify-center"
+                        >
+                          {isLoading === 'reset' ? (
+                            <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                          ) : (
+                            <span className="material-icons-outlined text-2xl">restart_alt</span>
+                          )}
+                          <span className="text-xs">Reset</span>
+                        </Button>
+                      </DialogTrigger>
+                    </TooltipTrigger>
+                    <TooltipContent>Send soft reset to controller</TooltipContent>
+                  </Tooltip>
+                  <DialogContent className="sm:max-w-md">
+                    <DialogHeader>
+                      <DialogTitle>Reset Controller?</DialogTitle>
+                      <DialogDescription>
+                        This will send a soft reset to the controller.
+                      </DialogDescription>
+                    </DialogHeader>
+                    <Alert className="flex items-center border-amber-500/50">
+                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
+                      <AlertDescription className="text-amber-600 dark:text-amber-400">
+                        Homing is required after resetting. The table will lose its position reference.
+                      </AlertDescription>
+                    </Alert>
+                    <DialogFooter className="gap-2 sm:gap-0">
+                      <DialogTrigger asChild>
+                        <Button variant="outline">Cancel</Button>
+                      </DialogTrigger>
+                      <DialogTrigger asChild>
+                        <Button variant="destructive" onClick={handleReset}>
+                          Reset Controller
+                        </Button>
+                      </DialogTrigger>
+                    </DialogFooter>
+                  </DialogContent>
+                </Dialog>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Speed Control */}
+          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
+            <CardHeader className="pb-3">
+              <div className="flex items-center justify-between">
+                <div>
+                  <CardTitle className="text-lg">Speed</CardTitle>
+                  <CardDescription>Ball movement speed</CardDescription>
+                </div>
+                <Badge variant="secondary" className="font-mono">
+                  {currentSpeed !== null ? `${currentSpeed} mm/s` : '-- mm/s'}
+                </Badge>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <div className="flex gap-2">
+                <Input
+                  type="number"
+                  value={speedInput}
+                  onChange={(e) => setSpeedInput(e.target.value)}
+                  placeholder="mm/s"
+                  min="1"
+                  step="1"
+                  className="flex-1"
+                  onKeyDown={(e) => e.key === 'Enter' && handleSetSpeed()}
+                />
+                <Button
+                  onClick={handleSetSpeed}
+                  disabled={isLoading === 'speed' || !speedInput}
+                  className="gap-2"
+                >
+                  {isLoading === 'speed' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">check</span>
+                  )}
+                  Set
+                </Button>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Position */}
+          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
+            <CardHeader className="pb-3">
+              <CardTitle className="text-lg">Position</CardTitle>
+              <CardDescription>Move ball to a specific location</CardDescription>
+            </CardHeader>
+            <CardContent>
+              <div className="grid grid-cols-3 gap-3">
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleMoveToCenter}
+                      disabled={isLoading === 'center'}
+                      variant="secondary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'center' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">center_focus_strong</span>
+                      )}
+                      <span className="text-xs">Center</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Move ball to center</TooltipContent>
+                </Tooltip>
+
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleMoveToPerimeter}
+                      disabled={isLoading === 'perimeter'}
+                      variant="secondary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'perimeter' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">trip_origin</span>
+                      )}
+                      <span className="text-xs">Perimeter</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Move ball to edge</TooltipContent>
+                </Tooltip>
+
+                <Dialog>
+                  <Tooltip>
+                    <TooltipTrigger asChild>
+                      <DialogTrigger asChild>
+                        <Button
+                          variant="secondary"
+                          className="h-16 gap-1 flex-col items-center justify-center"
+                        >
+                          <span className="material-icons-outlined text-2xl">screen_rotation</span>
+                          <span className="text-xs">Align</span>
+                        </Button>
+                      </DialogTrigger>
+                    </TooltipTrigger>
+                    <TooltipContent>Align pattern orientation</TooltipContent>
+                  </Tooltip>
+                <DialogContent className="sm:max-w-md">
+                  <DialogHeader>
+                    <DialogTitle>Pattern Orientation Alignment</DialogTitle>
+                    <DialogDescription>
+                      Follow these steps to align your patterns with their previews
+                    </DialogDescription>
+                  </DialogHeader>
+                  <div className="space-y-4 py-4">
+                    <ol className="space-y-3 text-sm">
+                      {[
+                        'Home the table then select move to perimeter. Look at your pattern preview and decide where the "bottom" should be.',
+                        'Manually move the radial arm or use the rotation buttons below to point 90° to the right of where you want the pattern bottom.',
+                        'Click the "Home" button to establish this as the reference position.',
+                        'All patterns will now be oriented according to their previews!',
+                      ].map((step, i) => (
+                        <li key={i} className="flex gap-3">
+                          <Badge
+                            variant="secondary"
+                            className="h-6 w-6 shrink-0 items-center justify-center rounded-full p-0"
+                          >
+                            {i + 1}
+                          </Badge>
+                          <span className="text-muted-foreground">{step}</span>
+                        </li>
+                      ))}
+                    </ol>
+
+                    <Separator />
+
+                    <Alert className="flex items-start border-amber-500/50">
+                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">
+                        warning
+                      </span>
+                      <AlertDescription className="text-amber-600 dark:text-amber-400">
+                        Only perform this when you want to change the orientation reference.
+                      </AlertDescription>
+                    </Alert>
+
+                    <div className="space-y-3">
+                      <p className="text-sm font-medium text-center">Fine Adjustment</p>
+                      <div className="flex justify-center gap-2">
+                        <Button
+                          variant="secondary"
+                          onClick={() => handleRotate(-10)}
+                          disabled={isLoading === 'rotate'}
+                        >
+                          <span className="material-icons text-lg mr-1">rotate_left</span>
+                          CCW 10°
+                        </Button>
+                        <Button
+                          variant="secondary"
+                          onClick={() => handleRotate(10)}
+                          disabled={isLoading === 'rotate'}
+                        >
+                          CW 10°
+                          <span className="material-icons text-lg ml-1">rotate_right</span>
+                        </Button>
+                      </div>
+                      <p className="text-xs text-muted-foreground text-center">
+                        Each click rotates 10 degrees
+                      </p>
+                    </div>
+                  </div>
+                  <DialogFooter>
+                    <DialogTrigger asChild>
+                      <Button>Got it</Button>
+                    </DialogTrigger>
+                  </DialogFooter>
+                </DialogContent>
+                </Dialog>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Clear Patterns */}
+          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
+            <CardHeader className="pb-3">
+              <CardTitle className="text-lg">Clear Sand</CardTitle>
+              <CardDescription>Erase current pattern from the table</CardDescription>
+            </CardHeader>
+            <CardContent>
+              <div className="grid grid-cols-3 gap-3">
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={() => handleClearPattern('clear_from_in.thr', 'clear from center')}
+                      disabled={isLoading === 'clear_from_in.thr'}
+                      variant="secondary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'clear_from_in.thr' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">center_focus_strong</span>
+                      )}
+                      <span className="text-xs">Clear Center</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Spiral outward from center</TooltipContent>
+                </Tooltip>
+
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={() => handleClearPattern('clear_from_out.thr', 'clear from perimeter')}
+                      disabled={isLoading === 'clear_from_out.thr'}
+                      variant="secondary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'clear_from_out.thr' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">all_out</span>
+                      )}
+                      <span className="text-xs">Clear Edge</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Spiral inward from edge</TooltipContent>
+                </Tooltip>
+
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={() => handleClearPattern('clear_sideway.thr', 'clear sideways')}
+                      disabled={isLoading === 'clear_sideway.thr'}
+                      variant="secondary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'clear_sideway.thr' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">swap_horiz</span>
+                      )}
+                      <span className="text-xs">Clear Sideways</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Clear with side-to-side motion</TooltipContent>
+                </Tooltip>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+
+        {/* Serial Terminal */}
+        <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
+          <CardHeader className="pb-3 space-y-3">
+            <div className="flex items-start justify-between gap-2">
+              <div className="min-w-0 space-y-2">
+                <CardTitle className="text-lg flex items-center gap-2">
+                  <span className="material-icons-outlined text-xl">terminal</span>
+                  Serial Terminal
+                </CardTitle>
+                <CardDescription className="hidden sm:block">Send raw commands to the table controller</CardDescription>
+                {/* Warning about pattern interference */}
+                <Alert className="flex items-center border-amber-500/50 py-2">
+                  <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
+                  <AlertDescription className="text-xs text-amber-600 dark:text-amber-400">
+                    Do not use while a pattern is running. This will interfere with the main connection.
+                  </AlertDescription>
+                </Alert>
+              </div>
+              {/* Clear button - only show on desktop in header */}
+              <div className="hidden sm:flex items-center gap-1">
+                {serialHistory.length > 0 && (
+                  <Button
+                    variant="ghost"
+                    size="icon"
+                    onClick={() => setSerialHistory([])}
+                    title="Clear history"
+                  >
+                    <span className="material-icons-outlined">delete_sweep</span>
+                  </Button>
+                )}
+              </div>
+            </div>
+            {/* Controls row - stacks better on mobile */}
+            <div className="flex flex-wrap items-center gap-2">
+              {/* Port selector - auto-refreshes on open */}
+              <Select
+                value={selectedSerialPort}
+                onValueChange={setSelectedSerialPort}
+                onOpenChange={(open) => open && fetchSerialPorts()}
+                disabled={serialConnected || serialLoading}
+              >
+                <SelectTrigger className="h-9 flex-1 min-w-[180px] max-w-[280px]">
+                  <SelectValue placeholder="Select port..." />
+                </SelectTrigger>
+                <SelectContent>
+                  {serialPorts.map((port) => (
+                    <SelectItem key={port} value={port}>{port}</SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+              {!serialConnected ? (
+                <Button
+                  size="sm"
+                  onClick={() => handleSerialConnect()}
+                  disabled={!selectedSerialPort || serialLoading}
+                  title="Connect"
+                >
+                  {serialLoading ? (
+                    <span className="material-icons-outlined animate-spin sm:mr-1">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined sm:mr-1">power</span>
+                  )}
+                  <span className="hidden sm:inline">Connect</span>
+                </Button>
+              ) : (
+                <>
+                  <Button
+                    size="sm"
+                    variant="destructive"
+                    onClick={handleSerialDisconnect}
+                    disabled={serialLoading}
+                    title="Disconnect"
+                  >
+                    <span className="material-icons-outlined sm:mr-1">power_off</span>
+                    <span className="hidden sm:inline">Disconnect</span>
+                  </Button>
+                  <Button
+                    size="sm"
+                    variant="secondary"
+                    onClick={handleSerialReset}
+                    disabled={serialLoading}
+                    title="Send soft reset to controller"
+                  >
+                    <span className="material-icons-outlined sm:mr-1">restart_alt</span>
+                    <span className="hidden sm:inline">Reset</span>
+                  </Button>
+                </>
+              )}
+              {/* Clear button - show on mobile in controls row */}
+              {serialHistory.length > 0 && (
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  className="sm:hidden"
+                  onClick={() => setSerialHistory([])}
+                  title="Clear history"
+                >
+                  <span className="material-icons-outlined">delete</span>
+                </Button>
+              )}
+            </div>
+          </CardHeader>
+          <CardContent>
+            {/* Output area */}
+            <div
+              ref={serialOutputRef}
+              className="bg-black/90 rounded-md p-3 h-48 overflow-y-auto font-mono text-sm mb-3"
+            >
+              {serialHistory.length > 0 ? (
+                serialHistory.map((entry, i) => (
+                  <div
+                    key={i}
+                    className={`${
+                      entry.type === 'cmd'
+                        ? 'text-cyan-400'
+                        : entry.type === 'error'
+                          ? 'text-red-400'
+                          : 'text-green-400'
+                    }`}
+                  >
+                    <span className="text-gray-500 text-xs mr-2">{entry.time}</span>
+                    {entry.type === 'cmd' ? '> ' : ''}
+                    {entry.text}
+                  </div>
+                ))
+              ) : (
+                <div className="text-gray-500 italic">
+                  {serialConnected
+                    ? 'Ready. Enter a command below (e.g., $, $$, ?, $H)'
+                    : 'Connect to a serial port to send commands'}
+                </div>
+              )}
+            </div>
+
+            {/* Input area */}
+            <div className="flex gap-2">
+              <Input
+                ref={serialInputRef}
+                value={serialCommand}
+                onChange={(e) => setSerialCommand(e.target.value)}
+                onKeyDown={handleSerialKeyDown}
+                disabled={!serialConnected}
+                readOnly={serialLoading}
+                placeholder={serialConnected ? 'Enter command (e.g., $, $$, ?, $H)' : 'Connect to send commands'}
+                className="font-mono text-base h-11"
+              />
+              <Button
+                onClick={handleSerialSend}
+                disabled={!serialConnected || !serialCommand.trim() || serialLoading}
+                className="h-11 px-6"
+              >
+                {serialLoading ? (
+                  <span className="material-icons-outlined animate-spin">sync</span>
+                ) : (
+                  <span className="material-icons-outlined">send</span>
+                )}
+              </Button>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    </TooltipProvider>
+  )
+}

+ 115 - 0
frontend/src/test/mocks/browser.ts

@@ -0,0 +1,115 @@
+import { vi } from 'vitest'
+
+// Mock IntersectionObserver
+export class MockIntersectionObserver implements IntersectionObserver {
+  callback: IntersectionObserverCallback
+  elements: Set<Element> = new Set()
+
+  // Required IntersectionObserver properties
+  readonly root: Element | Document | null = null
+  readonly rootMargin: string = '0px'
+  readonly thresholds: ReadonlyArray<number> = [0]
+
+  constructor(callback: IntersectionObserverCallback) {
+    this.callback = callback
+  }
+
+  observe(element: Element) {
+    this.elements.add(element)
+    // Immediately trigger as visible for testing
+    this.callback(
+      [{ target: element, isIntersecting: true, intersectionRatio: 1 } as IntersectionObserverEntry],
+      this
+    )
+  }
+
+  unobserve(element: Element) {
+    this.elements.delete(element)
+  }
+
+  disconnect() {
+    this.elements.clear()
+  }
+
+  takeRecords(): IntersectionObserverEntry[] {
+    return []
+  }
+}
+
+// Mock matchMedia
+export function createMockMatchMedia(matches: boolean = false) {
+  return vi.fn().mockImplementation((query: string) => ({
+    matches,
+    media: query,
+    onchange: null,
+    addListener: vi.fn(),
+    removeListener: vi.fn(),
+    addEventListener: vi.fn(),
+    removeEventListener: vi.fn(),
+    dispatchEvent: vi.fn(),
+  }))
+}
+
+// Mock ResizeObserver
+export class MockResizeObserver {
+  callback: ResizeObserverCallback
+
+  constructor(callback: ResizeObserverCallback) {
+    this.callback = callback
+  }
+
+  observe() {}
+  unobserve() {}
+  disconnect() {}
+}
+
+// Setup all browser mocks
+export function setupBrowserMocks() {
+  vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
+  vi.stubGlobal('ResizeObserver', MockResizeObserver)
+  vi.stubGlobal('matchMedia', createMockMatchMedia())
+
+  // Mock canvas context
+  HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({
+    fillRect: vi.fn(),
+    clearRect: vi.fn(),
+    beginPath: vi.fn(),
+    moveTo: vi.fn(),
+    lineTo: vi.fn(),
+    stroke: vi.fn(),
+    fill: vi.fn(),
+    arc: vi.fn(),
+    drawImage: vi.fn(),
+    save: vi.fn(),
+    restore: vi.fn(),
+    scale: vi.fn(),
+    translate: vi.fn(),
+    rotate: vi.fn(),
+    setTransform: vi.fn(),
+    getImageData: vi.fn().mockReturnValue({ data: new Uint8ClampedArray(4) }),
+    putImageData: vi.fn(),
+    createLinearGradient: vi.fn().mockReturnValue({ addColorStop: vi.fn() }),
+    createRadialGradient: vi.fn().mockReturnValue({ addColorStop: vi.fn() }),
+    measureText: vi.fn().mockReturnValue({ width: 0 }),
+    fillText: vi.fn(),
+    strokeText: vi.fn(),
+  })
+
+  // Mock localStorage
+  const localStorageMock = {
+    store: {} as Record<string, string>,
+    getItem: vi.fn((key: string) => localStorageMock.store[key] || null),
+    setItem: vi.fn((key: string, value: string) => { localStorageMock.store[key] = value }),
+    removeItem: vi.fn((key: string) => { delete localStorageMock.store[key] }),
+    clear: vi.fn(() => { localStorageMock.store = {} }),
+    get length() { return Object.keys(localStorageMock.store).length },
+    key: vi.fn((i: number) => Object.keys(localStorageMock.store)[i] || null),
+  }
+  vi.stubGlobal('localStorage', localStorageMock)
+
+  return { localStorage: localStorageMock }
+}
+
+export function cleanupBrowserMocks() {
+  vi.unstubAllGlobals()
+}

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

@@ -0,0 +1,359 @@
+import { http, HttpResponse } from 'msw'
+import type { PatternMetadata, PreviewData } from '@/lib/types'
+
+// ============================================
+// API Call Tracking for Integration Tests
+// ============================================
+
+// Track API calls for integration test verification
+export const apiCallLog: Array<{
+  endpoint: string
+  method: string
+  body?: unknown
+  timestamp: number
+}> = []
+
+export function resetApiCallLog() {
+  apiCallLog.length = 0
+}
+
+// Helper to log API calls
+function logApiCall(endpoint: string, method: string, body?: unknown) {
+  apiCallLog.push({
+    endpoint,
+    method,
+    body,
+    timestamp: Date.now(),
+  })
+}
+
+// ============================================
+// Mock Data Store (mutable for test scenarios)
+// ============================================
+
+export const mockData = {
+  patterns: [
+    { path: 'patterns/star.thr', name: 'star.thr', category: 'geometric', date_modified: Date.now(), coordinates_count: 150 },
+    { path: 'patterns/spiral.thr', name: 'spiral.thr', category: 'organic', date_modified: Date.now() - 86400000, coordinates_count: 200 },
+    { path: 'patterns/wave.thr', name: 'wave.thr', category: 'organic', date_modified: Date.now() - 172800000, coordinates_count: 175 },
+    { path: 'patterns/custom/my_design.thr', name: 'my_design.thr', category: 'custom', date_modified: Date.now() - 259200000, coordinates_count: 100 },
+  ] as PatternMetadata[],
+
+  playlists: {
+    default: ['patterns/star.thr', 'patterns/spiral.thr'],
+    favorites: ['patterns/star.thr'],
+    geometric: ['patterns/star.thr', 'patterns/wave.thr'],
+  } as Record<string, string[]>,
+
+  status: {
+    is_running: false,
+    is_paused: false,
+    current_file: null as string | null,
+    speed: 100,
+    progress: 0,
+    playlist_mode: false,
+    playlist_name: null as string | null,
+    queue: [] as string[],
+    connection_status: 'connected',
+    theta: 0,
+    rho: 0.5,
+  },
+}
+
+// Reset mock data between tests
+export function resetMockData() {
+  mockData.patterns = [
+    { path: 'patterns/star.thr', name: 'star.thr', category: 'geometric', date_modified: Date.now(), coordinates_count: 150 },
+    { path: 'patterns/spiral.thr', name: 'spiral.thr', category: 'organic', date_modified: Date.now() - 86400000, coordinates_count: 200 },
+    { path: 'patterns/wave.thr', name: 'wave.thr', category: 'organic', date_modified: Date.now() - 172800000, coordinates_count: 175 },
+    { path: 'patterns/custom/my_design.thr', name: 'my_design.thr', category: 'custom', date_modified: Date.now() - 259200000, coordinates_count: 100 },
+  ]
+  mockData.playlists = {
+    default: ['patterns/star.thr', 'patterns/spiral.thr'],
+    favorites: ['patterns/star.thr'],
+    geometric: ['patterns/star.thr', 'patterns/wave.thr'],
+  }
+  mockData.status = {
+    is_running: false,
+    is_paused: false,
+    current_file: null,
+    speed: 100,
+    progress: 0,
+    playlist_mode: false,
+    playlist_name: null,
+    queue: [],
+    connection_status: 'connected',
+    theta: 0,
+    rho: 0.5,
+  }
+}
+
+// ============================================
+// Handlers
+// ============================================
+
+export const handlers = [
+  // ----------------
+  // Pattern Endpoints
+  // ----------------
+  http.get('/list_theta_rho_files', () => {
+    return HttpResponse.json(mockData.patterns.map(p => ({ name: p.name, path: p.path })))
+  }),
+
+  http.get('/list_theta_rho_files_with_metadata', () => {
+    return HttpResponse.json(mockData.patterns)
+  }),
+
+  http.post('/preview_thr_batch', async ({ request }) => {
+    const body = await request.json() as { files?: string[]; file_names?: string[] }
+    const files = body.files || body.file_names || []
+    const previews: Record<string, PreviewData> = {}
+    for (const file of files) {
+      previews[file] = {
+        image_data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
+        first_coordinate: { x: 0, y: 0 },
+        last_coordinate: { x: 100, y: 100 },
+      }
+    }
+    return HttpResponse.json(previews)
+  }),
+
+  http.post('/get_theta_rho_coordinates', async () => {
+    // Return mock coordinates for pattern preview
+    return HttpResponse.json({
+      coordinates: Array.from({ length: 50 }, (_, i) => ({
+        theta: i * 7.2,
+        rho: 0.5 + Math.sin(i * 0.2) * 0.3,
+      })),
+    })
+  }),
+
+  http.post('/run_theta_rho', async ({ request }) => {
+    const body = await request.json() as { file_name?: string; file?: string; pre_execution?: string }
+    const file = body.file_name || body.file
+    logApiCall('/run_theta_rho', 'POST', body)
+    mockData.status.is_running = true
+    mockData.status.current_file = file || null
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/delete_theta_rho_file', async ({ request }) => {
+    const body = await request.json() as { file_path: string }
+    mockData.patterns = mockData.patterns.filter(p => p.path !== body.file_path)
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/upload_theta_rho', async () => {
+    return HttpResponse.json({ success: true, path: 'patterns/custom/uploaded.thr' })
+  }),
+
+  http.get('/api/pattern_history_all', () => {
+    return HttpResponse.json({})
+  }),
+
+  http.get('/api/pattern_history/:path', () => {
+    return HttpResponse.json({ executions: [] })
+  }),
+
+  // ----------------
+  // Playlist Endpoints
+  // ----------------
+  http.get('/list_all_playlists', () => {
+    return HttpResponse.json(Object.keys(mockData.playlists))
+  }),
+
+  http.get('/get_playlist', ({ request }) => {
+    const url = new URL(request.url)
+    const name = url.searchParams.get('name')
+    if (name && mockData.playlists[name]) {
+      return HttpResponse.json({ name, files: mockData.playlists[name] })
+    }
+    return HttpResponse.json({ name: name || '', files: [] })
+  }),
+
+  http.post('/create_playlist', async ({ request }) => {
+    const body = await request.json() as { name: string; playlist_name?: string; files?: string[] }
+    const name = body.playlist_name || body.name
+    logApiCall('/create_playlist', 'POST', body)
+    mockData.playlists[name] = body.files || []
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/modify_playlist', async ({ request }) => {
+    const body = await request.json() as { name?: string; playlist_name?: string; files: string[] }
+    const name = body.playlist_name || body.name || ''
+    logApiCall('/modify_playlist', 'POST', body)
+    if (mockData.playlists[name]) {
+      mockData.playlists[name] = body.files
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/rename_playlist', async ({ request }) => {
+    const body = await request.json() as { old_name: string; new_name: string }
+    logApiCall('/rename_playlist', 'POST', body)
+    if (mockData.playlists[body.old_name]) {
+      mockData.playlists[body.new_name] = mockData.playlists[body.old_name]
+      delete mockData.playlists[body.old_name]
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.delete('/delete_playlist', async ({ request }) => {
+    const body = await request.json() as { name?: string; playlist_name?: string }
+    const name = body.playlist_name || body.name || ''
+    logApiCall('/delete_playlist', 'DELETE', body)
+    delete mockData.playlists[name]
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/run_playlist', async ({ request }) => {
+    const body = await request.json() as { name?: string; playlist_name?: string }
+    const name = body.playlist_name || body.name || ''
+    logApiCall('/run_playlist', 'POST', body)
+    const playlist = mockData.playlists[name]
+    if (playlist && playlist.length > 0) {
+      mockData.status.is_running = true
+      mockData.status.playlist_mode = true
+      mockData.status.playlist_name = name
+      mockData.status.current_file = playlist[0]
+      mockData.status.queue = playlist.slice(1)
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/reorder_playlist', async ({ request }) => {
+    const body = await request.json() as { from_index: number; to_index: number }
+    // Reorder the current queue
+    const queue = [...mockData.status.queue]
+    const [item] = queue.splice(body.from_index, 1)
+    queue.splice(body.to_index, 0, item)
+    mockData.status.queue = queue
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/add_to_playlist', async ({ request }) => {
+    const body = await request.json() as { playlist_name: string; file_path: string }
+    if (!mockData.playlists[body.playlist_name]) {
+      mockData.playlists[body.playlist_name] = []
+    }
+    mockData.playlists[body.playlist_name].push(body.file_path)
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/add_to_queue', async ({ request }) => {
+    const body = await request.json() as { file: string; position?: 'next' | 'end' }
+    if (body.position === 'next') {
+      mockData.status.queue.unshift(body.file)
+    } else {
+      mockData.status.queue.push(body.file)
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  // ----------------
+  // Playback Control Endpoints
+  // ----------------
+  http.post('/pause_execution', () => {
+    logApiCall('/pause_execution', 'POST')
+    mockData.status.is_paused = true
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/resume_execution', () => {
+    logApiCall('/resume_execution', 'POST')
+    mockData.status.is_paused = false
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/stop_execution', () => {
+    logApiCall('/stop_execution', 'POST')
+    mockData.status.is_running = false
+    mockData.status.is_paused = false
+    mockData.status.current_file = null
+    mockData.status.playlist_mode = false
+    mockData.status.playlist_name = null
+    mockData.status.queue = []
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/force_stop', () => {
+    mockData.status.is_running = false
+    mockData.status.is_paused = false
+    mockData.status.current_file = null
+    mockData.status.playlist_mode = false
+    mockData.status.queue = []
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/skip_pattern', () => {
+    logApiCall('/skip_pattern', 'POST')
+    if (mockData.status.queue.length > 0) {
+      mockData.status.current_file = mockData.status.queue.shift() || null
+    } else {
+      mockData.status.is_running = false
+      mockData.status.current_file = null
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/set_speed', async ({ request }) => {
+    const body = await request.json() as { speed: number }
+    mockData.status.speed = body.speed
+    return HttpResponse.json({ success: true })
+  }),
+
+  // ----------------
+  // Table Control Endpoints
+  // ----------------
+  http.post('/send_home', () => {
+    mockData.status.theta = 0
+    mockData.status.rho = 0
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/soft_reset', () => {
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/move_to_center', () => {
+    mockData.status.rho = 0
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/move_to_perimeter', () => {
+    mockData.status.rho = 1
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/send_coordinate', async ({ request }) => {
+    const body = await request.json() as { theta: number; rho: number }
+    mockData.status.theta = body.theta
+    mockData.status.rho = body.rho
+    return HttpResponse.json({ success: true })
+  }),
+
+  // ----------------
+  // Status Endpoints
+  // ----------------
+  http.get('/serial_status', () => {
+    return HttpResponse.json(mockData.status)
+  }),
+
+  http.get('/list_serial_ports', () => {
+    return HttpResponse.json(['/dev/ttyUSB0', '/dev/ttyUSB1'])
+  }),
+
+  // Debug serial endpoints
+  http.post('/api/debug-serial/open', () => {
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/api/debug-serial/close', () => {
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/api/debug-serial/send', () => {
+    return HttpResponse.json({ success: true, response: 'OK' })
+  }),
+]

+ 4 - 0
frontend/src/test/mocks/server.ts

@@ -0,0 +1,4 @@
+import { setupServer } from 'msw/node'
+import { handlers } from './handlers'
+
+export const server = setupServer(...handlers)

+ 74 - 0
frontend/src/test/mocks/websocket.ts

@@ -0,0 +1,74 @@
+import { vi } from 'vitest'
+
+type MessageHandler = (event: MessageEvent) => void
+
+export class MockWebSocket {
+  static instances: MockWebSocket[] = []
+
+  url: string
+  readyState: number = WebSocket.CONNECTING
+  onopen: (() => void) | null = null
+  onclose: (() => void) | null = null
+  onmessage: MessageHandler | null = null
+  onerror: ((error: Event) => void) | null = null
+
+  constructor(url: string) {
+    this.url = url
+    MockWebSocket.instances.push(this)
+
+    // Simulate connection after microtask
+    setTimeout(() => {
+      this.readyState = WebSocket.OPEN
+      this.onopen?.()
+    }, 0)
+  }
+
+  send(_data: string) {
+    // Mock implementation - can be extended to handle specific messages
+  }
+
+  close() {
+    this.readyState = WebSocket.CLOSED
+    this.onclose?.()
+  }
+
+  // Helper to simulate receiving a message
+  simulateMessage(data: unknown) {
+    if (this.onmessage) {
+      const event = new MessageEvent('message', {
+        data: JSON.stringify(data),
+      })
+      this.onmessage(event)
+    }
+  }
+
+  // Helper to simulate connection error
+  simulateError() {
+    if (this.onerror) {
+      this.onerror(new Event('error'))
+    }
+  }
+
+  static CONNECTING = 0
+  static OPEN = 1
+  static CLOSING = 2
+  static CLOSED = 3
+}
+
+// Install mock WebSocket globally
+export function setupMockWebSocket() {
+  MockWebSocket.instances = []
+  vi.stubGlobal('WebSocket', MockWebSocket)
+}
+
+// Get the most recent WebSocket instance
+export function getLastWebSocket(): MockWebSocket | undefined {
+  return MockWebSocket.instances[MockWebSocket.instances.length - 1]
+}
+
+// Clean up mock WebSocket
+export function cleanupMockWebSocket() {
+  MockWebSocket.instances.forEach(ws => ws.close())
+  MockWebSocket.instances = []
+  vi.unstubAllGlobals()
+}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini