Explorar el Código

Add React frontend with Now Playing Bar component

- New React + TypeScript + Vite frontend in frontend/
- shadcn/ui components for consistent design
- NowPlayingBar: Persistent playback controls above bottom nav
  - Shows current pattern, progress, and quick controls
  - Expandable for full details (time, speed, playlist info)
  - Manual toggle via header button
- Real-time status updates via WebSocket
- Progress component for playback visualization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris hace 3 semanas
padre
commit
cfbc1a60bd
Se han modificado 46 ficheros con 12818 adiciones y 1 borrados
  1. 2 1
      .gitignore
  2. 24 0
      frontend/.gitignore
  3. 73 0
      frontend/README.md
  4. 19 0
      frontend/components.json
  5. 23 0
      frontend/eslint.config.js
  6. 36 0
      frontend/index.html
  7. 5522 0
      frontend/package-lock.json
  8. 60 0
      frontend/package.json
  9. 5 0
      frontend/postcss.config.js
  10. 27 0
      frontend/src/App.tsx
  11. 371 0
      frontend/src/components/NowPlayingBar.tsx
  12. 473 0
      frontend/src/components/layout/Layout.tsx
  13. 56 0
      frontend/src/components/ui/accordion.tsx
  14. 59 0
      frontend/src/components/ui/alert.tsx
  15. 36 0
      frontend/src/components/ui/badge.tsx
  16. 56 0
      frontend/src/components/ui/button.tsx
  17. 79 0
      frontend/src/components/ui/card.tsx
  18. 70 0
      frontend/src/components/ui/color-picker.tsx
  19. 120 0
      frontend/src/components/ui/dialog.tsx
  20. 22 0
      frontend/src/components/ui/input.tsx
  21. 24 0
      frontend/src/components/ui/label.tsx
  22. 29 0
      frontend/src/components/ui/popover.tsx
  23. 26 0
      frontend/src/components/ui/progress.tsx
  24. 44 0
      frontend/src/components/ui/radio-group.tsx
  25. 158 0
      frontend/src/components/ui/select.tsx
  26. 31 0
      frontend/src/components/ui/separator.tsx
  27. 26 0
      frontend/src/components/ui/slider.tsx
  28. 48 0
      frontend/src/components/ui/sonner.tsx
  29. 29 0
      frontend/src/components/ui/switch.tsx
  30. 55 0
      frontend/src/components/ui/tabs.tsx
  31. 28 0
      frontend/src/components/ui/tooltip.tsx
  32. 114 0
      frontend/src/index.css
  33. 313 0
      frontend/src/lib/previewCache.ts
  34. 33 0
      frontend/src/lib/types.ts
  35. 6 0
      frontend/src/lib/utils.ts
  36. 25 0
      frontend/src/main.tsx
  37. 1291 0
      frontend/src/pages/BrowsePage.tsx
  38. 730 0
      frontend/src/pages/LEDPage.tsx
  39. 919 0
      frontend/src/pages/PlaylistsPage.tsx
  40. 1105 0
      frontend/src/pages/SettingsPage.tsx
  41. 453 0
      frontend/src/pages/TableControlPage.tsx
  42. 56 0
      frontend/tailwind.config.js
  43. 34 0
      frontend/tsconfig.app.json
  44. 7 0
      frontend/tsconfig.json
  45. 26 0
      frontend/tsconfig.node.json
  46. 75 0
      frontend/vite.config.ts

+ 2 - 1
.gitignore

@@ -15,4 +15,5 @@ node_modules/
 *.png
 # Custom branding assets (user uploads)
 static/custom/*
-!static/custom/.gitkeep
+!static/custom/.gitkeep
+.claude/

+ 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?

+ 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"
+  }
+}

+ 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,
+    },
+  },
+])

+ 36 - 0
frontend/index.html

@@ -0,0 +1,36 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+    <!-- 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="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="/static/site.webmanifest" />
+
+    <title>Dune Weaver</title>
+
+    <!-- Check for custom favicon -->
+    <script>
+      fetch('/api/settings')
+        .then(r => r.json())
+        .then(settings => {
+          if (settings.app && settings.app.custom_logo) {
+            document.getElementById('favicon-ico').href = '/static/custom/favicon.ico';
+            document.getElementById('apple-touch-icon').href = '/static/custom/' + settings.app.custom_logo;
+          }
+          if (settings.app && settings.app.name) {
+            document.title = settings.app.name;
+          }
+        })
+        .catch(() => {});
+    </script>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 5522 - 0
frontend/package-lock.json

@@ -0,0 +1,5522 @@
+{
+  "name": "frontend",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "frontend",
+      "version": "0.0.0",
+      "dependencies": {
+        "@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",
+        "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-router-dom": "^7.12.0",
+        "sonner": "^2.0.7",
+        "zustand": "^5.0.9"
+      },
+      "devDependencies": {
+        "@eslint/js": "^9.39.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",
+        "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",
+        "lucide-react": "^0.562.0",
+        "postcss": "^8.5.6",
+        "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"
+      }
+    },
+    "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/@babel/code-frame": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+      "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.27.1",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
+      "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
+      "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.5",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
+      "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.5",
+        "@babel/types": "^7.28.5",
+        "@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-compilation-targets": {
+      "version": "7.27.2",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
+      "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.27.2",
+        "@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-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-module-imports": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
+      "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.27.1",
+        "@babel/types": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.28.3",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
+      "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.27.1",
+        "@babel/traverse": "^7.28.3"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
+      "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
+      "dev": true,
+      "license": "MIT",
+      "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/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.5",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
+      "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.5"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.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/template": {
+      "version": "7.27.2",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
+      "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.27.1",
+        "@babel/parser": "^7.27.2",
+        "@babel/types": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
+      "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.27.1",
+        "@babel/generator": "^7.28.5",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/parser": "^7.28.5",
+        "@babel/template": "^7.27.2",
+        "@babel/types": "^7.28.5",
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
+      "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
+      "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/@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/@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/@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/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/@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/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/@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/@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/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/@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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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-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/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/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/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-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/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/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/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/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/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/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-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/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/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/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/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/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/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/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-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/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/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/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/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-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-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/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/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/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.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/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/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/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/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/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/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/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/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-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/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/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/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/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/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/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/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-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/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/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/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/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-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/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/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/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/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/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/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-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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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-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
+        }
+      }
+    }
+  }
+}

+ 60 - 0
frontend/package.json

@@ -0,0 +1,60 @@
+{
+  "name": "frontend",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc -b && vite build",
+    "lint": "eslint .",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@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",
+    "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-router-dom": "^7.12.0",
+    "sonner": "^2.0.7",
+    "zustand": "^5.0.9"
+  },
+  "devDependencies": {
+    "@eslint/js": "^9.39.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",
+    "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",
+    "lucide-react": "^0.562.0",
+    "postcss": "^8.5.6",
+    "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"
+  }
+}

+ 5 - 0
frontend/postcss.config.js

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

+ 27 - 0
frontend/src/App.tsx

@@ -0,0 +1,27 @@
+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'
+
+function App() {
+  return (
+    <>
+      <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 />
+    </>
+  )
+}
+
+export default App

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

@@ -0,0 +1,371 @@
+import { useState, useEffect, useRef } from 'react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Progress } from '@/components/ui/progress'
+
+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
+    elapsed_time: number
+    percentage: number
+  } | null
+  playlist: {
+    current_index: number
+    total_files: number
+    mode: string
+    next_file: 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
+}
+
+interface NowPlayingBarProps {
+  isLogsOpen?: boolean
+  isVisible: boolean
+  onClose: () => void
+}
+
+export function NowPlayingBar({ isLogsOpen = false, isVisible, onClose }: NowPlayingBarProps) {
+  const [status, setStatus] = useState<PlaybackStatus | null>(null)
+  const [isExpanded, setIsExpanded] = useState(false)
+  const [previewUrl, setPreviewUrl] = useState<string | null>(null)
+  const wsRef = useRef<WebSocket | null>(null)
+
+  // Connect to status WebSocket
+  useEffect(() => {
+    const connectWebSocket = () => {
+      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+      const ws = new WebSocket(`${protocol}//${window.location.host}/ws/status`)
+
+      ws.onmessage = (event) => {
+        try {
+          const message = JSON.parse(event.data)
+          if (message.type === 'status_update' && message.data) {
+            setStatus(message.data)
+          }
+        } catch {
+          // Ignore parse errors
+        }
+      }
+
+      ws.onclose = () => {
+        setTimeout(connectWebSocket, 3000)
+      }
+
+      wsRef.current = ws
+    }
+
+    connectWebSocket()
+
+    return () => {
+      if (wsRef.current) {
+        wsRef.current.close()
+      }
+    }
+  }, [])
+
+  // Fetch preview image when current file changes
+  useEffect(() => {
+    if (status?.current_file) {
+      fetch('/preview_thr_batch', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ file_names: [status.current_file] }),
+      })
+        .then((r) => r.json())
+        .then((data) => {
+          if (data[status.current_file]?.image_data) {
+            setPreviewUrl(data[status.current_file].image_data)
+          }
+        })
+        .catch(() => {})
+    } else {
+      setPreviewUrl(null)
+    }
+  }, [status?.current_file])
+
+  const handlePause = async () => {
+    try {
+      const endpoint = status?.is_paused ? '/resume_execution' : '/pause_execution'
+      const response = await fetch(endpoint, { method: 'POST' })
+      if (!response.ok) throw new Error()
+      toast.success(status?.is_paused ? 'Resumed' : 'Paused')
+    } catch {
+      toast.error('Failed to toggle pause')
+    }
+  }
+
+  const handleStop = async () => {
+    try {
+      const response = await fetch('/stop_execution', { method: 'POST' })
+      if (!response.ok) throw new Error()
+      toast.success('Stopped')
+    } catch {
+      toast.error('Failed to stop')
+    }
+  }
+
+  const handleSkip = async () => {
+    try {
+      const response = await fetch('/skip', { method: 'POST' })
+      if (!response.ok) throw new Error()
+      toast.success('Skipping to next pattern')
+    } catch {
+      toast.error('Failed to skip')
+    }
+  }
+
+  // Don't render if not visible
+  if (!isVisible) {
+    return null
+  }
+
+  const isPlaying = status?.is_running || status?.is_paused
+  const patternName = formatPatternName(status?.current_file ?? null)
+  const progressPercent = status?.progress?.percentage || 0
+  const remainingTime = status?.progress?.remaining_time || 0
+  const elapsedTime = status?.progress?.elapsed_time || 0
+
+  return (
+    <>
+      {/* Backdrop when expanded */}
+      {isExpanded && (
+        <div
+          className="fixed inset-0 bg-black/30 z-30"
+          onClick={() => setIsExpanded(false)}
+        />
+      )}
+
+      {/* Now Playing Bar */}
+      <div
+        className={`fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300 ${
+          isExpanded ? 'rounded-t-xl' : ''
+        } ${isLogsOpen ? 'bottom-80' : 'bottom-16'}`}
+      >
+        {/* Mini Bar (always visible) */}
+        <div
+          className="flex items-center gap-3 px-4 py-2 cursor-pointer"
+          onClick={() => isPlaying && setIsExpanded(!isExpanded)}
+        >
+          {/* Pattern Preview */}
+          <div className="w-10 h-10 rounded-full overflow-hidden bg-muted shrink-0 border">
+            {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-lg">
+                  {isPlaying ? 'image' : 'hourglass_empty'}
+                </span>
+              </div>
+            )}
+          </div>
+
+          {/* Pattern Info */}
+          <div className="flex-1 min-w-0">
+            {isPlaying ? (
+              <>
+                <div className="flex items-center gap-2">
+                  <p className="text-sm font-medium truncate">{patternName}</p>
+                  {status?.is_paused && (
+                    <span className="text-xs text-muted-foreground">(Paused)</span>
+                  )}
+                </div>
+                <Progress value={progressPercent} className="h-1 mt-1" />
+              </>
+            ) : (
+              <p className="text-sm text-muted-foreground">Not playing</p>
+            )}
+          </div>
+
+          {/* Quick Controls */}
+          {isPlaying && (
+            <div className="flex items-center gap-1 shrink-0">
+              <Button
+                variant="ghost"
+                size="icon"
+                className="h-8 w-8"
+                onClick={(e) => {
+                  e.stopPropagation()
+                  handlePause()
+                }}
+              >
+                <span className="material-icons text-lg">
+                  {status?.is_paused ? 'play_arrow' : 'pause'}
+                </span>
+              </Button>
+              <Button
+                variant="ghost"
+                size="icon"
+                className="h-8 w-8"
+                onClick={(e) => {
+                  e.stopPropagation()
+                  handleStop()
+                }}
+              >
+                <span className="material-icons text-lg">stop</span>
+              </Button>
+            </div>
+          )}
+
+          {/* Expand/Close Indicator */}
+          {isPlaying ? (
+            <span
+              className={`material-icons-outlined text-muted-foreground transition-transform ${
+                isExpanded ? 'rotate-180' : ''
+              }`}
+            >
+              expand_less
+            </span>
+          ) : (
+            <Button
+              variant="ghost"
+              size="icon"
+              className="h-8 w-8"
+              onClick={(e) => {
+                e.stopPropagation()
+                onClose()
+              }}
+            >
+              <span className="material-icons-outlined text-lg">close</span>
+            </Button>
+          )}
+        </div>
+
+        {/* Expanded View */}
+        {isExpanded && isPlaying && (
+          <div className="px-4 pb-4 pt-2 border-t space-y-4">
+            {/* Time Info */}
+            <div className="flex items-center justify-between text-sm">
+              <span className="text-muted-foreground">
+                {formatTime(elapsedTime)}
+              </span>
+              <span className="text-muted-foreground">
+                -{formatTime(remainingTime)}
+              </span>
+            </div>
+
+            {/* Progress Bar */}
+            <Progress value={progressPercent} className="h-2" />
+
+            {/* Playback Controls */}
+            <div className="flex items-center justify-center gap-4">
+              {status.playlist && (
+                <Button
+                  variant="outline"
+                  size="icon"
+                  className="h-10 w-10 rounded-full"
+                  onClick={handleSkip}
+                  title="Skip to next"
+                >
+                  <span className="material-icons">skip_next</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>
+              <Button
+                variant="outline"
+                size="icon"
+                className="h-10 w-10 rounded-full"
+                onClick={handleStop}
+                title="Stop"
+              >
+                <span className="material-icons">stop</span>
+              </Button>
+            </div>
+
+            {/* Details Grid */}
+            <div className="grid grid-cols-2 gap-3 text-sm">
+              <div className="bg-muted/50 rounded-lg p-3">
+                <p className="text-muted-foreground text-xs">Speed</p>
+                <p className="font-medium">{status.speed} mm/s</p>
+              </div>
+              <div className="bg-muted/50 rounded-lg p-3">
+                <p className="text-muted-foreground text-xs">Progress</p>
+                <p className="font-medium">{progressPercent.toFixed(0)}%</p>
+              </div>
+              {status.playlist && (
+                <>
+                  <div className="bg-muted/50 rounded-lg p-3">
+                    <p className="text-muted-foreground text-xs">Playlist</p>
+                    <p className="font-medium">
+                      {status.playlist.current_index + 1} of {status.playlist.total_files}
+                    </p>
+                  </div>
+                  <div className="bg-muted/50 rounded-lg p-3">
+                    <p className="text-muted-foreground text-xs">Next</p>
+                    <p className="font-medium truncate">
+                      {status.playlist.next_file
+                        ? formatPatternName(status.playlist.next_file)
+                        : 'None'}
+                    </p>
+                  </div>
+                </>
+              )}
+            </div>
+
+            {/* Pause Time Remaining (if in pause between patterns) */}
+            {status.pause_time_remaining > 0 && (
+              <div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-3 text-center">
+                <p className="text-sm text-amber-600 dark:text-amber-400">
+                  <span className="material-icons-outlined text-base align-middle mr-1">
+                    schedule
+                  </span>
+                  Next pattern in {formatTime(status.pause_time_remaining)}
+                </p>
+              </div>
+            )}
+
+            {/* Scheduled Pause Indicator */}
+            {status.scheduled_pause && (
+              <div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-center">
+                <p className="text-sm text-blue-600 dark:text-blue-400">
+                  <span className="material-icons-outlined text-base align-middle mr-1">
+                    bedtime
+                  </span>
+                  Scheduled pause active
+                </p>
+              </div>
+            )}
+          </div>
+        )}
+      </div>
+    </>
+  )
+}

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

