diff --git a/ui/package-lock.json b/ui/package-lock.json index 8deb5e3..e3233ac 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-tabs": "^1.1.14", + "@tailwindcss/vite": "^4.3.1", "react": "^19.2.6", "react-dom": "^19.2.6" }, @@ -275,7 +276,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -287,7 +287,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -298,7 +297,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -541,7 +539,6 @@ "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==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -552,7 +549,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -563,7 +559,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -573,14 +568,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "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==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -591,7 +584,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -610,7 +602,6 @@ "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -1266,7 +1257,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1283,7 +1273,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1300,7 +1289,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1317,7 +1305,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1334,7 +1321,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1351,7 +1337,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1368,7 +1353,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1385,7 +1369,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1402,7 +1385,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1419,7 +1401,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1436,7 +1417,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1453,7 +1433,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1470,7 +1449,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1489,7 +1467,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1506,7 +1483,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1520,14 +1496,269 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, "license": "MIT" }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "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.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1559,7 +1790,7 @@ "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -2055,7 +2286,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2074,6 +2304,19 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2304,7 +2547,6 @@ "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" @@ -2373,7 +2615,6 @@ "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, @@ -2429,6 +2670,12 @@ "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/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2496,6 +2743,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "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", @@ -2578,7 +2834,6 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -2611,7 +2866,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2632,7 +2886,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2653,7 +2906,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2674,7 +2926,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2695,7 +2946,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2716,7 +2966,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2737,7 +2986,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2758,7 +3006,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2779,7 +3026,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2800,7 +3046,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2821,7 +3066,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2861,6 +3105,15 @@ "yallist": "^3.0.2" } }, + "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/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2888,7 +3141,6 @@ "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, "funding": [ { "type": "github", @@ -2994,14 +3246,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3014,7 +3264,6 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3153,7 +3402,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.133.0", @@ -3226,17 +3474,34 @@ "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==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3323,7 +3588,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -3414,7 +3679,6 @@ "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", - "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", diff --git a/ui/package.json b/ui/package.json index 2987136..2ed07b5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-tabs": "^1.1.14", + "@tailwindcss/vite": "^4.3.1", "react": "^19.2.6", "react-dom": "^19.2.6" }, diff --git a/ui/src/App.css b/ui/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/ui/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a66b5ef..a994fdc 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,122 +1,706 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' +/** + * Pi Multi-FX Pedal — App Shell + * + * Main application with: + * - Status bar (mode selector, connection status, CPU, sample rate) + * - Screen area (tab content: Rig, FX Chain, Models, IRs, Presets, Settings) + * - Footswitch bar (8 footswitches with dynamic scribble strips per mode) + * - Tab bar (bottom navigation) + */ -function App() { - const [count, setCount] = useState(0) +import { useState, useEffect, useCallback } from 'react'; +import { usePedalState, fetchPresets, apiPost, apiPut, apiGet, apiUpload } from './pedal/hooks/usePedalState'; +import { FootswitchModeProvider } from './pedal/FootswitchModeContext'; +import { FootswitchBar } from './pedal/footswitch/FootswitchBar'; +import { ModeSelector } from './pedal/footswitch/ModeSelector'; +import type { PedalState, PedalBlock, BankData } from './pedal/types'; +import './pedal/pedal.css'; + +const T = { + bg: '#0A0A0C', panel: '#141418', surface: '#1C1C22', + border: '#2A2A32', amber: '#E8A030', blue: '#3A7BA8', + blueDim: '#1E4060', amberDim: '#7A5218', + green: '#3AB87A', red: '#C84040', text: '#F0EDE6', + textSec: '#8888A0', textDim: '#444458', +}; + +// ── VU Meter ───────────────────────────────────────────────── + +function VUMeter({ level = 0, height = 60 }: { level?: number; height?: number }) { + const segs = 12; + const active = Math.round((level / 100) * segs); + const segColor = (i: number) => { + if (i >= 10) return T.red; + if (i >= 8) return '#E8C030'; + return T.green; + }; + return ( +
+ {Array.from({ length: segs }).map((_, i) => ( +
+ ))} +
+ ); +} + +// ── FX Type Labels ─────────────────────────────────────────── + +const FX_TYPE_LABELS: Record = { + noise_gate: 'Noise Gate', compressor: 'Compressor', boost: 'Boost', + overdrive: 'Overdrive', distortion: 'Distortion', fuzz: 'Fuzz', + eq: 'EQ', chorus: 'Chorus', flanger: 'Flanger', phaser: 'Phaser', + tremolo: 'Tremolo', vibrato: 'Vibrato', delay: 'Delay', reverb: 'Reverb', + volume: 'Volume', nam_amp: 'NAM Amp', ir_cab: 'IR Cab', + octaver: 'Octaver', pitch_shifter: 'Pitch Shifter', harmonizer: 'Harmonizer', + whammy: 'Whammy', detune: 'Detune', ring_modulator: 'Ring Mod', + auto_wah: 'Auto Wah', envelope_filter: 'Env Filter', rotary_speaker: 'Rotary', + uni_vibe: 'Uni-Vibe', auto_pan: 'Auto Pan', stereo_widener: 'Stereo Widener', + bitcrusher: 'Bitcrusher', wavefolder: 'Wavefolder', rectifier: 'Rectifier', + expander: 'Expander', de_esser: 'De-esser', transient_shaper: 'Transient Shaper', + parametric_eq: 'Parametric EQ', high_pass_filter: 'High Pass', + low_pass_filter: 'Low Pass', band_pass_filter: 'Band Pass', + notch_filter: 'Notch Filter', formant_filter: 'Formant Filter', + ping_pong_delay: 'Ping Pong Delay', multi_tap_delay: 'Multi Tap', + reverse_delay: 'Reverse Delay', tape_echo: 'Tape Echo', + shimmer_reverb: 'Shimmer Reverb', early_reflections: 'Early Reflections', + sidechain_compressor: 'Sidechain Comp', looper: 'Looper', +}; + +// ══════════════════════════════════════════════════════════════ +// RIG SCREEN +// ══════════════════════════════════════════════════════════════ + +function RigScreen({ state, connected }: { state: PedalState | null; connected: boolean; refresh?: () => void }) { + const [volume, setVolume] = useState( + state?.master_volume != null ? Math.round(state.master_volume * 100) : 80 + ); + const [localBypass, setLocalBypass] = useState(null); + const [localTuner, setLocalTuner] = useState(null); + + const bypass = localBypass ?? state?.bypass ?? false; + const tuner = localTuner ?? state?.tuner_enabled ?? false; + + useEffect(() => { + if (state?.master_volume != null) setVolume(Math.round(state.master_volume * 100)); + }, [state?.master_volume]); + + useEffect(() => { + if (state?.bypass !== undefined) setLocalBypass(null); + if (state?.tuner_enabled !== undefined) setLocalTuner(null); + }, [state?.bypass, state?.tuner_enabled]); + + const handleVolume = async (val: number) => { + setVolume(val); + try { await apiPut('/api/volume', { volume: val / 100 }); } catch {} + }; + + const handleBypass = async () => { + const next = !bypass; + setLocalBypass(next); + try { await apiPost('/api/bypass', { bypass: next }); } catch { setLocalBypass(null); } + }; + + const handleTuner = async () => { + const next = !tuner; + setLocalTuner(next); + try { await apiPost('/api/tuner', { enabled: next }); } catch { setLocalTuner(null); } + }; + + const inputLevel = state?.input_level ?? 0; + const outputLevel = state?.output_level ?? 0; + + return ( +
+
+
+
Active Rig
+
+ {state?.current_preset ? ( + + {state.current_preset.name} + + ) : ( + No preset loaded + )} + {connected ? ( + Connected + ) : ( + Offline + )} +
+
+
+ + +
+
+
+ {/* Status row */} +
+
+
Bypass
+
+
+
+
Tuner
+
+
+
+
Routing
+ {state?.routing_mode || 'mono'} +
+
+ + {/* Tuner */} + {tuner && ( +
+
🎵
+
TUNER ACTIVE
+
—————●—————
+
Tap switch again to exit tuner mode
+
+ )} + + {/* Volume */} +
+
+
Master Volume
+ handleVolume(+e.target.value)} + style={{ width: '100%', accentColor: T.amber }} /> +
{volume}%
+
+
+ {state?.nam_loaded ? 'NAM: Active' : 'NAM: —'}
+ {state?.ir_loaded ? 'IR: Active' : 'IR: —'} +
+
+ + {/* Signal chain */} +
+
Signal Chain
+
+ {['IN', state?.bypass ? 'BYP' : (state?.nam_loaded ? 'NAM' : 'AMP'), state?.ir_loaded ? 'IR' : 'CAB', 'OUT'].map((s, i, arr) => ( +
+
{s}
+ {i < arr.length - 1 &&
} +
+ ))} +
+
+
+
+ ); +} + +// ══════════════════════════════════════════════════════════════ +// FX CHAIN SCREEN +// ══════════════════════════════════════════════════════════════ + +function FXBlock({ title, type, active = true, bypassed = false }: { + title: string; type: string; active?: boolean; bypassed?: boolean; +}) { + const [open, setOpen] = useState(true); + return ( +
+
setOpen(o => !o)}> +
+ {title} + {type} + {open ? '▾' : '▸'} +
+
+ ); +} + +function FXScreen({ state, connected }: { state: PedalState | null; connected: boolean }) { + const [fxList, setFxList] = useState<{ id: number; title: string; type: string; bypassed: boolean }[]>([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!connected || !state?.current_preset) { setFxList([]); setLoading(false); return; } + fetchPresets().then(data => { + const banks = data.banks || []; + const cp = state.current_preset; + const preset = cp ? banks[cp.bank]?.presets?.[cp.program] : null; + if (preset?.chain) { + setFxList(preset.chain.map((b, i) => ({ + id: i + 1, + title: FX_TYPE_LABELS[b.fx_type] || b.fx_type, + type: b.fx_type, + bypassed: b.bypassed ?? false, + }))); + } + setLoading(false); + }).catch(() => { setFxList([]); setLoading(false); }); + }, [connected, state]); + + if (loading) return
; + + return ( +
+
+
FX Chain {fxList.length > 0 ? `(${fxList.length})` : ''}
+
+
+ {fxList.length === 0 ? ( +
No FX blocks in the chain.
+ ) : ( + fxList.map(f => ) + )} +
+
+ ); +} + +// ══════════════════════════════════════════════════════════════ +// MODELS SCREEN +// ══════════════════════════════════════════════════════════════ + +function ModelsScreen({ connected, refresh }: { state?: PedalState | null; connected: boolean; refresh: () => void }) { + const [models, setModels] = useState<{ name: string; path?: string; architecture?: string; size_mb?: string; loaded?: boolean }[]>([]); + const [current, setCurrent] = useState(null); + const [loading, setLoading] = useState(true); + const [fileRef, setFileRef] = useState(null); + + const loadModels = useCallback(async () => { + if (!connected) { setLoading(false); return; } + try { + const data = await apiGet<{ models?: any[]; current?: string }>('/api/models'); + setModels(data.models || []); + setCurrent(data.current ?? null); + } catch {} + setLoading(false); + }, [connected]); + + useEffect(() => { loadModels(); }, [loadModels]); + + const handleLoad = async (path?: string) => { + if (!path) return; + try { await apiPost('/api/models/load', { path }); await loadModels(); refresh(); } catch { alert('Failed to load model'); } + }; + + const handleUnload = async () => { + try { await apiPost('/api/models/unload'); setCurrent(null); await loadModels(); refresh(); } catch { alert('Failed to unload'); } + }; + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { await apiUpload('/api/models/upload', file); await loadModels(); } catch { alert('Upload failed'); } + }; + + if (loading) return
; + + return ( +
+
+
NAM Models ({models.length})
+ + setFileRef(el)} type="file" accept=".nam" style={{ display: 'none' }} onChange={handleUpload} /> +
+
+ {current && ( +
+
Loaded Model
+
+ {current} + +
+
+ )} + {models.length === 0 ? ( +
No NAM models found.
+ ) : ( + models.map(m => ( +
!m.loaded && handleLoad(m.path)}> +
+
{m.name}
+
{m.architecture} · {m.size_mb} MB
+
+ {m.loaded ? Loaded : } +
+ )) + )} +
+
+ ); +} + +// ══════════════════════════════════════════════════════════════ +// IRS SCREEN +// ══════════════════════════════════════════════════════════════ + +function IRsScreen({ connected, refresh }: { state?: PedalState | null; connected: boolean; refresh: () => void }) { + const [irs, setIrs] = useState<{ name: string; path?: string; sample_rate?: number; length_ms?: number; loaded?: boolean }[]>([]); + const [current, setCurrent] = useState(null); + const [loading, setLoading] = useState(true); + const [fileRef, setFileRef] = useState(null); + + const loadIrs = useCallback(async () => { + if (!connected) { setLoading(false); return; } + try { + const data = await apiGet<{ irs?: any[]; current?: string }>('/api/irs'); + setIrs(data.irs || []); + setCurrent(data.current ?? null); + } catch {} + setLoading(false); + }, [connected]); + + useEffect(() => { loadIrs(); }, [loadIrs]); + + const handleLoad = async (path?: string) => { + if (!path) return; + try { await apiPost('/api/irs/load', { path }); await loadIrs(); refresh(); } catch { alert('Failed to load IR'); } + }; + + const handleUnload = async () => { + try { await apiPost('/api/irs/unload'); setCurrent(null); await loadIrs(); refresh(); } catch { alert('Failed to unload'); } + }; + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { await apiUpload('/api/irs/upload', file); await loadIrs(); } catch { alert('Upload failed'); } + }; + + if (loading) return
; + + return ( +
+
+
IR Files ({irs.length})
+ + setFileRef(el)} type="file" accept=".wav" style={{ display: 'none' }} onChange={handleUpload} /> +
+
+ {current && ( +
+
Loaded IR
+
+ {current} + +
+
+ )} + {irs.length === 0 ? ( +
No IR files found.
+ ) : ( + irs.map(ir => ( +
!ir.loaded && handleLoad(ir.path)}> +
+
{ir.name}
+
{ir.sample_rate} Hz · {ir.length_ms} ms
+
+ {ir.loaded ? Loaded : } +
+ )) + )} +
+
+ ); +} + +// ══════════════════════════════════════════════════════════════ +// PRESETS SCREEN +// ══════════════════════════════════════════════════════════════ + +function PresetsScreen({ state, connected, refresh }: { state: PedalState | null; connected: boolean; refresh: () => void }) { + const [banks, setBanks] = useState([]); + const [selectedBank, setSelectedBank] = useState(0); + const [loading, setLoading] = useState(true); + + const loadPresets = useCallback(async () => { + if (!connected) { + setBanks([{ name: 'Default', number: 0, presets: [{ name: 'Edge of Breakup', bank: 0, program: 0 }, { name: 'Modern High Gain', bank: 0, program: 1 }, null, null] }]); + setLoading(false); + return; + } + try { + const data = await fetchPresets(); + setBanks(data.banks || []); + } catch {} + setLoading(false); + }, [connected]); + + useEffect(() => { loadPresets(); }, [loadPresets]); + + const handleActivate = async (bank: number, program: number) => { + try { await apiPost(`/api/presets/${bank}/${program}/activate`); await loadPresets(); refresh(); } catch { alert('Failed to load preset'); } + }; + + if (loading) return
; + + const currentPreset = state?.current_preset; + const bankData = banks[selectedBank]; + const presets = bankData?.presets || []; + + return ( +
+
+
Presets
+
+
+ {banks.length > 1 && ( +
+ {banks.map((b, i) => ( + + ))} +
+ )} + {currentPreset && ( +
+
+
Current
+
+ {currentPreset.name} + Bank {currentPreset.bank} · Prog {currentPreset.program} +
+
+
+ )} + {presets.length === 0 ? ( +
No presets in this bank.
+ ) : ( + presets.map((p, i) => { + if (!p) return null; + const isActive = currentPreset?.bank === p.bank && currentPreset?.program === p.program; + return ( +
handleActivate(p.bank, p.program)}> +
{p.program + 1}
+
+
{p.name}
+
Bank {p.bank} · Program {p.program}
+
+ {isActive && } +
+ ); + }) + )} +
+
+ ); +} + +// ══════════════════════════════════════════════════════════════ +// SETTINGS SCREEN +// ══════════════════════════════════════════════════════════════ + +function SettingsScreen({ state: pedalState }: { state: PedalState | null; refresh?: () => void }) { + return ( +
+
+
Settings
+
+
+
+
System
+ {pedalState?.cpu_percent != null && ( +
+ CPU + {pedalState.cpu_percent}% +
+ )} + {pedalState?.sample_rate && ( +
+ Sample Rate + {pedalState.sample_rate / 1000}kHz +
+ )} +
+ Connection + Live +
+
+
+
+ ); +} + +// ══════════════════════════════════════════════════════════════ +// APP SHELL +// ══════════════════════════════════════════════════════════════ + +const TABS = [ + { id: 'rig', label: 'Rig', icon: '🎛' }, + { id: 'fx', label: 'FX', icon: '⛓' }, + { id: 'models', label: 'Models', icon: '📁' }, + { id: 'irs', label: 'IRs', icon: '🎚' }, + { id: 'presets', label: 'Presets', icon: '⭐' }, + { id: 'settings', label: 'Settings', icon: '⚙' }, +] as const; + +type TabId = (typeof TABS)[number]['id']; + +// ── Pedal Shell Content (inner component that has context access) ── + +function PedalContent() { + const [tab, setTab] = useState('rig'); + const { state, connected, refresh } = usePedalState(); + + // Build block list from current preset state + const [blocks, setBlocks] = useState([]); + const [bankNames, setBankNames] = useState(['Default']); + + useEffect(() => { + if (connected && state?.current_preset) { + fetchPresets().then(data => { + const names = (data.banks || []).map((b: BankData) => b.name); + if (names.length > 0) setBankNames(names); + + const cp = state.current_preset; + const bank = data.banks?.[cp.bank]; + const preset = bank?.presets?.[cp.program]; + if (preset?.chain) { + setBlocks(preset.chain.map((b, i) => ({ + id: i + 1, + fx_type: b.fx_type, + title: FX_TYPE_LABELS[b.fx_type] || b.fx_type, + bypassed: b.bypassed ?? false, + }))); + } + }).catch(() => {}); + } + }, [connected, state?.current_preset]); + + const handleToggleBlock = useCallback(async (blockId: number, newBypass: boolean) => { + setBlocks(prev => prev.map(b => b.id === blockId ? { ...b, bypassed: newBypass } : b)); + if (connected) { + try { await apiPost('/api/blocks/toggle', { block_id: blockId, bypass: newBypass }); } catch {} + } + }, [connected]); + + // Snapshots (placeholder — will be wired to real API) + const [snapshots] = useState<{ name: string; active: boolean }[]>( + Array.from({ length: 8 }, (_, i) => ({ name: `Snap ${i + 1}`, active: false })) + ); + + return ( + +
+ {/* ── Status Bar ── */} +
+
+
+ PI MULTI-FX + {state?.tuner_enabled && TUNER} +
+
+ +
+
+ {state?.cpu_percent != null && ( + CPU {state.cpu_percent}% + )} + {state?.sample_rate && ( + {state.sample_rate / 1000}kHz + )} + {state?.bypass && BYP} + {state?.nam_loaded && NAM} + {state?.ir_loaded && IR} +
+
+ + {/* ── Screen Area ── */} +
+ {tab === 'rig' && } + {tab === 'fx' && } + {tab === 'models' && } + {tab === 'irs' && } + {tab === 'presets' && } + {tab === 'settings' && } +
+ + {/* ── Footswitch Bar ── */} + + + {/* ── Tab Bar ── */} +
+ {TABS.map(t => ( +
setTab(t.id)}> + {t.icon} + {t.label} +
+ ))} +
+
+ + ); +} + +// ── Root App ───────────────────────────────────────────────── + +export default function App() { + useEffect(() => { document.title = 'Pi Multi-FX Pedal'; }, []); return ( <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
+ {/* Global styles for inline-styled components */} + + - ) + ); } - -export default App diff --git a/ui/src/index.css b/ui/src/index.css index f4c5103..c0ec2a4 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1,84 +1,31 @@ +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600&display=swap'); @import "tailwindcss"; @custom-variant dark (&:is(.dark *)); :root { - --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); - --card: oklch(1 0 0); - --card-foreground: oklch(0.141 0.005 285.823); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.21 0.006 285.885); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.967 0.001 286.375); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.967 0.001 286.375); - --accent-foreground: oklch(0.21 0.006 285.885); - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.577 0.245 27.325); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.705 0.015 286.067); - --radius: 0.625rem; -} - -.dark { - --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.985 0 0); - --card: oklch(0.141 0.005 285.823); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.141 0.005 285.823); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.985 0 0); - --primary-foreground: oklch(0.21 0.006 285.885); - --secondary: oklch(0.274 0.006 286.033); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.396 0.141 25.723); - --destructive-foreground: oklch(0.637 0.237 25.331); - --border: oklch(0.274 0.006 286.033); - --input: oklch(0.274 0.006 286.033); - --ring: oklch(0.442 0.017 285.786); + --background: #0A0A0C; + --foreground: #F0EDE6; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + --radius-sm: calc(0.625rem - 4px); + --radius-md: calc(0.625rem - 2px); + --radius-lg: 0.625rem; + --radius-xl: calc(0.625rem + 4px); } @layer base { - * { - @apply border-border; - } + * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } body { - @apply bg-background text-foreground; - font-feature-settings: "rlig" 1, "calt" 1; + background: var(--background); + color: var(--foreground); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + user-select: none; + -webkit-user-select: none; + overflow: hidden; } + input, textarea, select { font-family: inherit; } } diff --git a/ui/src/pedal/FootswitchModeContext.tsx b/ui/src/pedal/FootswitchModeContext.tsx new file mode 100644 index 0000000..35c8e32 --- /dev/null +++ b/ui/src/pedal/FootswitchModeContext.tsx @@ -0,0 +1,355 @@ +/** + * FootswitchMode Context — provides the current mode and mode switching + * to all footswitch components. + */ + +import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; +import type { FootswitchMode, ScribbleStripData, PedalState, PedalBlock } from '../types'; +import { apiPost, activatePreset } from "./hooks/usePedalState"; + +// ── Context Shape ───────────────────────────────────────────── + +interface FootswitchModeContextValue { + mode: FootswitchMode; + setMode: (mode: FootswitchMode) => void; + /** Scribble strip data for each footswitch position (0–7) */ + scribbleData: ScribbleStripData[]; + /** Handle a footswitch press at a given position */ + handleFootswitch: (index: number) => void; + /** Handle secondary action (double-tap / long-press) at a given position */ + handleSecondary: (index: number) => void; + /** Current banks/presets for preset mode */ + currentBankIndex: number; + currentPresetIndex: number; + setBank: (bank: number) => void; + setPreset: (preset: number) => void; + /** Whether the preset has been modified */ + dirty: boolean; +} + +const FootswitchModeContext = createContext(null); + +// ── Mode-specific scribble strip generators ─────────────────── + +function stompScribbles( + blocks: PedalBlock[], + _state: PedalState | null, +): ScribbleStripData[] { + const items: ScribbleStripData[] = []; + for (let i = 0; i < 8; i++) { + const block = blocks[i]; + if (block) { + items.push({ + key: `stomp-${block.id}`, + label: block.title.length > 6 ? block.title.slice(0, 6) : block.title, + sublabel: block.bypassed ? 'BYP' : 'ON', + color: block.bypassed ? 'off' : 'green', + active: !block.bypassed, + bypassed: block.bypassed, + }); + } else { + items.push({ + key: `stomp-empty-${i}`, + label: '—', + sublabel: '', + color: 'off', + active: false, + bypassed: false, + }); + } + } + return items; +} + +function presetScribbles( + state: PedalState | null, + bankIndex: number, + bankNames: string[], +): ScribbleStripData[] { + const items: ScribbleStripData[] = []; + + // FS 0-1: Bank navigation + items.push({ + key: 'bank-down', + label: 'BANK', + sublabel: bankIndex > 0 ? `${bankNames[bankIndex - 1] || '−'}` : '—', + color: 'blue', + active: bankIndex > 0, + }); + items.push({ + key: 'bank-up', + label: 'BANK', + sublabel: bankIndex < bankNames.length - 1 ? `${bankNames[bankIndex + 1] || '+'}` : '—', + color: 'blue', + active: bankIndex < bankNames.length - 1, + }); + + // FS 2-7: Preset slots + for (let i = 0; i < 6; i++) { + const presetIdx = i; + const isActive = state?.current_preset?.program === presetIdx && + state?.current_preset?.bank === bankIndex; + items.push({ + key: `preset-${presetIdx}`, + label: String(presetIdx + 1), + sublabel: state?.current_preset && state.current_preset.program === presetIdx + ? state.current_preset.name : '', + color: isActive ? 'green' : 'off', + active: isActive, + }); + } + + return items; +} + +function snapshotScribbles( + snapshots: { name: string; active: boolean }[], +): ScribbleStripData[] { + const items: ScribbleStripData[] = []; + for (let i = 0; i < 8; i++) { + const snap = snapshots[i]; + if (snap) { + items.push({ + key: `snapshot-${i}`, + label: snap.name.length > 6 ? snap.name.slice(0, 6) : (snap.name || `Snap ${i + 1}`), + sublabel: snap.active ? 'ACTIVE' : '', + color: snap.active ? 'amber' : 'off', + active: snap.active, + }); + } else { + items.push({ + key: `snapshot-empty-${i}`, + label: `Snap ${i + 1}`, + sublabel: '', + color: 'off', + active: false, + }); + } + } + return items; +} + +function comboScribbles( + blocks: PedalBlock[], + state: PedalState | null, + bankNames: string[], + bankIndex: number, +): ScribbleStripData[] { + // Combo = first 4 stomp + bank/preset info on last 4 + const stomp = stompScribbles(blocks.slice(0, 4), state); + + // FS 4: bank down, FS 5: bank up, FS 6-7: preset select + return [ + ...stomp.slice(0, 4), + { + key: 'bank-down', + label: 'BANK', + sublabel: bankIndex > 0 ? '▼' : '—', + color: 'blue', + active: bankIndex > 0, + }, + { + key: 'bank-up', + label: 'BANK', + sublabel: bankIndex < bankNames.length - 1 ? '▲' : '—', + color: 'blue', + active: bankIndex < bankNames.length - 1, + }, + { + key: 'preset-select', + label: state?.current_preset?.name?.slice(0, 6) || '—', + sublabel: state?.current_preset ? `B${bankIndex + 1}` : '', + color: 'amber', + active: true, + }, + { + key: 'global-bypass', + label: 'BYP', + sublabel: state?.bypass ? 'ON' : 'OFF', + color: state?.bypass ? 'red' : 'off', + active: !!state?.bypass, + }, + ]; +} + +// ── Provider ────────────────────────────────────────────────── + +interface FootswitchModeProviderProps { + children: ReactNode; + state: PedalState | null; + connected: boolean; + refresh: () => void; + blocks: PedalBlock[]; + bankNames: string[]; + /** Current snapshot definitions */ + snapshots: { name: string; active: boolean }[]; + /** Called when a block toggle needs to happen */ + onToggleBlock?: (blockId: number, bypassed: boolean) => void; +} + +export function FootswitchModeProvider({ + children, + state, + connected, + refresh, + blocks, + bankNames, + snapshots, + onToggleBlock, +}: FootswitchModeProviderProps) { + const [mode, setMode] = useState('stomp'); + const [currentBankIndex, setCurrentBankIndex] = useState(0); + const [currentPresetIndex, setCurrentPresetIndex] = useState(0); + const [dirty, setDirty] = useState(false); + + // Generate scribble data based on mode + let scribbleData: ScribbleStripData[]; + switch (mode) { + case 'stomp': + scribbleData = stompScribbles(blocks, state); + break; + case 'preset': + scribbleData = presetScribbles(state, currentBankIndex, bankNames); + break; + case 'snapshot': + scribbleData = snapshotScribbles(snapshots); + break; + case 'combo': + scribbleData = comboScribbles(blocks, state, bankNames, currentBankIndex); + break; + default: + scribbleData = stompScribbles(blocks, state); + } + + const handleFootswitch = useCallback((index: number) => { + switch (mode) { + case 'stomp': { + const block = blocks[index]; + if (block && onToggleBlock) { + onToggleBlock(block.id, !block.bypassed); + } + break; + } + case 'preset': { + // FS 0 = bank down, FS 1 = bank up, FS 2-7 = presets + if (index === 0) { + setCurrentBankIndex(prev => Math.max(0, prev - 1)); + } else if (index === 1) { + setCurrentBankIndex(prev => Math.min(bankNames.length - 1, prev + 1)); + } else { + const presetIndex = index - 2; + if (connected) { + activatePreset(currentBankIndex, presetIndex).then(() => refresh()); + setCurrentPresetIndex(presetIndex); + } + } + break; + } + case 'snapshot': { + // Recall snapshot at index + if (connected) { + apiPost(`/api/snapshots/${index}/recall`).catch(() => {}); + } + break; + } + case 'combo': { + // FS 0-3 = stomp toggles, FS 4 = bank down, FS 5 = bank up, + // FS 6 = open preset browser, FS 7 = global bypass + if (index < 4) { + const block = blocks[index]; + if (block && onToggleBlock) { + onToggleBlock(block.id, !block.bypassed); + } + } else if (index === 4) { + setCurrentBankIndex(prev => Math.max(0, prev - 1)); + } else if (index === 5) { + setCurrentBankIndex(prev => Math.min(bankNames.length - 1, prev + 1)); + } else if (index === 6) { + setMode('preset'); + } else if (index === 7) { + if (connected) { + apiPost('/api/bypass', { bypass: !state?.bypass }).catch(() => {}); + } + } + break; + } + } + }, [mode, blocks, onToggleBlock, connected, currentBankIndex, bankNames, state, refresh]); + + const handleSecondary = useCallback((index: number) => { + // Double-tap / long-press secondary actions per mode + switch (mode) { + case 'stomp': { + // Secondary: toggle tuner on FS 7 (last footswitch), or reset block params + if (index === 7 && connected) { + apiPost('/api/tuner', { enabled: !state?.tuner_enabled }).catch(() => {}); + } + break; + } + case 'preset': { + // Secondary on bank switches: jump to first/last bank + if (index === 0) { + setCurrentBankIndex(0); + } else if (index === 1) { + setCurrentBankIndex(bankNames.length - 1); + } + break; + } + case 'snapshot': { + // Secondary: save current state to snapshot + if (connected) { + apiPost(`/api/snapshots/${index}/save`).catch(() => {}); + } + break; + } + case 'combo': { + // Secondary on stomp blocks: edit block params (would open param panel) + // Secondary on bypass: enter tuner mode + if (index === 7 && connected) { + apiPost('/api/tuner', { enabled: !state?.tuner_enabled }).catch(() => {}); + } + break; + } + } + }, [mode, state, connected, bankNames]); + + const setBank = useCallback((bank: number) => { + setCurrentBankIndex(bank); + }, []); + + const setPreset = useCallback((preset: number) => { + setCurrentPresetIndex(preset); + if (connected) { + activatePreset(currentBankIndex, preset).then(() => refresh()); + } + }, [connected, currentBankIndex, refresh]); + + const value: FootswitchModeContextValue = { + mode, + setMode, + scribbleData, + handleFootswitch, + handleSecondary, + currentBankIndex, + currentPresetIndex, + setBank, + setPreset, + dirty, + }; + + return ( + + {children} + + ); +} + +// ── Consumer Hook ───────────────────────────────────────────── + +export function useFootswitchMode(): FootswitchModeContextValue { + const ctx = useContext(FootswitchModeContext); + if (!ctx) { + throw new Error('useFootswitchMode must be used within a FootswitchModeProvider'); + } + return ctx; +} diff --git a/ui/src/pedal/footswitch/FootswitchBar.tsx b/ui/src/pedal/footswitch/FootswitchBar.tsx new file mode 100644 index 0000000..5099168 --- /dev/null +++ b/ui/src/pedal/footswitch/FootswitchBar.tsx @@ -0,0 +1,41 @@ +/** + * FootswitchBar — the bottom bar of 8 footswitches with scribble strip LCDs. + * + * Renders the appropriate scribble data per mode (handled via FootswitchModeContext). + * Each footswitch supports single-tap (primary action) and double-tap (secondary). + */ + +import { useFootswitchMode } from '../FootswitchModeContext'; +import { ScribbleStrip } from './ScribbleStrip'; + +export function FootswitchBar() { + const { scribbleData, handleFootswitch, handleSecondary } = useFootswitchMode(); + + return ( +
+ {scribbleData.map((sd, i) => ( +
+ {/* Scribble strip LCD above the switch */} + handleFootswitch(i)} + onSecondary={() => handleSecondary(i)} + /> + + {/* Footswitch button */} +
handleFootswitch(i)} + onContextMenu={(e) => { e.preventDefault(); handleSecondary(i); }} + style={{ + '--fs-color': sd.color, + } as React.CSSProperties} + > +
+
+
+
+ ))} +
+ ); +} diff --git a/ui/src/pedal/footswitch/ModeSelector.tsx b/ui/src/pedal/footswitch/ModeSelector.tsx new file mode 100644 index 0000000..257fe3b --- /dev/null +++ b/ui/src/pedal/footswitch/ModeSelector.tsx @@ -0,0 +1,56 @@ +/** + * ModeSelector — mode switch buttons shown in the status bar. + * Lets the user cycle between Stomp, Preset, Snapshot, and Combo modes. + */ + +import { useFootswitchMode } from '../FootswitchModeContext'; +import type { FootswitchMode } from '../types'; +import { FOOTSWITCH_MODE_LABELS } from '../types'; + +const MODES: FootswitchMode[] = ['stomp', 'preset', 'snapshot', 'combo']; +const MODE_ICONS: Record = { + stomp: '◉', + preset: '☆', + snapshot: '◆', + combo: '◈', + transport: '▶', +}; + +interface ModeSelectorProps { + collapsed?: boolean; +} + +export function ModeSelector({ collapsed = false }: ModeSelectorProps) { + const { mode, setMode } = useFootswitchMode(); + + if (collapsed) { + // Compact: just a single click target that cycles + const nextIdx = (MODES.indexOf(mode) + 1) % MODES.length; + return ( +
setMode(MODES[nextIdx])} + title={`Mode: ${FOOTSWITCH_MODE_LABELS[mode]} — tap to switch`} + > + {MODE_ICONS[mode]} + {FOOTSWITCH_MODE_LABELS[mode]} +
+ ); + } + + return ( +
+ {MODES.map(m => ( + + ))} +
+ ); +} diff --git a/ui/src/pedal/footswitch/ScribbleStrip.tsx b/ui/src/pedal/footswitch/ScribbleStrip.tsx new file mode 100644 index 0000000..e60788d --- /dev/null +++ b/ui/src/pedal/footswitch/ScribbleStrip.tsx @@ -0,0 +1,60 @@ +/** + * ScribbleStrip — the LCD-like label display above each footswitch. + * + * Shows block/preset/snapshot name and status, mimicking the + * Helix / Line 6 scribble strip LCDs. + */ + +import type { ScribbleStripData, ScribbleColor } from '../types'; +import { SCRIBBLE_COLORS } from '../types'; +import { useDoubleTap } from '../hooks/useDoubleTap'; + +interface ScribbleStripProps { + data: ScribbleStripData; + onPress: () => void; + onSecondary: () => void; +} + +export function ScribbleStrip({ data, onPress, onSecondary }: ScribbleStripProps) { + const { handleTap } = useDoubleTap({ + onSingleTap: onPress, + onDoubleTap: onSecondary, + }); + + const color = SCRIBBLE_COLORS[data.color ?? 'off']; + const isActive = data.active ?? false; + const isBypassed = data.bypassed ?? false; + + return ( +
+ {/* LED indicator */} +
+ + {/* Label area — LCD-style */} +
+ {data.label || '—'} + {data.sublabel && ( + {data.sublabel} + )} +
+ + {/* Icon (optional) */} + {data.icon && {data.icon}} +
+ ); +} diff --git a/ui/src/pedal/hooks/useDoubleTap.ts b/ui/src/pedal/hooks/useDoubleTap.ts new file mode 100644 index 0000000..5af0ec2 --- /dev/null +++ b/ui/src/pedal/hooks/useDoubleTap.ts @@ -0,0 +1,82 @@ +/** + * Double-tap gesture hook for footswitch capacitive touch / secondary actions. + * + * Detects two rapid taps within a configurable threshold and fires + * onDoubleTap. Single taps fire onSingleTap after a short debounce. + */ + +import { useRef, useCallback } from 'react'; + +interface UseDoubleTapOptions { + /** Max ms between taps to count as double-tap (default: 300) */ + threshold?: number; + /** Called on single tap after debounce */ + onSingleTap?: () => void; + /** Called on double tap */ + onDoubleTap?: () => void; +} + +export function useDoubleTap({ + threshold = 300, + onSingleTap, + onDoubleTap, +}: UseDoubleTapOptions) { + const lastTapRef = useRef(0); + const singleTimerRef = useRef | null>(null); + + const handleTap = useCallback(() => { + const now = Date.now(); + const sinceLast = now - lastTapRef.current; + + if (sinceLast < threshold) { + // Double tap detected + if (singleTimerRef.current) { + clearTimeout(singleTimerRef.current); + singleTimerRef.current = null; + } + lastTapRef.current = 0; + onDoubleTap?.(); + } else { + // Potential single tap — wait for double-tap window to close + lastTapRef.current = now; + if (singleTimerRef.current) { + clearTimeout(singleTimerRef.current); + } + singleTimerRef.current = setTimeout(() => { + onSingleTap?.(); + singleTimerRef.current = null; + }, threshold); + } + }, [threshold, onSingleTap, onDoubleTap]); + + return { handleTap }; +} + +/** + * Hook for long-press gesture detection. + * Fires onLongPress after `duration` ms of continuous hold. + */ +export function useLongPress({ + duration = 500, + onLongPress, +}: { + duration?: number; + onLongPress?: () => void; +}) { + const timerRef = useRef | null>(null); + + const handleStart = useCallback(() => { + timerRef.current = setTimeout(() => { + onLongPress?.(); + }, duration); + }, [duration, onLongPress]); + + const handleEnd = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + return { handleStart, handleEnd }; +} diff --git a/ui/src/pedal/hooks/usePedalState.ts b/ui/src/pedal/hooks/usePedalState.ts new file mode 100644 index 0000000..c8a4251 --- /dev/null +++ b/ui/src/pedal/hooks/usePedalState.ts @@ -0,0 +1,155 @@ +/** + * Pi Multi-FX Pedal — State Hook + * + * API helpers, WebSocket real-time updates, and pedal state management. + * Ported and organized from frontend-react/App.jsx. + */ + +import { useState, useRef, useEffect, useCallback } from 'react'; +import type { PedalState, PresetListResponse } from '../types'; + +// ── API Base ────────────────────────────────────────────────── + +const API = window.location.origin; +const WS_URL = API.replace(/^http/, 'ws') + '/ws'; + +// ── API Helpers ─────────────────────────────────────────────── + +export async function apiGet(path: string): Promise { + const r = await fetch(`${API}${path}`); + if (!r.ok) throw new Error(`${path} => ${r.status}`); + return r.json(); +} + +export async function apiPost(path: string, data?: unknown): Promise { + const r = await fetch(`${API}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: data != null ? JSON.stringify(data) : undefined, + }); + if (!r.ok) throw new Error(`${path} => ${r.status}`); + return r.json(); +} + +export async function apiPut(path: string, data: unknown): Promise { + const r = await fetch(`${API}${path}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!r.ok) throw new Error(`${path} => ${r.status}`); + return r.json(); +} + +export async function apiDelete(path: string): Promise { + const r = await fetch(`${API}${path}`, { method: 'DELETE' }); + if (!r.ok) throw new Error(`${path} => ${r.status}`); + return r.json(); +} + +export async function apiUpload(path: string, file: File): Promise { + const fd = new FormData(); + fd.append('file', file); + const r = await fetch(`${API}${path}`, { method: 'POST', body: fd }); + if (!r.ok) throw new Error(`${path} => ${r.status}`); + return r.json(); +} + +// ── usePedalState Hook ──────────────────────────────────────── + +export interface PedalStateResult { + state: PedalState | null; + connected: boolean; + refresh: () => void; +} + +export function usePedalState(): PedalStateResult { + const [state, setState] = useState(null); + const [connected, setConnected] = useState(false); + const wsRef = useRef(null); + const pollRef = useRef | null>(null); + + useEffect(() => { + // Fetch initial state + apiGet('/api/state') + .then(s => { setState(s); setConnected(true); }) + .catch(() => { setConnected(false); }); + + // WebSocket for real-time updates + let reconnectTimer: ReturnType; + + function connect() { + try { + const ws = new WebSocket(WS_URL); + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (msg.type === 'connected' || msg.type === 'state') { + setState(msg.state); + setConnected(true); + } + if (msg.type === 'preset_changed' && msg.state) setState(msg.state); + if (msg.type === 'bypass_changed') { + setState(prev => prev ? { ...prev, bypass: msg.bypass } : prev); + } + if (msg.type === 'tuner_changed') { + setState(prev => prev ? { ...prev, tuner_enabled: msg.tuner_enabled } : prev); + } + if (msg.type === 'routing_changed') { + setState(prev => prev ? { + ...prev, + routing_mode: msg.routing_mode, + routing_breakpoint: msg.routing_breakpoint, + } : prev); + } + if (msg.type === 'model_loaded' || msg.type === 'ir_loaded') { + apiGet('/api/state').then(s => setState(s)); + } + } catch { + // ignore parse errors + } + }; + ws.onclose = () => { + setConnected(false); + reconnectTimer = setTimeout(connect, 3000); + }; + ws.onerror = () => ws.close(); + wsRef.current = ws; + } catch { + reconnectTimer = setTimeout(connect, 3000); + } + } + connect(); + + // Polling fallback every 5s + pollRef.current = setInterval(() => { + apiGet('/api/state') + .then(s => { setState(s); setConnected(true); }) + .catch(() => {}); + }, 5000); + + return () => { + if (pollRef.current) clearInterval(pollRef.current); + clearTimeout(reconnectTimer); + if (wsRef.current) wsRef.current.close(); + }; + }, []); + + const refresh = useCallback(() => { + apiGet('/api/state') + .then(s => { setState(s); setConnected(true); }) + .catch(() => {}); + }, []); + + return { state, connected, refresh }; +} + +// ── Preset helpers ──────────────────────────────────────────── + +export async function fetchPresets(): Promise { + return apiGet('/api/presets'); +} + +export async function activatePreset(bank: number, program: number): Promise { + await apiPost(`/api/presets/${bank}/${program}/activate`); +} diff --git a/ui/src/pedal/pedal.css b/ui/src/pedal/pedal.css new file mode 100644 index 0000000..131029a --- /dev/null +++ b/ui/src/pedal/pedal.css @@ -0,0 +1,447 @@ +/* ══════════════════════════════════════════════════════════════ + Pi Multi-FX Pedal — Styles + Dark pedal theme with Helix-inspired footswitch bar + ══════════════════════════════════════════════════════════════ */ + +/* ── Design Tokens ─────────────────────────────────────────── */ + +:root { + --pedal-bg: #0A0A0C; + --pedal-panel: #141418; + --pedal-surface: #1C1C22; + --pedal-border: #2A2A32; + --pedal-border-light: #3A3A48; + --pedal-amber: #E8A030; + --pedal-amber-dim: #7A5218; + --pedal-blue: #3A7BA8; + --pedal-blue-dim: #1E4060; + --pedal-green: #3AB87A; + --pedal-green-dim: #1E6040; + --pedal-red: #C84040; + --pedal-red-dim: #602020; + --pedal-purple: #9B59B6; + --pedal-cyan: #1ABC9C; + --pedal-text: #F0EDE6; + --pedal-text-sec: #8888A0; + --pedal-text-dim: #444458; + --pedal-radius: 0.625rem; + --pedal-fs-height: 80px; +} + +/* ── Status Bar ────────────────────────────────────────────── */ + +.pedal-statusbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 12px 4px; + background: var(--pedal-panel); + border-bottom: 1px solid var(--pedal-border); + flex-shrink: 0; + gap: 8px; + min-height: 32px; +} + +.pedal-statusbar-left, +.pedal-statusbar-right { + display: flex; + align-items: center; + gap: 6px; +} + +.pedal-statusbar-mid { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + justify-content: center; +} + +.pedal-brand { + font-family: 'JetBrains Mono', 'SF Mono', monospace; + font-size: 9px; + color: var(--pedal-text-sec); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* ── Mode Selector ─────────────────────────────────────────── */ + +.mode-selector { + display: flex; + gap: 2px; + background: var(--pedal-surface); + border-radius: 6px; + border: 1px solid var(--pedal-border); + overflow: hidden; +} + +.mode-selector-collapsed { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + cursor: pointer; + border-radius: 5px; + background: var(--pedal-surface); + border: 1px solid var(--pedal-border); + transition: background 0.15s; +} + +.mode-selector-collapsed:hover { + background: var(--pedal-border); +} + +.mode-btn { + flex: 1; + display: flex; + align-items: center; + gap: 3px; + padding: 5px 7px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + background: none; + border: none; + color: var(--pedal-text-dim); + cursor: pointer; + transition: all 0.12s; + white-space: nowrap; +} + +.mode-btn:hover { + color: var(--pedal-text-sec); +} + +.mode-btn-active { + background: var(--pedal-amber-dim); + color: var(--pedal-amber) !important; +} + +.mode-btn-icon { + font-size: 11px; +} + +.mode-icon { + font-size: 12px; + color: var(--pedal-amber); +} + +.mode-label { + font-size: 10px; + color: var(--pedal-text-sec); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* ── Badge chips in status bar ─────────────────────────────── */ + +.pedal-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 5px; + border-radius: 3px; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.pedal-badge-amber { + background: rgba(232, 160, 48, 0.2); + color: var(--pedal-amber); +} + +.pedal-badge-green { + background: rgba(58, 184, 122, 0.2); + color: var(--pedal-green); +} + +.pedal-badge-red { + background: rgba(200, 64, 64, 0.2); + color: var(--pedal-red); +} + +.pedal-badge-blue { + background: rgba(58, 123, 168, 0.2); + color: var(--pedal-blue); +} + +/* ── Status LED dot ────────────────────────────────────────── */ + +.pedal-led-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +/* ── Footswitch Bar ────────────────────────────────────────── */ + +.footswitch-bar { + display: flex; + gap: 3px; + padding: 4px 6px 6px; + background: var(--pedal-panel); + border-top: 1px solid var(--pedal-border); + flex-shrink: 0; + justify-content: center; +} + +.footswitch-slot { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + flex: 1; + min-width: 0; + max-width: 56px; +} + +/* ── Scribble Strip ────────────────────────────────────────── */ + +.scribble-strip { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + width: 100%; + padding: 3px 2px; + background: #0D0D10; + border: 1px solid var(--pedal-border); + border-radius: 3px; + cursor: pointer; + transition: border-color 0.15s; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +.scribble-strip[data-active="true"] { + border-color: var(--scribble-color, var(--pedal-amber)); +} + +.scribble-strip[data-bypassed="true"] { + opacity: 0.5; +} + +.scribble-led { + width: 4px; + height: 4px; + border-radius: 50%; + flex-shrink: 0; + transition: opacity 0.15s; + margin-bottom: 1px; +} + +.scribble-label-wrap { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + min-height: 22px; + justify-content: center; +} + +.scribble-label { + font-family: 'JetBrains Mono', 'SF Mono', monospace; + font-size: 8px; + font-weight: 600; + color: var(--pedal-text); + text-align: center; + line-height: 1.1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.scribble-sublabel { + font-family: 'JetBrains Mono', 'SF Mono', monospace; + font-size: 6.5px; + color: var(--scribble-color, var(--pedal-text-sec)); + text-align: center; + line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.scribble-icon { + font-size: 9px; + line-height: 1; +} + +/* ── Footswitch Button ─────────────────────────────────────── */ + +.footswitch-btn { + position: relative; + width: 42px; + height: 42px; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + transition: transform 0.08s; +} + +.footswitch-btn:active { + transform: scale(0.93); +} + +.footswitch-ring { + position: absolute; + inset: 0; + border-radius: 50%; + border: 2.5px solid var(--pedal-border); + background: radial-gradient(circle at 40% 35%, #2A2A38, #141418); + transition: border-color 0.12s, box-shadow 0.12s; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.footswitch-btn-active .footswitch-ring { + border-color: var(--fs-color, var(--pedal-green)); + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.5), + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 0 8px var(--fs-color, var(--pedal-green)); +} + +.footswitch-btn-bypassed .footswitch-ring { + opacity: 0.5; + border-color: var(--pedal-text-dim); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5); +} + +.footswitch-cap { + position: absolute; + inset: 8px; + border-radius: 50%; + background: radial-gradient(circle at 38% 32%, #3A3A48, #1A1A22); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 -1px 0 rgba(0, 0, 0, 0.3); +} + +.footswitch-cap::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 8px; + height: 8px; + border-radius: 50%; + background: radial-gradient(circle at 50% 35%, #5A5A6A, #2A2A38); +} + +/* ── Tab bar (existing, aligned) ───────────────────────────── */ + +.pedal-tabbar { + display: flex; + background: var(--pedal-panel); + border-top: 1px solid var(--pedal-border); + flex-shrink: 0; +} + +.pedal-tab { + flex: 1; + padding: 8px 4px 6px; + font-size: 9px; + font-weight: 500; + letter-spacing: 0.04em; + text-align: center; + text-transform: uppercase; + color: var(--pedal-text-dim); + cursor: pointer; + border-top: 2px solid transparent; + transition: all 0.12s; + user-select: none; +} + +.pedal-tab-active { + color: var(--pedal-amber); + border-top-color: var(--pedal-amber); +} + +.pedal-tab-icon { + font-size: 13px; + display: block; + margin-bottom: 1px; +} + +/* ── Screen Area ───────────────────────────────────────────── */ + +.pedal-screen { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* ── Responsive ────────────────────────────────────────────── */ + +@media (max-width: 420px) { + .footswitch-bar { + padding: 3px 3px 5px; + gap: 2px; + } + + .footswitch-slot { + max-width: 48px; + } + + .footswitch-btn { + width: 36px; + height: 36px; + } + + .footswitch-cap { + inset: 6px; + } + + .footswitch-cap::after { + width: 6px; + height: 6px; + } + + .scribble-label { + font-size: 7px; + } + + .mode-btn { + padding: 3px 5px; + font-size: 9px; + } + + .mode-btn-label { + display: none; + } +} + +/* ── Pedal Shell ───────────────────────────────────────────── */ + +.pedal-shell { + width: 100%; + max-width: 440px; + margin: 0 auto; + height: 100dvh; + display: flex; + flex-direction: column; + background: var(--pedal-bg); + color: var(--pedal-text); + overflow: hidden; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; +} + +@media (min-width: 441px) { + .pedal-shell { + border-left: 1px solid var(--pedal-border); + border-right: 1px solid var(--pedal-border); + } +} diff --git a/ui/src/pedal/types.ts b/ui/src/pedal/types.ts new file mode 100644 index 0000000..23962f5 --- /dev/null +++ b/ui/src/pedal/types.ts @@ -0,0 +1,164 @@ +/** + * Pi Multi-FX Pedal — Type Definitions + * + * Core types for footswitch modes, pedal state, and UI components. + */ + +// ── Footswitch Modes ────────────────────────────────────────── + +export type FootswitchMode = 'stomp' | 'preset' | 'snapshot' | 'combo' | 'transport'; + +export const FOOTSWITCH_MODES: FootswitchMode[] = ['stomp', 'preset', 'snapshot', 'combo']; +export const FOOTSWITCH_MODE_LABELS: Record = { + stomp: 'Stomp', + preset: 'Preset', + snapshot: 'Snapshot', + combo: 'Combo', + transport: 'Transport', +}; + +// ── Scribble Strip ──────────────────────────────────────────── + +export interface ScribbleStripData { + /** Main label shown on the strip (uppercase track name) */ + label: string; + /** Optional secondary line below the label */ + sublabel?: string; + /** LED color for the footswitch ring / indicator */ + color?: ScribbleColor; + /** Whether the footswitch is active/enabled */ + active?: boolean; + /** Whether the block is bypassed (stomp mode) */ + bypassed?: boolean; + /** Unique key for this footswitch */ + key: string; + /** Optional icon */ + icon?: string; +} + +export type ScribbleColor = + | 'amber' + | 'green' + | 'red' + | 'blue' + | 'purple' + | 'cyan' + | 'white' + | 'off'; + +export const SCRIBBLE_COLORS: Record = { + amber: '#E8A030', + green: '#3AB87A', + red: '#C84040', + blue: '#3A7BA8', + purple: '#9B59B6', + cyan: '#1ABC9C', + white: '#F0EDE6', + off: '#444458', +}; + +// ── Footswitch Action ───────────────────────────────────────── + +export type FootswitchAction = + | { type: 'toggle_block'; blockId: string } + | { type: 'bank_up' } + | { type: 'bank_down' } + | { type: 'select_preset'; bank: number; program: number } + | { type: 'recall_snapshot'; snapshotIndex: number } + | { type: 'global_bypass' } + | { type: 'tap_tempo' } + | { type: 'tuner' } + | { type: 'bank_select'; bank: number }; + +// ── Pedal State (from API) ──────────────────────────────────── + +export interface PedalState { + master_volume?: number; + bypass?: boolean; + tuner_enabled?: boolean; + cpu_percent?: number; + sample_rate?: number; + nam_loaded?: boolean; + nam_model?: string; + ir_loaded?: boolean; + ir_name?: string; + current_preset?: { name: string; bank: number; program: number }; + input_level?: number; + output_level?: number; + routing_mode?: string; + routing_breakpoint?: number; + wifi?: { + connected: boolean; + ssid?: string; + ip?: string; + signal?: number; + hotspot_active: boolean; + hotspot_ssid?: string; + }; + bluetooth?: { + powered: boolean; + discoverable: boolean; + midi_enabled: boolean; + name?: string; + address?: string; + }; + blocks?: PedalBlock[]; +} + +export interface PedalBlock { + id: number; + fx_type: string; + title: string; + bypassed: boolean; + params?: BlockParam[]; +} + +export interface BlockParam { + name: string; + key: string; + default?: number; + min?: number; + max?: number; +} + +// ── Preset Data ─────────────────────────────────────────────── + +export interface PresetData { + name: string; + bank: number; + program: number; + chain?: PedalBlock[]; +} + +export interface BankData { + name: string; + number: number; + presets: (PresetData | null)[]; +} + +export interface PresetListResponse { + banks: BankData[]; +} + +// ── Snapshot ────────────────────────────────────────────────── + +export interface SnapshotData { + name: string; + index: number; + /** Block bypass states at snapshot time */ + blockStates: Record; + /** Parameter values at snapshot time (blockId -> paramKey -> value) */ + paramValues: Record>; +} + +// ── WebSocket Messages ──────────────────────────────────────── + +export type WsMessage = + | { type: 'connected'; state: PedalState } + | { type: 'state'; state: PedalState } + | { type: 'preset_changed'; state: PedalState } + | { type: 'bypass_changed'; bypass: boolean } + | { type: 'tuner_changed'; tuner_enabled: boolean } + | { type: 'routing_changed'; routing_mode: string; routing_breakpoint: number } + | { type: 'model_loaded' } + | { type: 'ir_loaded' };