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 */}
+
+
+
+
+
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 (
+
+
+
+ {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 (
+
+
+
+
+
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 (
<>
-
-
-
-
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' };