@@ -0,0 +1,473 @@
+import { Outlet, Link, useLocation } from 'react-router-dom'
+import { useEffect, useState, useRef } from 'react'
+import { toast } from 'sonner'
+import { NowPlayingBar } from '@/components/NowPlayingBar'
+
+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()
+  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)
+
+  // Connection status
+  const [isConnected, setIsConnected] = useState(false)
+  const wsRef = useRef<WebSocket | null>(null)
+
+  // Fetch app settings
+  const fetchAppSettings = () => {
+    fetch('/api/settings')
+      .then((r) => r.json())
+      .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)
+    }
+  }, [])
+
+  // Logs drawer state
+  const [isLogsOpen, setIsLogsOpen] = useState(false)
+
+  // Now Playing bar state
+  const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
+  const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
+  const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
+  const logsWsRef = useRef<WebSocket | null>(null)
+  const logsContainerRef = useRef<HTMLDivElement>(null)
+
+  // Check device connection status via WebSocket
+  useEffect(() => {
+    const connectWebSocket = () => {
+      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+      const ws = new WebSocket(`${protocol}//${window.location.host}/ws/status`)
+
+      ws.onmessage = (event) => {
+        try {
+          const data = JSON.parse(event.data)
+          // Use device connection status from the status message
+          if (data.connected !== undefined) {
+            setIsConnected(data.connected)
+          }
+        } catch {
+          // Ignore parse errors
+        }
+      }
+
+      ws.onclose = () => {
+        // Reconnect after 3 seconds (don't change device status on WS disconnect)
+        setTimeout(connectWebSocket, 3000)
+      }
+
+      wsRef.current = ws
+    }
+
+    connectWebSocket()
+
+    return () => {
+      if (wsRef.current) {
+        wsRef.current.close()
+      }
+    }
+  }, [])
+
+  // Connect to logs WebSocket when drawer opens
+  useEffect(() => {
+    if (!isLogsOpen) {
+      // Close WebSocket when drawer closes
+      if (logsWsRef.current) {
+        logsWsRef.current.close()
+        logsWsRef.current = null
+      }
+      return
+    }
+
+    // Fetch initial logs
+    const fetchInitialLogs = async () => {
+      try {
+        const response = await fetch('/api/logs?limit=200')
+        const data = await response.json()
+        // Filter out empty/invalid log entries
+        const validLogs = (data.logs || []).filter(
+          (log: { message?: string }) => log && log.message && log.message.trim() !== ''
+        )
+        // API returns newest first, reverse to show oldest first (newest at bottom)
+        setLogs(validLogs.reverse())
+        // 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 = () => {
+      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+      const ws = new WebSocket(`${protocol}//${window.location.host}/ws/logs`)
+
+      ws.onopen = () => {
+        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
+          }
+          setLogs((prev) => {
+            const newLogs = [...prev, log]
+            // Keep only last 500 logs to prevent memory issues
+            if (newLogs.length > 500) {
+              return newLogs.slice(-500)
+            }
+            return newLogs
+          })
+          // Auto-scroll to bottom
+          setTimeout(() => {
+            if (logsContainerRef.current) {
+              logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
+            }
+          }, 10)
+        } catch {
+          // Ignore parse errors
+        }
+      }
+
+      ws.onclose = () => {
+        console.log('Logs WebSocket closed, reconnecting...')
+        // Reconnect after 3 seconds if drawer is still open
+        reconnectTimeout = setTimeout(() => {
+          if (logsWsRef.current === ws) {
+            connectLogsWebSocket()
+          }
+        }, 3000)
+      }
+
+      ws.onerror = (error) => {
+        console.error('Logs WebSocket error:', error)
+      }
+
+      logsWsRef.current = ws
+    }
+
+    connectLogsWebSocket()
+
+    return () => {
+      if (reconnectTimeout) {
+        clearTimeout(reconnectTimeout)
+      }
+      if (logsWsRef.current) {
+        logsWsRef.current.close()
+        logsWsRef.current = null
+      }
+    }
+  }, [isLogsOpen])
+
+  const handleOpenLogs = () => {
+    setIsLogsOpen(true)
+  }
+
+  // Filter logs by level
+  const filteredLogs = logLevelFilter === 'ALL'
+    ? logs
+    : logs.filter((log) => log.level === logLevelFilter)
+
+  // 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
+  const handleCopyLogs = () => {
+    const text = filteredLogs
+      .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`)
+      .join('\n')
+    navigator.clipboard.writeText(text)
+    toast.success('Logs copied to clipboard')
+  }
+
+  // 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 the system?')) return
+
+    try {
+      const response = await fetch('/restart', { method: 'POST' })
+      if (response.ok) {
+        toast.success('System is restarting...')
+      } else {
+        throw new Error('Restart failed')
+      }
+    } catch {
+      toast.error('Failed to restart system')
+    }
+  }
+
+  // Update document title based on current page
+  useEffect(() => {
+    const currentNav = navItems.find((item) => item.path === location.pathname)
+    if (currentNav) {
+      document.title = `${currentNav.title} | ${appName}`
+    } else {
+      document.title = appName
+    }
+  }, [location.pathname, appName])
+
+  useEffect(() => {
+    if (isDark) {
+      document.documentElement.classList.add('dark')
+      localStorage.setItem('theme', 'dark')
+    } else {
+      document.documentElement.classList.remove('dark')
+      localStorage.setItem('theme', 'light')
+    }
+  }, [isDark])
+
+  return (
+    <div className="min-h-screen bg-background">
+      {/* Header */}
+      <header className="sticky top-0 z-40 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
+        <div className="flex h-14 items-center justify-between px-4">
+          <Link to="/" className="flex items-center gap-2">
+            <img
+              src={customLogo ? `/static/custom/${customLogo}` : '/static/android-chrome-192x192.png'}
+              alt={appName}
+              className="w-8 h-8 rounded-full object-cover"
+            />
+            <span className="font-semibold text-lg">{appName}</span>
+            <span
+              className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}
+              title={isConnected ? 'Connected' : 'Disconnected'}
+            />
+          </Link>
+          <div className="flex items-center gap-1">
+            <button
+              onClick={() => setIsNowPlayingOpen(!isNowPlayingOpen)}
+              className={`rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent ${
+                isNowPlayingOpen ? 'text-primary' : ''
+              }`}
+              aria-label="Now playing"
+              title="Now Playing"
+            >
+              <span className="material-icons-outlined">play_circle</span>
+            </button>
+            <button
+              onClick={handleOpenLogs}
+              className="rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent"
+              aria-label="View logs"
+              title="View Application Logs"
+            >
+              <span className="material-icons-outlined">article</span>
+            </button>
+            <button
+              onClick={handleRestart}
+              className="rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent hover:text-amber-500"
+              aria-label="Restart system"
+              title="Restart System"
+            >
+              <span className="material-icons-outlined">restart_alt</span>
+            </button>
+            <button
+              onClick={() => setIsDark(!isDark)}
+              className="rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent"
+              aria-label="Toggle dark mode"
+            >
+              <span className="material-icons-outlined">
+                {isDark ? 'light_mode' : 'dark_mode'}
+              </span>
+            </button>
+          </div>
+        </div>
+      </header>
+
+      {/* Main Content */}
+      <main className={`container mx-auto px-4 transition-all duration-300 ${isLogsOpen ? 'pb-80' : 'pb-20'}`}>
+        <Outlet />
+      </main>
+
+      {/* Now Playing Bar */}
+      <NowPlayingBar
+        isLogsOpen={isLogsOpen}
+        isVisible={isNowPlayingOpen}
+        onClose={() => setIsNowPlayingOpen(false)}
+      />
+
+      {/* Logs Drawer */}
+      <div
+        className={`fixed left-0 right-0 z-30 bg-background border-t border-border transition-all duration-300 ${
+          isLogsOpen ? 'bottom-16 h-64' : 'bottom-16 h-0'
+        }`}
+      >
+        {isLogsOpen && (
+          <>
+            <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
+              <div className="flex items-center gap-3">
+                <h2 className="text-sm font-semibold">Logs</h2>
+                <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>
+                <span className="text-xs text-muted-foreground">
+                  {filteredLogs.length} entries
+                </span>
+              </div>
+              <div className="flex items-center gap-1">
+                <button
+                  onClick={handleCopyLogs}
+                  className="rounded-full w-7 h-7 flex items-center justify-center hover:bg-accent"
+                  title="Copy logs"
+                >
+                  <span className="material-icons-outlined text-base">content_copy</span>
+                </button>
+                <button
+                  onClick={handleDownloadLogs}
+                  className="rounded-full w-7 h-7 flex items-center justify-center hover:bg-accent"
+                  title="Download logs"
+                >
+                  <span className="material-icons-outlined text-base">download</span>
+                </button>
+                <button
+                  onClick={() => setIsLogsOpen(false)}
+                  className="rounded-full w-7 h-7 flex items-center justify-center hover:bg-accent"
+                  title="Close logs"
+                >
+                  <span className="material-icons-outlined text-base">close</span>
+                </button>
+              </div>
+            </div>
+            <div
+              ref={logsContainerRef}
+              className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
+            >
+              {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>
+
+      {/* Bottom Navigation */}
+      <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-background">
+        <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={`flex flex-col items-center justify-center gap-1 transition-colors ${
+                  isActive
+                    ? 'text-primary'
+                    : 'text-muted-foreground hover:text-foreground'
+                }`}
+              >
+                <span className="material-icons-outlined text-xl">
+                  {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-background 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 }

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

@@ -0,0 +1,56 @@
+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-md 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-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-secondary/80",
+        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 rounded-md px-3",
+        lg: "h-11 rounded-md px-8",
+        icon: "h-10 w-10",
+      },
+    },
+    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="outline"
+          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-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}
+  />
+))
+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-50 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-md border border-input bg-background px-3 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-50 w-72 rounded-md 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 }

+ 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-md border border-input bg-background px-3 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-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md 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",
+          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-sm 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 }

+ 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-input",
+      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-50 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 }

+ 114 - 0
frontend/src/index.css

@@ -0,0 +1,114 @@
+@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(0 0% 100%);
+  --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(210 40% 96.1%);
+  --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-border: hsl(0 0% 25%);
+  --color-input: hsl(0 0% 25%);
+  --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% 20%);
+  --color-secondary-foreground: hsl(210 40% 98%);
+
+  --color-muted: hsl(0 0% 20%);
+  --color-muted-foreground: hsl(215 20.2% 65.1%);
+
+  --color-accent: hsl(0 0% 20%);
+  --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% 15%);
+  --color-card-foreground: hsl(210 40% 98%);
+
+  --color-popover: hsl(0 0% 15%);
+  --color-popover-foreground: hsl(210 40% 98%);
+}
+
+/* Base styles */
+* {
+  border-color: var(--color-border);
+}
+
+body {
+  background-color: var(--color-background);
+  color: var(--color-foreground);
+  font-family: var(--font-family-sans);
+  margin: 0;
+  min-height: 100vh;
+}
+
+/* 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);
+}

+ 313 - 0
frontend/src/lib/previewCache.ts

@@ -0,0 +1,313 @@
+// IndexedDB cache for preview images - matches original implementation
+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
+}

+ 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' | 'category'
+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 Sideway' },
+  { value: 'none', label: 'None' },
+]

+ 6 - 0
frontend/src/lib/utils.ts

@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs))
+}

+ 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>,
+)

+ 1291 - 0
frontend/src/pages/BrowsePage.tsx

@@ -0,0 +1,1291 @@
+import { useState, useEffect, useMemo, useRef, useCallback, createContext, useContext } from 'react'
+import { toast } from 'sonner'
+import {
+  initPreviewCacheDB,
+  getPreviewsFromCache,
+  savePreviewToCache,
+} from '@/lib/previewCache'
+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 { Slider } from '@/components/ui/slider'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+
+// 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' | 'category'
+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 Sideway' },
+  { 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>('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)
+
+  // 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)
+
+  // Cache all previews state
+  const [isCaching, setIsCaching] = useState(false)
+  const [cacheProgress, setCacheProgress] = useState(0)
+  const [allCached, setAllCached] = useState(false)
+  const cacheAbortRef = useRef(false)
+
+  // Favorites state
+  const [favorites, setFavorites] = useState<Set<string>>(new Set())
+
+  // 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()
+  }, [])
+
+  // Load favorites from "Favorites" playlist
+  const loadFavorites = async () => {
+    try {
+      const response = await fetch('/get_playlist?name=Favorites')
+      if (response.ok) {
+        const playlist = await response.json()
+        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)
+        const response = await fetch('/modify_playlist', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ playlist_name: 'Favorites', files: Array.from(newFavorites) }),
+        })
+        if (response.ok) {
+          setFavorites(newFavorites)
+          toast.success('Removed from favorites')
+        }
+      } else {
+        // Add to favorites - first check if playlist exists
+        newFavorites.add(path)
+        const checkResponse = await fetch('/get_playlist?name=Favorites')
+        if (checkResponse.ok) {
+          // Playlist exists, add to it
+          await fetch('/add_to_playlist', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ playlist_name: 'Favorites', pattern: path }),
+          })
+        } else {
+          // Create playlist with this pattern
+          await fetch('/create_playlist', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ 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 {
+      const response = await fetch('/list_theta_rho_files_with_metadata')
+      const data = await response.json()
+      setPatterns(data)
+
+      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[]) => {
+    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))
+
+      // Only fetch uncached patterns from API
+      if (uncachedPaths.length > 0) {
+        const response = await fetch('/preview_thr_batch', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ file_names: uncachedPaths }),
+        })
+        const data = await response.json()
+
+        // 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 (error) {
+      console.error('Error fetching previews:', error)
+    }
+  }
+
+  const fetchCoordinates = async (filePath: string) => {
+    setIsLoadingCoordinates(true)
+    try {
+      const response = await fetch('/get_theta_rho_coordinates', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ file_name: filePath }),
+      })
+      const data = await response.json()
+      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) {
+      const query = searchQuery.toLowerCase()
+      result = result.filter(
+        (p) =>
+          p.name.toLowerCase().includes(query) ||
+          p.category.toLowerCase().includes(query)
+      )
+    }
+
+    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 'category':
+          comparison = a.category.localeCompare(b.category) || a.name.localeCompare(b.name)
+          break
+        default:
+          return 0
+      }
+      return sortAsc ? comparison : -comparison
+    })
+
+    return result
+  }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc])
+
+  // 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 = (pattern: PatternMetadata) => {
+    setSelectedPattern(pattern)
+    setIsPanelOpen(true)
+    setPreExecution('adaptive')
+  }
+
+  const handleClosePanel = () => {
+    setIsPanelOpen(false)
+  }
+
+  const handleOpenAnimatedPreview = async () => {
+    if (!selectedPattern) return
+    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 {
+      const response = await fetch('/run_theta_rho', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          file_name: selectedPattern.path,
+          pre_execution: preExecution,
+        }),
+      })
+
+      if (response.status === 409) {
+        toast.error('Another pattern is already running')
+      } else if (response.ok) {
+        toast.success(`Running ${selectedPattern.name}`)
+      } else {
+        const data = await response.json()
+        throw new Error(data.detail || 'Failed to run pattern')
+      }
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : 'Failed to run pattern')
+    } 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 {
+      const response = await fetch('/delete_theta_rho_file', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ file_name: selectedPattern.path }),
+      })
+
+      if (response.ok) {
+        toast.success(`Deleted ${selectedPattern.name}`)
+        setIsPanelOpen(false)
+        setSelectedPattern(null)
+        fetchPatterns()
+      } else {
+        throw new Error('Failed to delete pattern')
+      }
+    } catch (error) {
+      toast.error('Failed to delete pattern')
+    }
+  }
+
+  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(2)}, ${coord.y.toFixed(2)})`
+  }
+
+  const canDelete = selectedPattern?.path.startsWith('custom_patterns/')
+
+  // Cache all previews handler
+  const handleCacheAllPreviews = async () => {
+    if (isCaching) {
+      // Cancel caching
+      cacheAbortRef.current = true
+      return
+    }
+
+    const BATCH_SIZE = 10
+    const CACHE_PROGRESS_KEY = 'dune_weaver_cache_progress'
+    const CACHE_TIMESTAMP_KEY = 'dune_weaver_cache_timestamp'
+    const CACHE_PROGRESS_EXPIRY = 24 * 60 * 60 * 1000 // 24 hours
+
+    setIsCaching(true)
+    setCacheProgress(0)
+    cacheAbortRef.current = false
+
+    try {
+      // Check IndexedDB cache for all patterns to find uncached ones
+      const allPaths = patterns.map((p) => p.path)
+      const cachedPreviews = await getPreviewsFromCache(allPaths)
+      const uncachedPatterns = allPaths.filter((path) => !cachedPreviews.has(path))
+
+      if (uncachedPatterns.length === 0) {
+        toast.success('All patterns are already cached!')
+        setAllCached(true)
+        setIsCaching(false)
+        return
+      }
+
+      // Check for existing progress
+      let startIndex = 0
+      const savedProgress = localStorage.getItem(CACHE_PROGRESS_KEY)
+      const savedTimestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY)
+
+      if (savedProgress && savedTimestamp) {
+        const progressAge = Date.now() - parseInt(savedTimestamp)
+        if (progressAge < CACHE_PROGRESS_EXPIRY) {
+          const lastIndex = uncachedPatterns.findIndex((p) => p === savedProgress)
+          if (lastIndex !== -1) {
+            startIndex = lastIndex + 1
+            toast.info('Resuming from previous progress...')
+          }
+        } else {
+          localStorage.removeItem(CACHE_PROGRESS_KEY)
+          localStorage.removeItem(CACHE_TIMESTAMP_KEY)
+        }
+      }
+
+      const remainingPatterns = uncachedPatterns.slice(startIndex)
+      const totalBatches = Math.ceil(remainingPatterns.length / BATCH_SIZE)
+
+      for (let i = 0; i < totalBatches; i++) {
+        if (cacheAbortRef.current) {
+          toast.info('Caching cancelled')
+          break
+        }
+
+        const batchStart = i * BATCH_SIZE
+        const batchEnd = Math.min(batchStart + BATCH_SIZE, remainingPatterns.length)
+        const batchPatterns = remainingPatterns.slice(batchStart, batchEnd)
+
+        // Update progress
+        const overallProgress = Math.round(
+          ((startIndex + batchEnd) / uncachedPatterns.length) * 100
+        )
+        setCacheProgress(overallProgress)
+
+        try {
+          const response = await fetch('/preview_thr_batch', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ file_names: batchPatterns }),
+          })
+
+          if (response.ok) {
+            const results = await response.json()
+
+            // Cache each preview and update state
+            for (const [path, data] of Object.entries(results)) {
+              if (data && !(data as PreviewData).error) {
+                await savePreviewToCache(path, data as PreviewData)
+
+                // Save progress after each successful pattern
+                localStorage.setItem(CACHE_PROGRESS_KEY, path)
+                localStorage.setItem(CACHE_TIMESTAMP_KEY, Date.now().toString())
+              }
+            }
+
+            // Update previews state
+            setPreviews((prev) => ({ ...prev, ...results }))
+          }
+        } catch (error) {
+          console.error(`Error caching batch ${i + 1}:`, error)
+        }
+
+        // Small delay between batches
+        await new Promise((resolve) => setTimeout(resolve, 100))
+      }
+
+      if (!cacheAbortRef.current) {
+        // Clear progress after successful completion
+        localStorage.removeItem(CACHE_PROGRESS_KEY)
+        localStorage.removeItem(CACHE_TIMESTAMP_KEY)
+        setAllCached(true)
+        toast.success('All pattern previews have been cached!')
+      }
+    } catch (error) {
+      console.error('Error caching previews:', error)
+      toast.error('Failed to cache previews. Click again to resume.')
+    } finally {
+      setIsCaching(false)
+      setCacheProgress(0)
+    }
+  }
+
+  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-6 py-6 px-4 transition-all duration-300 ${isPanelOpen ? 'lg:mr-[28rem]' : ''}`}>
+      {/* Page Header */}
+      <div className="space-y-1">
+        <h1 className="text-3xl font-bold tracking-tight">Browse Patterns</h1>
+        <p className="text-muted-foreground">
+          Explore and run patterns on your sand table · {patterns.length} patterns available
+        </p>
+      </div>
+
+      <Separator />
+
+      {/* Sticky Filters */}
+      <div className="sticky top-14 z-30 py-4 -mx-4 px-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
+        <div className="flex flex-col sm:flex-row gap-3">
+          <div className="flex-1">
+            <div className="relative">
+              <span className="material-icons-outlined absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-xl">
+                search
+              </span>
+              <Input
+                value={searchQuery}
+                onChange={(e) => setSearchQuery(e.target.value)}
+                placeholder="Search patterns..."
+                className="pl-10 pr-10"
+              />
+              {searchQuery && (
+                <button
+                  onClick={() => setSearchQuery('')}
+                  className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+                >
+                  <span className="material-icons-outlined text-xl">close</span>
+                </button>
+              )}
+            </div>
+          </div>
+
+          <Select value={selectedCategory} onValueChange={setSelectedCategory}>
+            <SelectTrigger className="w-full sm:w-44">
+              <SelectValue placeholder="Category" />
+            </SelectTrigger>
+            <SelectContent>
+              {categories.map((cat) => (
+                <SelectItem key={cat} value={cat}>
+                  {cat === 'all' ? 'All Categories' : cat === 'root' ? 'Uncategorized' : cat}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+
+          <div className="flex gap-2">
+            <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
+              <SelectTrigger className="w-full sm:w-36">
+                <SelectValue placeholder="Sort by" />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="name">Name</SelectItem>
+                <SelectItem value="date">Date Modified</SelectItem>
+                <SelectItem value="category">Category</SelectItem>
+              </SelectContent>
+            </Select>
+
+            <Button
+              variant="outline"
+              size="icon"
+              onClick={() => setSortAsc(!sortAsc)}
+              className="shrink-0"
+              title={sortAsc ? 'Ascending' : 'Descending'}
+            >
+              <span className="material-icons-outlined text-lg">
+                {sortAsc ? 'arrow_upward' : 'arrow_downward'}
+              </span>
+            </Button>
+          </div>
+
+          {!allCached && (
+            <Button
+              variant="outline"
+              onClick={handleCacheAllPreviews}
+              className="gap-2 whitespace-nowrap"
+            >
+              {isCaching ? (
+                <>
+                  <span className="material-icons-outlined animate-spin text-lg">sync</span>
+                  <span>{cacheProgress}%</span>
+                </>
+              ) : (
+                <>
+                  <span className="material-icons-outlined text-lg">cached</span>
+                  <span className="hidden sm:inline">Cache All</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="outline"
+              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)}
+                onToggleFavorite={toggleFavorite}
+                onClick={() => handlePatternClick(pattern)}
+              />
+            ))}
+          </div>
+        </PreviewContext.Provider>
+      )}
+
+      <div className="h-48" />
+
+      {/* Slide-in Preview Panel */}
+      <div
+        className={`fixed top-0 bottom-0 right-0 w-full max-w-md transform transition-transform duration-300 ease-in-out z-40 ${
+          isPanelOpen ? 'translate-x-0' : 'translate-x-full'
+        }`}
+      >
+        <div className="h-full bg-background border-l shadow-xl flex flex-col">
+          <header className="flex h-14 items-center justify-between border-b px-4 shrink-0">
+            <h2 className="text-lg font-semibold truncate pr-4">
+              {selectedPattern?.name || 'Pattern Details'}
+            </h2>
+            <button
+              onClick={handleClosePanel}
+              className="rounded-full w-10 h-10 flex items-center justify-center text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
+            >
+              <span className="material-icons-outlined">close</span>
+            </button>
+          </header>
+
+          {selectedPattern && (
+            <div className="p-6 overflow-y-auto flex-1">
+              {/* Clickable Round Preview Image */}
+              <div
+                className="mb-6 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 overlay on hover */}
+                <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-black/20">
+                  <div className="bg-background rounded-full w-12 h-12 flex items-center justify-center shadow-lg">
+                    <span className="material-icons text-2xl">play_arrow</span>
+                  </div>
+                </div>
+              </div>
+
+              {/* Coordinates */}
+              <div className="mb-6 flex justify-between text-sm">
+                <div className="flex items-center gap-2">
+                  <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="text-muted-foreground">Last:</span>
+                  <span className="font-semibold">
+                    {formatCoordinate(previews[selectedPattern.path]?.last_coordinate)}
+                  </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">
+                <Button
+                  onClick={handleRunPattern}
+                  disabled={isRunning}
+                  className="w-full 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>
+
+                <Button
+                  variant="outline"
+                  onClick={handleDeletePattern}
+                  disabled={!canDelete}
+                  className={`w-full gap-2 ${
+                    canDelete
+                      ? 'border-destructive text-destructive hover:bg-destructive/10'
+                      : 'opacity-50 cursor-not-allowed'
+                  }`}
+                  size="lg"
+                >
+                  <span className="material-icons text-lg">delete</span>
+                  Delete
+                </Button>
+
+                {!canDelete && selectedPattern && (
+                  <p className="text-xs text-muted-foreground text-center">
+                    Only custom patterns can be deleted
+                  </p>
+                )}
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Backdrop for mobile panel */}
+      {isPanelOpen && (
+        <div
+          className="fixed inset-0 bg-black/50 z-30 lg:hidden"
+          onClick={handleClosePanel}
+        />
+      )}
+
+      {/* Animated Preview Modal */}
+      {isAnimatedPreviewOpen && (
+        <div
+          className="fixed inset-0 bg-black/50 z-50 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
+                onClick={handleCloseAnimatedPreview}
+                className="text-muted-foreground hover:text-foreground transition-colors"
+              >
+                <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-[400px] h-[400px] 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">
+                  <canvas
+                    ref={canvasRef}
+                    width={400}
+                    height={400}
+                    className="rounded-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="outline" 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
+  onToggleFavorite: (path: string, e: React.MouseEvent) => void
+  onClick: () => void
+}
+
+function PatternCard({ pattern, isSelected, isFavorite, 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 rounded-lg transition-all hover:-translate-y-1 hover:shadow-lg 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 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>
+        {/* Favorite heart button */}
+        <div
+          className={`absolute -top-1 -right-1 w-6 h-6 rounded-full flex items-center justify-center shadow-sm z-10 transition-opacity duration-200 cursor-pointer bg-white/90 dark:bg-gray-800/90 ${
+            isFavorite ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
+          }`}
+          onClick={(e) => onToggleFavorite(pattern.path, e)}
+          title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
+        >
+          <span
+            className={`material-icons transition-colors ${
+              isFavorite ? 'text-red-500 hover:text-red-600' : 'text-gray-400 hover:text-red-500'
+            }`}
+            style={{ fontSize: '14px' }}
+          >
+            {isFavorite ? 'favorite' : 'favorite_border'}
+          </span>
+        </div>
+      </div>
+
+      <span className="text-xs font-medium text-center truncate w-full px-1" title={pattern.name}>
+        {pattern.name}
+      </span>
+    </button>
+  )
+}

+ 730 - 0
frontend/src/pages/LEDPage.tsx

@@ -0,0 +1,730 @@
+import { useState, useEffect, useCallback } from 'react'
+import { Link } from 'react-router-dom'
+import { toast } from 'sonner'
+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 [intensity, setIntensity] = 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')
+
+  // 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)
+
+  // Fetch LED configuration
+  useEffect(() => {
+    const fetchConfig = async () => {
+      try {
+        const response = await fetch('/get_led_config')
+        const data = await response.json()
+        // Map backend response fields to our interface
+        setLedConfig({
+          provider: data.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 response = await fetch('/api/dw_leds/status')
+      const data = await response.json()
+      setDwStatus(data)
+      if (data.connected) {
+        setBrightness(data.brightness || 35)
+        setSpeed(data.speed || 128)
+        setIntensity(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 [effectsRes, palettesRes] = await Promise.all([
+        fetch('/api/dw_leds/effects'),
+        fetch('/api/dw_leds/palettes'),
+      ])
+      const effectsData = await effectsRes.json()
+      const palettesData = await palettesRes.json()
+
+      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 response = await fetch('/api/dw_leds/get_effect_settings')
+      const data = await response.json()
+      setIdleEffect(data.idle_effect || null)
+      setPlayingEffect(data.playing_effect || null)
+    } catch (error) {
+      console.error('Error fetching effect settings:', error)
+    }
+  }
+
+  const fetchIdleTimeout = async () => {
+    try {
+      const response = await fetch('/api/dw_leds/idle_timeout')
+      const data = await response.json()
+      setIdleTimeoutEnabled(data.enabled || false)
+      setIdleTimeoutMinutes(data.minutes || 30)
+    } catch (error) {
+      console.error('Error fetching idle timeout:', error)
+    }
+  }
+
+  const handlePowerToggle = async () => {
+    try {
+      const response = await fetch('/api/dw_leds/power', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ state: 2 }), // Toggle
+      })
+      const data = await response.json()
+      if (data.connected) {
+        toast.success(`Power ${data.power_on ? 'ON' : 'OFF'}`)
+        await fetchDWLedsStatus()
+      } else {
+        toast.error(data.error || 'Failed to toggle power')
+      }
+    } catch (error) {
+      toast.error('Failed to toggle power')
+    }
+  }
+
+  const handleBrightnessChange = useCallback(async (value: number[]) => {
+    setBrightness(value[0])
+  }, [])
+
+  const handleBrightnessCommit = async (value: number[]) => {
+    try {
+      const response = await fetch('/api/dw_leds/brightness', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ value: value[0] }),
+      })
+      const data = await response.json()
+      if (data.connected) {
+        toast.success(`Brightness: ${value[0]}%`)
+      }
+    } catch (error) {
+      toast.error('Failed to set brightness')
+    }
+  }
+
+  const handleSpeedChange = useCallback((value: number[]) => {
+    setSpeed(value[0])
+  }, [])
+
+  const handleSpeedCommit = async (value: number[]) => {
+    try {
+      await fetch('/api/dw_leds/speed', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ speed: value[0] }),
+      })
+      toast.success(`Speed: ${value[0]}`)
+    } catch (error) {
+      toast.error('Failed to set speed')
+    }
+  }
+
+  const handleIntensityChange = useCallback((value: number[]) => {
+    setIntensity(value[0])
+  }, [])
+
+  const handleIntensityCommit = async (value: number[]) => {
+    try {
+      await fetch('/api/dw_leds/intensity', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ intensity: value[0] }),
+      })
+      toast.success(`Intensity: ${value[0]}`)
+    } catch (error) {
+      toast.error('Failed to set intensity')
+    }
+  }
+
+  const handleEffectChange = async (value: string) => {
+    setSelectedEffect(value)
+    try {
+      const response = await fetch('/api/dw_leds/effect', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ effect_id: parseInt(value) }),
+      })
+      const data = await response.json()
+      if (data.connected) {
+        toast.success('Effect changed')
+        if (data.power_on !== undefined) {
+          setDwStatus((prev) => prev ? { ...prev, power_on: data.power_on } : null)
+        }
+      }
+    } catch (error) {
+      toast.error('Failed to set effect')
+    }
+  }
+
+  const handlePaletteChange = async (value: string) => {
+    setSelectedPalette(value)
+    try {
+      const response = await fetch('/api/dw_leds/palette', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ palette_id: parseInt(value) }),
+      })
+      const data = await response.json()
+      if (data.connected) {
+        toast.success('Palette changed')
+      }
+    } catch (error) {
+      toast.error('Failed to set palette')
+    }
+  }
+
+  const handleColorChange = async (slot: 1 | 2 | 3, value: string) => {
+    if (slot === 1) setColor1(value)
+    else if (slot === 2) setColor2(value)
+    else setColor3(value)
+
+    // Debounce color changes
+    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 fetch('/api/dw_leds/colors', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(payload),
+      })
+    } catch (error) {
+      console.error('Failed to set color:', error)
+    }
+  }
+
+  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 fetch('/api/dw_leds/save_effect_settings', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(settings),
+      })
+
+      toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} effect saved`)
+      await fetchEffectSettings()
+    } catch (error) {
+      toast.error(`Failed to save ${type} effect`)
+    }
+  }
+
+  const clearEffectSettings = async (type: 'idle' | 'playing') => {
+    try {
+      await fetch('/api/dw_leds/clear_effect_settings', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ type }),
+      })
+      toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} effect cleared`)
+      await fetchEffectSettings()
+    } catch (error) {
+      toast.error(`Failed to clear ${type} effect`)
+    }
+  }
+
+  const saveIdleTimeout = async () => {
+    try {
+      await fetch('/api/dw_leds/idle_timeout', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ enabled: idleTimeoutEnabled, minutes: idleTimeoutMinutes }),
+      })
+      toast.success(`Idle timeout ${idleTimeoutEnabled ? 'enabled' : 'disabled'}`)
+    } catch (error) {
+      toast.error('Failed to save idle timeout')
+    }
+  }
+
+  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-2xl font-bold">LED Controller Not Configured</h1>
+          <p className="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 h-[calc(100vh-180px)] py-4">
+        <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-6 px-4">
+      {/* Page Header */}
+      <div className="space-y-1">
+        <h1 className="text-3xl font-bold tracking-tight">LED Control</h1>
+        <p className="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">
+                      <Label className="flex items-center gap-2">
+                        <span className="material-icons-outlined text-base 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-2 gap-4">
+                <div className="p-4 bg-muted/50 rounded-lg space-y-3">
+                  <div className="flex justify-between items-center">
+                    <Label className="flex items-center gap-2">
+                      <span className="material-icons-outlined text-base text-muted-foreground">speed</span>
+                      Speed
+                    </Label>
+                    <span className="text-sm font-medium">{speed}</span>
+                  </div>
+                  <Slider
+                    value={[speed]}
+                    onValueChange={handleSpeedChange}
+                    onValueCommit={handleSpeedCommit}
+                    max={255}
+                    step={1}
+                  />
+                </div>
+                <div className="p-4 bg-muted/50 rounded-lg space-y-3">
+                  <div className="flex justify-between items-center">
+                    <Label className="flex items-center gap-2">
+                      <span className="material-icons-outlined text-base text-muted-foreground">tungsten</span>
+                      Intensity
+                    </Label>
+                    <span className="text-sm font-medium">{intensity}</span>
+                  </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={(checked) => setIdleTimeoutEnabled(checked)}
+                />
+              </div>
+              {idleTimeoutEnabled && (
+                <div className="flex items-center gap-2">
+                  <Input
+                    type="number"
+                    value={idleTimeoutMinutes}
+                    onChange={(e) => setIdleTimeoutMinutes(parseInt(e.target.value) || 30)}
+                    min={1}
+                    max={1440}
+                    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 bg-muted/50 rounded-lg 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-background 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="outline"
+                  onClick={() => clearEffectSettings('playing')}
+                >
+                  Clear
+                </Button>
+              </div>
+            </div>
+
+            {/* Idle Effect */}
+            <div className="p-4 bg-muted/50 rounded-lg 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-background 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="outline"
+                  onClick={() => clearEffectSettings('idle')}
+                >
+                  Clear
+                </Button>
+              </div>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  )
+}

+ 919 - 0
frontend/src/pages/PlaylistsPage.tsx

@@ -0,0 +1,919 @@
+import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
+import { toast } from 'sonner'
+import {
+  initPreviewCacheDB,
+  getPreviewsFromCache,
+  savePreviewToCache,
+} from '@/lib/previewCache'
+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 { Switch } from '@/components/ui/switch'
+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>(null)
+  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)
+
+  // 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)
+
+  // Playback settings
+  const [runMode, setRunMode] = useState<RunMode>('single')
+  const [shuffle, setShuffle] = useState(false)
+  const [pauseTime, setPauseTime] = useState(5)
+  const [pauseUnit, setPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
+  const [clearPattern, setClearPattern] = useState<PreExecution>('adaptive')
+  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)
+
+  // Initialize and fetch data
+  useEffect(() => {
+    initPreviewCacheDB().catch(() => {})
+    fetchPlaylists()
+    fetchAllPatterns()
+  }, [])
+
+  const fetchPlaylists = async () => {
+    setIsLoadingPlaylists(true)
+    try {
+      const response = await fetch('/list_all_playlists')
+      const data = await response.json()
+      // 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 response = await fetch(`/get_playlist?name=${encodeURIComponent(name)}`)
+      if (!response.ok) throw new Error('Playlist not found')
+      const data = await response.json()
+      setPlaylistPatterns(data.files || [])
+
+      // Load previews for playlist patterns
+      if (data.files?.length > 0) {
+        loadPreviewsForPaths(data.files)
+      }
+    } catch (error) {
+      console.error('Error fetching playlist:', error)
+      toast.error('Failed to load playlist')
+      setPlaylistPatterns([])
+    }
+  }
+
+  const fetchAllPatterns = async () => {
+    try {
+      const response = await fetch('/list_theta_rho_files_with_metadata')
+      const data = await response.json()
+      setAllPatterns(data)
+    } catch (error) {
+      console.error('Error fetching patterns:', error)
+    }
+  }
+
+  // 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[]) => {
+    try {
+      const response = await fetch('/preview_thr_batch', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ file_names: paths }),
+      })
+      const data = await response.json()
+
+      const newPreviews: Record<string, PreviewData> = {}
+      for (const [path, previewData] of Object.entries(data)) {
+        newPreviews[path] = previewData as PreviewData
+        savePreviewToCache(path, previewData as PreviewData)
+      }
+      setPreviews(prev => ({ ...prev, ...newPreviews }))
+    } catch (error) {
+      console.error('Error fetching previews:', error)
+    }
+  }
+
+  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)
+  }
+
+  const handleCreatePlaylist = async () => {
+    if (!newPlaylistName.trim()) {
+      toast.error('Please enter a playlist name')
+      return
+    }
+
+    const name = newPlaylistName.trim()
+    try {
+      const response = await fetch('/create_playlist', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ playlist_name: name, files: [] }),
+      })
+      if (response.ok) {
+        toast.success('Playlist created')
+        setIsCreateModalOpen(false)
+        setNewPlaylistName('')
+        await fetchPlaylists()
+        handleSelectPlaylist(name)
+      } else {
+        const data = await response.json()
+        throw new Error(data.detail || 'Failed to create playlist')
+      }
+    } 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 {
+      const response = await fetch('/rename_playlist', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ old_name: playlistToRename, new_name: newPlaylistName.trim() }),
+      })
+      if (response.ok) {
+        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 {
+      const response = await fetch('/delete_playlist', {
+        method: 'DELETE',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ playlist_name: name }),
+      })
+      if (response.ok) {
+        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 {
+      const response = await fetch('/modify_playlist', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ playlist_name: selectedPlaylist, files: newPatterns }),
+      })
+      if (response.ok) {
+        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)
+
+    // Load previews for all patterns
+    if (allPatterns.length > 0) {
+      const paths = allPatterns.slice(0, 50).map(p => p.path)
+      loadPreviewsForPaths(paths)
+    }
+  }
+
+  const handleSavePatterns = async () => {
+    if (!selectedPlaylist) return
+
+    const newPatterns = Array.from(selectedPatternPaths)
+    try {
+      const response = await fetch('/modify_playlist', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ playlist_name: selectedPlaylist, files: newPatterns }),
+      })
+      if (response.ok) {
+        setPlaylistPatterns(newPatterns)
+        setIsPickerOpen(false)
+        toast.success('Playlist updated')
+        loadPreviewsForPaths(newPatterns)
+      }
+    } 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 {
+      const response = await fetch('/run_playlist', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          playlist_name: selectedPlaylist,
+          run_mode: runMode === 'indefinite' ? 'indefinite' : 'single',
+          pause_time: getPauseTimeInSeconds(),
+          clear_pattern: clearPattern,
+          shuffle: shuffle,
+        }),
+      })
+      if (response.ok) {
+        toast.success(`Started playlist: ${selectedPlaylist}`)
+      } else {
+        const data = await response.json()
+        throw new Error(data.detail || 'Failed to run playlist')
+      }
+    } 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) {
+      const query = searchQuery.toLowerCase()
+      filtered = filtered.filter(p => p.name.toLowerCase().includes(query))
+    }
+
+    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 'category':
+          cmp = a.category.localeCompare(b.category)
+          break
+      }
+      return sortAsc ? cmp : -cmp
+    })
+
+    return filtered
+  }, [allPatterns, searchQuery, selectedCategory, sortBy, sortAsc])
+
+  // 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-6 py-6 h-[calc(100vh-10.5rem)] overflow-hidden">
+      {/* Page Header */}
+      <div className="space-y-1 shrink-0">
+        <h1 className="text-3xl font-bold tracking-tight">Playlists</h1>
+        <p className="text-muted-foreground">
+          Create and manage pattern playlists for your sand table
+        </p>
+      </div>
+
+      <Separator className="shrink-0" />
+
+      {/* Main Content Area */}
+      <div className="flex flex-col lg:flex-row gap-4 flex-1 min-h-0">
+        {/* Playlists Sidebar */}
+        <aside className="w-full lg:w-64 shrink-0 bg-card border rounded-lg flex flex-col max-h-48 lg:max-h-none">
+          <div className="flex items-center justify-between px-3 py-2.5 border-b shrink-0">
+            <h2 className="text-lg font-semibold">My Playlists</h2>
+            <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-0 group-hover:opacity-100 transition-opacity">
+                  <button
+                    className="p-1 rounded hover:bg-muted-foreground/20"
+                    onClick={(e) => {
+                      e.stopPropagation()
+                      setPlaylistToRename(name)
+                      setNewPlaylistName(name)
+                      setIsRenameModalOpen(true)
+                    }}
+                  >
+                    <span className="material-icons-outlined text-base">edit</span>
+                  </button>
+                  <button
+                    className="p-1 rounded hover:bg-destructive/20 text-destructive"
+                    onClick={(e) => {
+                      e.stopPropagation()
+                      handleDeletePlaylist(name)
+                    }}
+                  >
+                    <span className="material-icons-outlined text-base">delete</span>
+                  </button>
+                </div>
+              </div>
+            ))
+          )}
+        </nav>
+      </aside>
+
+        {/* Main Content */}
+        <main className="flex-1 bg-card border rounded-lg flex flex-col overflow-hidden min-h-0">
+          {/* Header */}
+          <header className="flex items-center justify-between px-4 py-3 border-b shrink-0">
+            <div className="flex items-center gap-3 min-w-0">
+              <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 ? (
+              <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="outline" 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-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
+                {playlistPatterns.map((path) => {
+                  const previewUrl = getPreviewUrl(path)
+                  if (!previewUrl && !previews[path]) {
+                    requestPreview(path)
+                  }
+                  return (
+                    <div
+                      key={path}
+                      className="flex flex-col items-center 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">
+                          {previewUrl ? (
+                            <img
+                              src={previewUrl}
+                              alt={getPatternName(path)}
+                              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">
+                                image
+                              </span>
+                            </div>
+                          )}
+                        </div>
+                        <button
+                          className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-0 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-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
+                    </div>
+                  )
+                })}
+              </div>
+            )}
+          </div>
+
+          {/* Playback Settings - Always visible when playlist selected */}
+          {selectedPlaylist && (
+            <div className="border-t px-4 py-3 bg-muted/30 shrink-0">
+              <div className="flex flex-wrap items-center gap-3">
+                {/* Run Mode Segmented Control */}
+                <div className="flex rounded-md border bg-muted/50 p-0.5">
+                  <button
+                    onClick={() => setRunMode('single')}
+                    className={`flex items-center gap-1 px-2.5 py-1 rounded text-sm font-medium transition-colors ${
+                      runMode === 'single'
+                        ? 'bg-background text-foreground shadow-sm'
+                        : 'text-muted-foreground hover:text-foreground'
+                    }`}
+                  >
+                    <span className="material-icons-outlined text-sm">play_circle</span>
+                    Once
+                  </button>
+                  <button
+                    onClick={() => setRunMode('indefinite')}
+                    className={`flex items-center gap-1 px-2.5 py-1 rounded text-sm font-medium transition-colors ${
+                      runMode === 'indefinite'
+                        ? 'bg-background text-foreground shadow-sm'
+                        : 'text-muted-foreground hover:text-foreground'
+                    }`}
+                  >
+                    <span className="material-icons-outlined text-sm">repeat</span>
+                    Loop
+                  </button>
+                </div>
+
+                {/* Shuffle Toggle */}
+                <div className="flex items-center gap-1.5 h-8 px-2 rounded-md border bg-muted/50">
+                  <span className="material-icons-outlined text-sm text-muted-foreground">shuffle</span>
+                  <Switch
+                    checked={shuffle}
+                    onCheckedChange={setShuffle}
+                    className="scale-90"
+                  />
+                </div>
+
+                {/* Pause Time */}
+                <div className="flex items-center gap-1.5">
+                  <Label className="text-xs text-muted-foreground">Pause:</Label>
+                  <Input
+                    type="number"
+                    value={pauseTime}
+                    onChange={(e) => setPauseTime(Number(e.target.value))}
+                    min={0}
+                    className="w-14 h-8 text-sm"
+                  />
+                  <Select value={pauseUnit} onValueChange={(v) => setPauseUnit(v as 'sec' | 'min' | 'hr')}>
+                    <SelectTrigger className="h-8 w-16 text-sm">
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="sec">sec</SelectItem>
+                      <SelectItem value="min">min</SelectItem>
+                      <SelectItem value="hr">hr</SelectItem>
+                    </SelectContent>
+                  </Select>
+                </div>
+
+                {/* Clear Pattern */}
+                <div className="flex items-center gap-1.5">
+                  <Label className="text-xs text-muted-foreground">Clear:</Label>
+                  <Select value={clearPattern} onValueChange={(v) => setClearPattern(v as PreExecution)}>
+                    <SelectTrigger className="h-8 w-28 text-sm">
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      {preExecutionOptions.map(opt => (
+                        <SelectItem key={opt.value} value={opt.value}>
+                          {opt.label}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                </div>
+
+                {/* Spacer */}
+                <div className="flex-1" />
+
+                {/* Run Button */}
+                <Button
+                  className="gap-2"
+                  onClick={handleRunPlaylist}
+                  disabled={isRunning || playlistPatterns.length === 0}
+                >
+                  {isRunning ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">play_arrow</span>
+                  )}
+                  Run Playlist
+                </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="outline" 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="outline" 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-3 items-center p-3 rounded-lg bg-muted/50">
+              <div className="flex items-center gap-2">
+                <Label className="text-xs text-muted-foreground">Sort:</Label>
+                <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
+                  <SelectTrigger className="h-8 w-28">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="name">Name</SelectItem>
+                    <SelectItem value="date">Date</SelectItem>
+                    <SelectItem value="category">Category</SelectItem>
+                  </SelectContent>
+                </Select>
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  className="h-8 w-8"
+                  onClick={() => setSortAsc(!sortAsc)}
+                >
+                  <span className="material-icons-outlined text-lg">
+                    {sortAsc ? 'arrow_upward' : 'arrow_downward'}
+                  </span>
+                </Button>
+              </div>
+
+              <Separator orientation="vertical" className="h-6" />
+
+              <div className="flex items-center gap-2">
+                <Label className="text-xs text-muted-foreground">Folder:</Label>
+                <Select value={selectedCategory} onValueChange={setSelectedCategory}>
+                  <SelectTrigger className="h-8 w-32">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {categories.map(cat => (
+                      <SelectItem key={cat} value={cat}>
+                        {cat === 'all' ? 'All Folders' : cat}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+              </div>
+
+              <div className="flex-1" />
+
+              <div className="flex items-center gap-2 text-sm">
+                <span className="material-icons-outlined text-base text-primary">check_circle</span>
+                <span className="font-medium">{selectedPatternPaths.size}</span>
+                <span className="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)
+                  const previewUrl = getPreviewUrl(pattern.path)
+                  if (!previewUrl && !previews[pattern.path]) {
+                    requestPreview(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'
+                        }`}
+                      >
+                        {previewUrl ? (
+                          <img
+                            src={previewUrl}
+                            alt={pattern.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-muted-foreground">
+                              image
+                            </span>
+                          </div>
+                        )}
+                        {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="outline" 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>
+  )
+}

+ 1105 - 0
frontend/src/pages/SettingsPage.tsx

@@ -0,0 +1,1105 @@
+import { useState, useEffect } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import { toast } from 'sonner'
+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,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
+
+// Types
+interface SerialPort {
+  port: string
+  description: string
+}
+
+interface Settings {
+  app_name?: string
+  custom_logo?: string
+  preferred_port?: string
+  table_type_override?: string
+  homing_mode?: number
+  angular_offset?: number
+  auto_home_enabled?: boolean
+  auto_home_after_patterns?: number
+  clear_pattern_speed?: number
+  custom_clear_from_in?: string
+  custom_clear_from_out?: string
+}
+
+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<SerialPort[]>([])
+  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' })
+  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 [autoPlayEnabled, setAutoPlayEnabled] = useState(false)
+  const [playlists, setPlaylists] = useState<string[]>([])
+
+  // Still Sands state
+  const [stillSandsEnabled, setStillSandsEnabled] = useState(false)
+
+  // Scroll to section and clear URL param after navigation
+  useEffect(() => {
+    if (sectionParam) {
+      // Scroll to the section after a short delay to allow render
+      setTimeout(() => {
+        const element = document.getElementById(`section-${sectionParam}`)
+        if (element) {
+          element.scrollIntoView({ behavior: 'smooth', block: 'start' })
+        }
+        // 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()
+        break
+      case 'application':
+      case 'mqtt':
+      case 'autoplay':
+      case 'stillsands':
+        // These all share settings data
+        if (!loadedSections.has('_settings')) {
+          setLoadedSections((prev) => new Set(prev).add('_settings'))
+          await fetchSettings()
+        }
+        if (section === 'autoplay' && !loadedSections.has('_playlists')) {
+          setLoadedSections((prev) => new Set(prev).add('_playlists'))
+          await fetchPlaylists()
+        }
+        break
+      case 'led':
+        await fetchLedConfig()
+        break
+    }
+  }
+
+  // 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(() => {
+        const element = document.getElementById(`section-${newlyOpened}`)
+        if (element) {
+          element.scrollIntoView({ behavior: 'smooth', block: 'start' })
+        }
+      }, 100)
+    }
+  }
+
+  // Load initial section data
+  useEffect(() => {
+    openSections.forEach((section) => {
+      loadSectionData(section)
+    })
+  }, [])
+
+  const fetchPorts = async () => {
+    try {
+      const response = await fetch('/serial_status')
+      const data = await response.json()
+      setPorts(data.available_ports || [])
+      setIsConnected(data.connected || false)
+      setConnectionStatus(data.connected ? 'Connected' : 'Disconnected')
+      if (data.current_port) {
+        setSelectedPort(data.current_port)
+      }
+    } catch (error) {
+      console.error('Error fetching ports:', error)
+    }
+  }
+
+  const fetchSettings = async () => {
+    try {
+      const response = await fetch('/api/settings')
+      const data = await response.json()
+      // 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,
+        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,
+      })
+      // Also set auto-play and still sands from the same response
+      if (data.auto_play) {
+        setAutoPlayEnabled(data.auto_play.enabled || false)
+      }
+      if (data.scheduled_pause) {
+        setStillSandsEnabled(data.scheduled_pause.enabled || false)
+      }
+      // 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 {
+      const response = await fetch('/get_led_config')
+      const data = await response.json()
+      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,
+      })
+    } catch (error) {
+      console.error('Error fetching LED config:', error)
+    }
+  }
+
+  const fetchPlaylists = async () => {
+    try {
+      const response = await fetch('/list_all_playlists')
+      const data = await response.json()
+      setPlaylists(data.playlists || [])
+    } catch (error) {
+      console.error('Error fetching playlists:', error)
+    }
+  }
+
+  const handleConnect = async () => {
+    if (!selectedPort) {
+      toast.error('Please select a port')
+      return
+    }
+    setIsLoading('connect')
+    try {
+      const response = await fetch('/connect', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ port: selectedPort }),
+      })
+      const data = await response.json()
+      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 response = await fetch('/disconnect', { method: 'POST' })
+      const data = await response.json()
+      if (data.success) {
+        setIsConnected(false)
+        setConnectionStatus('Disconnected')
+        toast.success('Disconnected')
+      }
+    } catch (error) {
+      toast.error('Failed to disconnect')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveAppName = async () => {
+    setIsLoading('appName')
+    try {
+      const response = await fetch('/api/settings', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ app: { name: settings.app_name } }),
+      })
+      if (response.ok) {
+        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
+    const faviconIco = document.getElementById('favicon-ico') as HTMLLinkElement
+    const appleTouchIcon = document.getElementById('apple-touch-icon') as HTMLLinkElement
+
+    if (customLogo) {
+      if (faviconIco) faviconIco.href = `/static/custom/favicon.ico?v=${timestamp}`
+      if (appleTouchIcon) appleTouchIcon.href = `/static/custom/${customLogo}?v=${timestamp}`
+    } else {
+      if (faviconIco) faviconIco.href = `/static/favicon.ico?v=${timestamp}`
+      if (appleTouchIcon) appleTouchIcon.href = `/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 formData = new FormData()
+      formData.append('file', file)
+
+      const response = await fetch('/api/upload-logo', {
+        method: 'POST',
+        body: formData,
+      })
+
+      if (response.ok) {
+        const data = await response.json()
+        setSettings({ ...settings, custom_logo: data.filename })
+        updateBranding(data.filename)
+        toast.success('Logo uploaded!')
+      } else {
+        const data = await response.json()
+        throw new Error(data.detail || 'Upload failed')
+      }
+    } 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 {
+      const response = await fetch('/api/custom-logo', { method: 'DELETE' })
+      if (response.ok) {
+        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)
+      const response = await fetch('/set_led_config', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          provider: ledConfig.provider,
+          ip_address: ledConfig.wled_ip,
+          num_leds: ledConfig.num_leds,
+          gpio_pin: ledConfig.gpio_pin,
+          pixel_order: ledConfig.pixel_order,
+        }),
+      })
+      if (response.ok) {
+        toast.success('LED configuration saved')
+      } else {
+        const data = await response.json()
+        throw new Error(data.detail || 'Failed to save LED config')
+      }
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : 'Failed to save LED config')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveMqttConfig = async () => {
+    setIsLoading('mqtt')
+    try {
+      const response = await fetch('/api/settings', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          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,
+          },
+        }),
+      })
+      if (response.ok) {
+        toast.success('MQTT configuration saved. Restart required.')
+      }
+    } catch (error) {
+      toast.error('Failed to save MQTT config')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  return (
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-6 px-4">
+      {/* Page Header */}
+      <div className="space-y-1">
+        <h1 className="text-3xl font-bold tracking-tight">Settings</h1>
+        <p className="text-muted-foreground">
+          Configure your sand table and application preferences
+        </p>
+      </div>
+
+      <Separator />
+
+      <Accordion
+        type="multiple"
+        value={openSections}
+        onValueChange={handleAccordionChange}
+        className="space-y-4"
+      >
+        {/* Device Connection */}
+        <AccordionItem value="connection" id="section-connection" className="border rounded-lg px-4 overflow-visible">
+          <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 bg-muted/50 rounded-lg">
+              <div className="flex items-center gap-3">
+                <div className={`p-2 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.map((port) => (
+                      <SelectItem key={port.port} value={port.port}>
+                        {port.port} - {port.description}
+                      </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>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Application Settings */}
+        <AccordionItem value="application" id="section-application" className="border rounded-lg px-4 overflow-visible">
+          <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 items-center gap-4 p-4 bg-muted/50 rounded-lg">
+                <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={`/static/custom/${settings.custom_logo}`}
+                      alt="Custom Logo"
+                      className="w-full h-full object-cover"
+                    />
+                  ) : (
+                    <img
+                      src="/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 className="flex gap-2">
+                  <Button
+                    variant="outline"
+                    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="outline"
+                      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>
+
+        {/* LED Controller Configuration */}
+        <AccordionItem value="led" id="section-led" className="border rounded-lg px-4 overflow-visible">
+          <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 bg-muted/50 rounded-lg">
+                <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-4 p-4 bg-muted/50 rounded-lg">
+                <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-2">
+                    <Label htmlFor="numLeds">Number of LEDs</Label>
+                    <Input
+                      id="numLeds"
+                      type="number"
+                      value={ledConfig.num_leds || 60}
+                      onChange={(e) =>
+                        setLedConfig({ ...ledConfig, num_leds: parseInt(e.target.value) })
+                      }
+                      min={1}
+                      max={1000}
+                    />
+                  </div>
+                  <div className="space-y-2">
+                    <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-2">
+                  <Label htmlFor="pixelOrder">Pixel Color Order</Label>
+                  <Select
+                    value={ledConfig.pixel_order || 'GRB'}
+                    onValueChange={(value) =>
+                      setLedConfig({ ...ledConfig, pixel_order: value })
+                    }
+                  >
+                    <SelectTrigger>
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="GRB">GRB - WS2812/WS2812B (most common)</SelectItem>
+                      <SelectItem value="RGB">RGB - WS2815/WS2811</SelectItem>
+                      <SelectItem value="GRBW">GRBW - SK6812 RGBW</SelectItem>
+                      <SelectItem value="RGBW">RGBW - SK6812 variant</SelectItem>
+                    </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">
+          <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 bg-muted/50 rounded-lg">
+              <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-4">
+                {/* Broker Settings */}
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div className="space-y-2">
+                    <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-2">
+                    <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-2">
+                    <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-2">
+                    <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-2">
+                    <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-2">
+                    <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>
+            )}
+
+            <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>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Auto-play on Boot */}
+        <AccordionItem value="autoplay" id="section-autoplay" className="border rounded-lg px-4 overflow-visible">
+          <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 bg-muted/50 rounded-lg">
+              <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={autoPlayEnabled}
+                onCheckedChange={setAutoPlayEnabled}
+              />
+            </div>
+
+            {autoPlayEnabled && (
+              <div className="space-y-4 p-4 bg-muted/50 rounded-lg">
+                <div className="space-y-2">
+                  <Label>Startup Playlist</Label>
+                  <Select>
+                    <SelectTrigger>
+                      <SelectValue placeholder="Select a playlist..." />
+                    </SelectTrigger>
+                    <SelectContent>
+                      {playlists.map((playlist) => (
+                        <SelectItem key={playlist} value={playlist}>
+                          {playlist}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                </div>
+
+                <div className="grid grid-cols-2 gap-4">
+                  <div className="space-y-2">
+                    <Label>Run Mode</Label>
+                    <Select defaultValue="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-2">
+                    <Label>Pause Between Patterns (s)</Label>
+                    <Input type="number" defaultValue="5" min="0" step="0.5" />
+                  </div>
+                </div>
+              </div>
+            )}
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Still Sands */}
+        <AccordionItem value="stillsands" id="section-stillsands" className="border rounded-lg px-4 overflow-visible">
+          <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 bg-muted/50 rounded-lg">
+              <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={stillSandsEnabled}
+                onCheckedChange={setStillSandsEnabled}
+              />
+            </div>
+
+            {stillSandsEnabled && (
+              <Alert className="flex items-start">
+                <span className="material-icons-outlined text-base mr-2 shrink-0">schedule</span>
+                <AlertDescription>
+                  Configure time periods when the sand table should rest.
+                  Patterns will resume automatically when still periods end.
+                </AlertDescription>
+              </Alert>
+            )}
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Software Version */}
+        <AccordionItem value="version" id="section-version" className="border rounded-lg px-4 overflow-visible">
+          <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-4">
+            <div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
+              <div className="p-2 bg-muted 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">v1.0.0</p>
+              </div>
+            </div>
+
+            <div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
+              <div className="p-2 bg-muted 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 text-muted-foreground">Checking...</p>
+              </div>
+              <Button variant="secondary" size="sm" disabled>
+                <span className="material-icons-outlined text-base mr-1">download</span>
+                Update
+              </Button>
+            </div>
+          </AccordionContent>
+        </AccordionItem>
+      </Accordion>
+    </div>
+  )
+}

+ 453 - 0
frontend/src/pages/TableControlPage.tsx

@@ -0,0 +1,453 @@
+import { useState } 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'
+
+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 handleAction = async (
+    action: string,
+    endpoint: string,
+    body?: object
+  ) => {
+    setIsLoading(action)
+    try {
+      const response = await fetch(endpoint, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        ...(body && { body: JSON.stringify(body) }),
+      })
+      const data = await response.json()
+      if (data.success || response.ok) {
+        return { success: true, data }
+      }
+      throw new Error(data.detail || 'Action failed')
+    } catch (error) {
+      console.error(`Error with ${action}:`, error)
+      throw error
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  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 {
+      toast.error('Failed to stop execution')
+    }
+  }
+
+  const handleMoveToCenter = async () => {
+    try {
+      await handleAction('center', '/move_to_center')
+      toast.success('Moving to center...')
+    } catch {
+      toast.error('Failed to move to center')
+    }
+  }
+
+  const handleMoveToPerimeter = async () => {
+    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) => {
+    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')
+    }
+  }
+
+  return (
+    <TooltipProvider>
+      <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-6">
+        {/* Page Header */}
+        <div className="space-y-1">
+          <h1 className="text-3xl font-bold tracking-tight">Table Control</h1>
+          <p className="text-muted-foreground">
+            Manual controls for your sand table
+          </p>
+        </div>
+
+        <Separator />
+
+        {/* Main Controls Grid */}
+        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+          {/* Movement Controls */}
+          <Card className="lg:row-span-2">
+            <CardHeader className="pb-3">
+              <div className="flex items-center justify-between">
+                <div>
+                  <CardTitle className="text-lg">Movement</CardTitle>
+                  <CardDescription>Control ball position</CardDescription>
+                </div>
+                <Dialog>
+                  <DialogTrigger asChild>
+                    <Button variant="ghost" size="icon" className="h-8 w-8">
+                      <span className="material-icons-outlined text-lg">help_outline</span>
+                    </Button>
+                  </DialogTrigger>
+                  <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="outline"
+                              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="outline"
+                            onClick={() => handleRotate(-10)}
+                            disabled={isLoading === 'rotate'}
+                          >
+                            <span className="material-icons text-lg mr-1">rotate_left</span>
+                            CCW 10°
+                          </Button>
+                          <Button
+                            variant="outline"
+                            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>
+            </CardHeader>
+            <CardContent className="space-y-6">
+              {/* Primary Actions */}
+              <div className="space-y-3">
+                <div>
+                  <p className="text-sm font-medium">Primary Actions</p>
+                  <p className="text-xs text-muted-foreground">Calibrate or stop the table</p>
+                </div>
+                <div className="grid grid-cols-2 gap-3">
+                  <Tooltip>
+                    <TooltipTrigger asChild>
+                      <Button
+                        onClick={handleHome}
+                        disabled={isLoading === 'home'}
+                        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>Emergency stop</TooltipContent>
+                  </Tooltip>
+                </div>
+              </div>
+
+              <Separator />
+
+              {/* Position Controls */}
+              <div className="space-y-3">
+                <div>
+                  <p className="text-sm font-medium">Position</p>
+                  <p className="text-xs text-muted-foreground">Move ball to a specific location</p>
+                </div>
+                <div className="grid grid-cols-2 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">filter_center_focus</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">all_out</span>
+                        )}
+                        <span className="text-xs">Perimeter</span>
+                      </Button>
+                    </TooltipTrigger>
+                    <TooltipContent>Move ball to edge</TooltipContent>
+                  </Tooltip>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Speed Control */}
+          <Card>
+            <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>
+
+          {/* Clear Patterns */}
+          <Card>
+            <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="outline"
+                      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="outline"
+                      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="outline"
+                      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 Sideway</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Clear with side-to-side motion</TooltipContent>
+                </Tooltip>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      </div>
+    </TooltipProvider>
+  )
+}

+ 56 - 0
frontend/tailwind.config.js

@@ -0,0 +1,56 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+  darkMode: 'class',
+  content: [
+    './index.html',
+    './src/**/*.{js,ts,jsx,tsx}',
+  ],
+  theme: {
+    extend: {
+      fontFamily: {
+        sans: ['Plus Jakarta Sans', 'Noto Sans', 'sans-serif'],
+      },
+      colors: {
+        border: 'hsl(var(--border))',
+        input: 'hsl(var(--input))',
+        ring: 'hsl(var(--ring))',
+        background: 'hsl(var(--background))',
+        foreground: 'hsl(var(--foreground))',
+        primary: {
+          DEFAULT: 'hsl(var(--primary))',
+          foreground: 'hsl(var(--primary-foreground))',
+        },
+        secondary: {
+          DEFAULT: 'hsl(var(--secondary))',
+          foreground: 'hsl(var(--secondary-foreground))',
+        },
+        destructive: {
+          DEFAULT: 'hsl(var(--destructive))',
+          foreground: 'hsl(var(--destructive-foreground))',
+        },
+        muted: {
+          DEFAULT: 'hsl(var(--muted))',
+          foreground: 'hsl(var(--muted-foreground))',
+        },
+        accent: {
+          DEFAULT: 'hsl(var(--accent))',
+          foreground: 'hsl(var(--accent-foreground))',
+        },
+        popover: {
+          DEFAULT: 'hsl(var(--popover))',
+          foreground: 'hsl(var(--popover-foreground))',
+        },
+        card: {
+          DEFAULT: 'hsl(var(--card))',
+          foreground: 'hsl(var(--card-foreground))',
+        },
+      },
+      borderRadius: {
+        lg: 'var(--radius)',
+        md: 'calc(var(--radius) - 2px)',
+        sm: 'calc(var(--radius) - 4px)',
+      },
+    },
+  },
+  plugins: [require('tailwindcss-animate')],
+}

+ 34 - 0
frontend/tsconfig.app.json

@@ -0,0 +1,34 @@
+{
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+    "target": "ES2022",
+    "useDefineForClassFields": true,
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "module": "ESNext",
+    "types": ["vite/client"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "verbatimModuleSyntax": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+    "jsx": "react-jsx",
+
+    /* Path aliases */
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["./src/*"]
+    },
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true
+  },
+  "include": ["src"]
+}

+ 7 - 0
frontend/tsconfig.json

@@ -0,0 +1,7 @@
+{
+  "files": [],
+  "references": [
+    { "path": "./tsconfig.app.json" },
+    { "path": "./tsconfig.node.json" }
+  ]
+}

+ 26 - 0
frontend/tsconfig.node.json

@@ -0,0 +1,26 @@
+{
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+    "target": "ES2023",
+    "lib": ["ES2023"],
+    "module": "ESNext",
+    "types": ["node"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "verbatimModuleSyntax": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 75 - 0
frontend/vite.config.ts

@@ -0,0 +1,75 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [react()],
+  resolve: {
+    alias: {
+      '@': path.resolve(__dirname, './src'),
+    },
+  },
+  server: {
+    port: 5173,
+    proxy: {
+      // WebSocket endpoints
+      '/ws': {
+        target: 'ws://localhost:8080',
+        ws: true,
+        // Suppress connection reset errors (common during backend restarts)
+        configure: (proxy, _options) => {
+          // Handle proxy errors silently for expected connection issues
+          const handleError = (err: Error) => {
+            const msg = err.message || ''
+            if (msg.includes('ECONNRESET') || msg.includes('ECONNREFUSED') || msg.includes('EPIPE')) {
+              return // Silently ignore
+            }
+            console.error('WebSocket proxy error:', msg)
+          }
+          proxy.on('error', handleError)
+          proxy.on('proxyReqWs', (_proxyReq, _req, socket) => {
+            socket.on('error', handleError)
+          })
+        },
+      },
+      // API endpoints - proxy all backend routes
+      '/send_home': 'http://localhost:8080',
+      '/send_coordinate': 'http://localhost:8080',
+      '/stop_execution': 'http://localhost:8080',
+      '/move_to_center': 'http://localhost:8080',
+      '/move_to_perimeter': 'http://localhost:8080',
+      '/set_speed': 'http://localhost:8080',
+      '/run_theta_rho': 'http://localhost:8080',
+      '/pause_execution': 'http://localhost:8080',
+      '/resume_execution': 'http://localhost:8080',
+      '/serial_status': 'http://localhost:8080',
+      '/connect': 'http://localhost:8080',
+      '/disconnect': 'http://localhost:8080',
+      '/get_speed': 'http://localhost:8080',
+      '/list_theta_rho_files': 'http://localhost:8080',
+      '/list_theta_rho_files_with_metadata': 'http://localhost:8080',
+      '/list_all_playlists': 'http://localhost:8080',
+      '/get_playlist': 'http://localhost:8080',
+      '/create_playlist': 'http://localhost:8080',
+      '/modify_playlist': 'http://localhost:8080',
+      '/delete_playlist': 'http://localhost:8080',
+      '/rename_playlist': 'http://localhost:8080',
+      '/run_playlist': 'http://localhost:8080',
+      '/add_to_playlist': 'http://localhost:8080',
+      '/preview_thr': 'http://localhost:8080',
+      '/preview_thr_batch': 'http://localhost:8080',
+      '/preview': 'http://localhost:8080',
+      '/get_theta_rho_coordinates': 'http://localhost:8080',
+      '/get_led_config': 'http://localhost:8080',
+      '/set_led_config': 'http://localhost:8080',
+      '/api': 'http://localhost:8080',
+      '/restart': 'http://localhost:8080',
+      '/static': 'http://localhost:8080',
+    },
+  },
+  build: {
+    outDir: '../static/dist',
+    emptyOutDir: true,
+  },
+})