From 15c8c641988b6050d2aa254e8da3368842d6839f Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 22 Sep 2026 21:00:59 +0100 Subject: [PATCH 1/6] Simplify setup and settings with Tailwind and large controls --- README.md | 2 +- package-lock.json | 645 +++++++++++++++++++++++++++- package.json | 6 +- src-tauri/tauri.conf.json | 2 +- src/App.test.tsx | 58 +-- src/App.tsx | 85 ++-- src/PointScan.test.tsx | 29 +- src/Switches.test.tsx | 16 + src/Tabs.tsx | 5 +- src/help/Demonstration.test.tsx | 13 +- src/help/Demonstration.tsx | 13 +- src/help/demonstrations.css | 7 +- src/help/demonstrations.ts | 14 +- src/scanning/SwitchPractice.tsx | 9 +- src/settings/CursorSection.tsx | 5 +- src/settings/PointerSection.tsx | 26 +- src/settings/PrivacySection.tsx | 3 +- src/settings/ScannerPreferences.tsx | 70 +-- src/settings/ScanningSection.tsx | 7 +- src/settings/SwitchesSection.tsx | 171 ++++---- src/settings/UpdatesSection.tsx | 3 +- src/settings/controls.tsx | 7 +- src/settings/settings.test.tsx | 26 +- src/styles.css | 48 ++- src/ui/app.css | 112 +++++ src/ui/controls.test.tsx | 44 ++ src/ui/controls.tsx | 37 ++ vite.config.ts | 3 +- 28 files changed, 1176 insertions(+), 290 deletions(-) create mode 100644 src/ui/app.css create mode 100644 src/ui/controls.test.tsx create mode 100644 src/ui/controls.tsx diff --git a/README.md b/README.md index 11c04192..97820c5f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The application uses the shipping product identity `Switchify PC` and bundle ide - Node.js 24 - Rust 1.97.1 through rustup, including `rustfmt` and `clippy` - Windows: Visual Studio Build Tools with the Desktop development with C++ workload -- macOS: Xcode Command Line Tools and macOS 13 or later +- macOS: Xcode Command Line Tools and macOS 13.3 or later ## Run diff --git a/package-lock.json b/package-lock.json index b40caf13..164179b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "react-dom": "^19.1.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@tauri-apps/cli": "^2.8.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", @@ -21,6 +22,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", "jsdom": "^25.0.1", + "tailwindcss": "^4.3.3", "typescript": "~5.8.3", "vite": "^7.0.0", "vitest": "^3.0.0" @@ -88,7 +90,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -438,7 +439,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -462,7 +462,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1333,6 +1332,290 @@ "win32" ] }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@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.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@tauri-apps/api": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", @@ -1641,7 +1924,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1719,7 +2003,6 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1730,7 +2013,6 @@ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1887,6 +2169,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -1897,6 +2180,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -1964,7 +2248,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -2175,12 +2458,23 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -2204,6 +2498,20 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -2467,6 +2775,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2580,6 +2895,16 @@ "dev": true, "license": "MIT" }, + "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==", + "dev": true, + "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", @@ -2593,7 +2918,6 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -2655,6 +2979,279 @@ "node": ">=6" } }, + "node_modules/lightningcss": { + "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" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2687,6 +3284,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -2830,7 +3428,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2873,6 +3470,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -2897,7 +3495,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2907,7 +3504,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2920,7 +3516,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -3106,6 +3703,27 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3264,7 +3882,6 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index d943d5ff..8a983aa6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "private": true, "version": "1.0.0-rc.14", "type": "module", - "engines": { "node": ">=24 <25" }, + "engines": { + "node": ">=24 <25" + }, "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", @@ -22,6 +24,7 @@ "react-dom": "^19.1.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@tauri-apps/cli": "^2.8.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", @@ -29,6 +32,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", "jsdom": "^25.0.1", + "tailwindcss": "^4.3.3", "typescript": "~5.8.3", "vite": "^7.0.0", "vitest": "^3.0.0" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 914e6f1e..e017517a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -51,7 +51,7 @@ }, "macOS": { "hardenedRuntime": true, - "minimumSystemVersion": "13.0", + "minimumSystemVersion": "13.3", "infoPlist": "Info.plist" } } diff --git a/src/App.test.tsx b/src/App.test.tsx index 77294481..0a467130 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -38,17 +38,19 @@ describe("Switchify PC shell", () => { const setSettings = vi.spyOn(api, "saveSettings"); render(); await screen.findByRole("heading", { name: "Switchify PC" }); - fireEvent.click(screen.getByRole("button", { name: "Mobile connection" })); - fireEvent.click(screen.getByRole("button", { name: "Show demonstration: Pair a mobile device" })); + fireEvent.click(screen.getByRole("button", { name: "Mobile" })); + fireEvent.click(screen.getByRole("button", { name: "Show me how: Pair a mobile device" })); expect(screen.getByRole("button", { name: "Pause Pair a mobile device" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Scanning" })); expect(screen.queryByRole("button", { name: "Pause Pair a mobile device" })).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Show demonstration: Automatic grid then line scanning" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Show demonstration: Automatic line-only scanning" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show me how: Automatic grid then line scanning" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show me how: Automatic line-only scanning" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Switches" })); - expect(screen.getByRole("button", { name: "Show demonstration: Connect a jack switch" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "More options" })); + fireEvent.click(screen.getByRole("button", { name: "Switch help" })); + expect(screen.getByRole("button", { name: "Show me how: Connect a jack switch" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Settings" })); - expect(screen.getByRole("button", { name: "Show demonstration: Understand startup behavior" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show me how: Understand startup behavior" })).toBeInTheDocument(); expect(setSettings).not.toHaveBeenCalled(); }); @@ -61,7 +63,7 @@ describe("Switchify PC shell", () => { let receive: ((state: typeof browserState) => void) | undefined; vi.spyOn(api, "onState").mockImplementation(async (handler) => { receive = handler; return () => undefined; }); render(); - await screen.findByRole("heading", { name: "Ready for switch control" }); + await screen.findByRole("heading", { name: "Ready" }); expect(screen.getByText("1 saved · Select assigned")).toBeInTheDocument(); expect(screen.getByText("Manual scanning · Line only")).toBeInTheDocument(); scanning.state = { ...scanning.state!, paused: true }; @@ -202,12 +204,12 @@ describe("Switchify PC shell", () => { it("distinguishes the Mobile install QR from pairing for existing users", async () => { render(); await screen.findByRole("heading", { name: "Switchify PC" }); - fireEvent.click(screen.getByRole("button", { name: "Mobile connection" })); + fireEvent.click(screen.getByRole("button", { name: "Mobile" })); expect(screen.getByRole("heading", { name: "Install Switchify for mobile" })).toBeInTheDocument(); const qr = screen.getByRole("img", { name: "QR code for Switchify on Google Play" }); - expect(qr.closest("figure")).toHaveTextContent("Scan to open Google Play and install Switchify."); + expect(qr.closest("figure")).toHaveTextContent("Get Switchify"); expect(screen.getByRole("link", { name: "Open Google Play" })).toHaveAttribute("href", expect.stringContaining("play.google.com")); - const pairing = screen.getByRole("heading", { name: "Already installed? Connect to this computer" }).closest("section")!; + const pairing = screen.getByRole("heading", { name: "Connect your mobile" }).closest("section")!; expect(within(pairing).getAllByRole("listitem").map((item) => item.textContent)).toEqual([ "Open Switchify on your mobile device and select this computer.", "Compare the pairing codes shown in both apps.", @@ -224,7 +226,7 @@ describe("Switchify PC shell", () => { render(); await screen.findByRole("heading", { name: "Switchify PC" }); - fireEvent.click(screen.getByRole("button", { name: "Mobile connection" })); + fireEvent.click(screen.getByRole("button", { name: "Mobile" })); fireEvent.click(screen.getByRole("tab", { name: "Paired devices" })); expect(screen.getByText(`Last connected ${new Date(connectedAt).toLocaleString()}`)).toBeInTheDocument(); @@ -247,7 +249,7 @@ describe("Switchify PC shell", () => { expect(screen.queryByText("Recent activity")).not.toBeInTheDocument(); expect(screen.queryByText("Internal runtime activity")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Support" })); + fireEvent.click(screen.getByRole("button", { name: "Help" })); fireEvent.click(await screen.findByRole("tab", { name: "Troubleshooting" })); expect(screen.queryByText("Recent activity")).not.toBeInTheDocument(); expect(screen.queryByText("Internal runtime activity")).not.toBeInTheDocument(); @@ -273,7 +275,7 @@ describe("Switchify PC shell", () => { render(); await screen.findByRole("heading", { name: "Switchify PC" }); - fireEvent.click(screen.getByRole("button", { name: "Mobile connection" })); + fireEvent.click(screen.getByRole("button", { name: "Mobile" })); expect(screen.getByText(description)).toBeInTheDocument(); if (bluetooth !== "connected") expect(screen.queryByText("Stale tablet")).not.toBeInTheDocument(); expect(screen.queryByText("Internal runtime activity")).not.toBeInTheDocument(); @@ -382,7 +384,7 @@ describe("Switchify PC shell", () => { fireEvent.click(screen.getByRole("button", { name: "Skip setup for now" })); await waitFor(() => expect(screen.queryByRole("dialog", { name: "Input access" })).not.toBeInTheDocument()); expect(markShown).toHaveBeenCalledTimes(2); - fireEvent.click(screen.getByRole("button", { name: "Support" })); + fireEvent.click(screen.getByRole("button", { name: "Help" })); fireEvent.click(screen.getByRole("button", { name: "Open setup guide" })); expect(await screen.findByRole("dialog", { name: "Input access" })).toBeInTheDocument(); }); @@ -413,7 +415,7 @@ describe("Switchify PC shell", () => { setup: { shown: true, completed: true, autoOpenEligible: false }, })); render(); - fireEvent.click(await screen.findByRole("button", { name: "Support" })); + fireEvent.click(await screen.findByRole("button", { name: "Help" })); fireEvent.click(screen.getByRole("button", { name: "Open setup guide" })); fireEvent.click(await screen.findByRole("button", { name: "Next" })); expect(screen.getByRole("dialog", { name: "Add your switch" })).toBeInTheDocument(); @@ -440,7 +442,7 @@ describe("Switchify PC shell", () => { recentErrors: [{ sequence: 3, timestamp: 3, category: "runtime", status: "failed", detail: "Bluetooth unavailable" }], }; render(); - fireEvent.click(await screen.findByRole("button", { name: "Support" })); + fireEvent.click(await screen.findByRole("button", { name: "Help" })); fireEvent.click(screen.getByRole("tab", { name: "Troubleshooting" })); expect(screen.getByText("Recent Bluetooth changes")).toBeInTheDocument(); expect(screen.getByText("advertising")).toBeInTheDocument(); @@ -508,7 +510,7 @@ describe("Switchify PC shell", () => { return [profile]; }); render(); - fireEvent.click(await screen.findByRole("button", { name: "Mobile connection" })); + fireEvent.click(await screen.findByRole("button", { name: "Mobile" })); fireEvent.click(screen.getByRole("tab", { name: "Switch Forwarding" })); const newProfile = await screen.findByRole("button", { name: "New profile" }); expect(newProfile).toHaveClass("primary"); @@ -529,7 +531,7 @@ describe("Switchify PC shell", () => { }; vi.spyOn(api, "listProfiles").mockResolvedValue([source, { ...source, id: crypto.randomUUID(), name: "Generic keyboard copy", builtIn: false }]); render(); - fireEvent.click(await screen.findByRole("button", { name: "Mobile connection" })); + fireEvent.click(await screen.findByRole("button", { name: "Mobile" })); fireEvent.click(screen.getByRole("tab", { name: "Switch Forwarding" })); fireEvent.click(await screen.findByRole("button", { name: /Generic keyboard.*Built in/ })); expect(screen.getByRole("textbox", { name: "Profile name" })).toBeDisabled(); @@ -544,7 +546,7 @@ describe("Switchify PC shell", () => { it("identifies duplicate names and bindings at their fields", async () => { render(); - fireEvent.click(await screen.findByRole("button", { name: "Mobile connection" })); + fireEvent.click(await screen.findByRole("button", { name: "Mobile" })); fireEvent.click(screen.getByRole("tab", { name: "Switch Forwarding" })); fireEvent.click(await screen.findByRole("button", { name: "New profile" })); fireEvent.change(screen.getByRole("textbox", { name: "Profile name" }), { target: { value: "Generic keyboard" } }); @@ -563,7 +565,7 @@ describe("Switchify PC shell", () => { it("confirms dirty editor dismissal and restores focus", async () => { render(); - fireEvent.click(await screen.findByRole("button", { name: "Mobile connection" })); + fireEvent.click(await screen.findByRole("button", { name: "Mobile" })); fireEvent.click(screen.getByRole("tab", { name: "Switch Forwarding" })); const opener = await screen.findByRole("button", { name: "New profile" }); fireEvent.click(opener); @@ -578,7 +580,7 @@ describe("Switchify PC shell", () => { it("closes pristine new and duplicated profiles without a discard prompt", async () => { render(); - fireEvent.click(await screen.findByRole("button", { name: "Mobile connection" })); + fireEvent.click(await screen.findByRole("button", { name: "Mobile" })); fireEvent.click(screen.getByRole("tab", { name: "Switch Forwarding" })); const newProfile = await screen.findByRole("button", { name: "New profile" }); fireEvent.click(newProfile); @@ -596,7 +598,7 @@ describe("Switchify PC shell", () => { it("prevents navigation from discarding a modified profile", async () => { const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); render(); - fireEvent.click(await screen.findByRole("button", { name: "Mobile connection" })); + fireEvent.click(await screen.findByRole("button", { name: "Mobile" })); fireEvent.click(screen.getByRole("tab", { name: "Switch Forwarding" })); fireEvent.click(await screen.findByRole("button", { name: "New profile" })); fireEvent.change(screen.getByRole("textbox", { name: "Profile name" }), { target: { value: "Modified controls" } }); @@ -619,7 +621,7 @@ describe("Switchify PC shell", () => { return () => undefined; }); render(); - fireEvent.click(await screen.findByRole("button", { name: "Mobile connection" })); + fireEvent.click(await screen.findByRole("button", { name: "Mobile" })); fireEvent.click(screen.getByRole("tab", { name: "Switch Forwarding" })); fireEvent.click(await screen.findByRole("button", { name: "New profile" })); fireEvent.change(screen.getByRole("textbox", { name: "Profile name" }), { target: { value: "Unsaved controls" } }); @@ -645,7 +647,7 @@ describe("Switchify PC shell", () => { vi.spyOn(api, "listProfiles").mockResolvedValue([custom]); vi.spyOn(api, "deleteProfile").mockResolvedValue([]); render(); - fireEvent.click(await screen.findByRole("button", { name: "Mobile connection" })); + fireEvent.click(await screen.findByRole("button", { name: "Mobile" })); fireEvent.click(screen.getByRole("tab", { name: "Switch Forwarding" })); fireEvent.click(await screen.findByRole("button", { name: /Scanning controls.*Custom/ })); fireEvent.change(screen.getByRole("textbox", { name: "Profile name" }), { target: { value: "Changed controls" } }); @@ -660,7 +662,7 @@ describe("Switchify PC shell", () => { it("keeps the editor open and focused when saving fails", async () => { vi.spyOn(api, "saveProfile").mockRejectedValue(new Error("Profile storage unavailable")); render(); - fireEvent.click(await screen.findByRole("button", { name: "Mobile connection" })); + fireEvent.click(await screen.findByRole("button", { name: "Mobile" })); fireEvent.click(screen.getByRole("tab", { name: "Switch Forwarding" })); fireEvent.click(await screen.findByRole("button", { name: "New profile" })); fireEvent.click(screen.getByRole("button", { name: "Save profile" })); @@ -671,7 +673,7 @@ describe("Switchify PC shell", () => { it("exposes setup status and troubleshooting actions", async () => { render(); - fireEvent.click(await screen.findByRole("button", { name: "Support" })); + fireEvent.click(await screen.findByRole("button", { name: "Help" })); expect(screen.getByRole("heading", { name: "Local switches" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("tab", { name: "Troubleshooting" })); expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument(); @@ -685,7 +687,7 @@ describe("Switchify PC shell", () => { const originalPlatform = browserState.capabilities.platform; browserState.capabilities.platform = "macos"; render(); - fireEvent.click(await screen.findByRole("button", { name: "Support" })); + fireEvent.click(await screen.findByRole("button", { name: "Help" })); expect(screen.getByText(/Enable “Switchify PC” in Accessibility/)).toBeInTheDocument(); expect(screen.getByText(/select the stale row, click Remove/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Open Accessibility Settings" })).toBeInTheDocument(); @@ -708,7 +710,7 @@ describe("Switchify PC shell", () => { it("gives the Support tabs a single tab stop, arrow-key movement and linked panels", async () => { render(); - fireEvent.click(await screen.findByRole("button", { name: "Support" })); + fireEvent.click(await screen.findByRole("button", { name: "Help" })); const setup = screen.getByRole("tab", { name: "Setup" }); const troubleshooting = screen.getByRole("tab", { name: "Troubleshooting" }); diff --git a/src/App.tsx b/src/App.tsx index ebfc37d9..30fd6847 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import { Button, Input, Select } from "./ui/controls"; import { Demonstration, DemonstrationProvider } from "./help/Demonstration"; import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { @@ -42,7 +43,7 @@ const bluetoothDescriptions: Record = { }; function NavButton({ active, icon, children, onClick }: { active: boolean; icon: ReactNode; children: ReactNode; onClick: () => void }) { - return ; + return ; } function StatusIcon({ ok, children }: { ok: boolean; children: ReactNode }) { @@ -68,15 +69,15 @@ function HomeView({ state, switches, scanning, navigate, onDisconnect, onAccessi

Switchify PC

Control your computer with switches.

-

{ready ? "Ready for switch control" : "Switch control"}

{message}

- +

{ready ? "Ready" : "Switch control"}

{message}

+
-

Input access

{state.accessibility === "required" && }
-

Configured switches

{saved.length} saved · {hasSelect ? "Select assigned" : "Select action needed"}

-

Scanning

{areaOptions(scanning.config, "point").automatic ? "Automatic scanning" : "Manual scanning"} · {scanning.config.mode === "line" ? "Line only" : "Grid then line"}

+

Input access

{state.accessibility === "required" && }
+

Saved switches

{saved.length} saved · {hasSelect ? "Select assigned" : "Select action needed"}

+

Scanning

{areaOptions(scanning.config, "point").automatic ? "Automatic scanning" : "Manual scanning"} · {scanning.config.mode === "line" ? "Line only" : "Grid then line"}

-

Mobile connection

{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothLabels.connected : bluetoothLabels[state.bluetooth]}

{state.bluetooth === "connected" && }
+

Mobile connection

{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothLabels.connected : bluetoothLabels[state.bluetooth]}

{state.bluetooth === "connected" && }
; } @@ -85,21 +86,21 @@ function MobileConnection({ state, onDisconnect }: { state: AppState; onDisconne return

{bluetoothLabels[state.bluetooth]}

{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothDescriptions.connected : bluetoothDescriptions[state.bluetooth]}

- {state.bluetooth === "connected" && } + {state.bluetooth === "connected" && }
-

Mobile connection is optional. Bluetooth advertises automatically; local switches do not need a mobile device. Connecting a mobile device pauses local scanning until it disconnects.

+

Optional. Connecting a mobile device pauses local scanning.

-

Install Switchify for mobile

Need the mobile app? Scan the QR code or open Google Play to install Switchify.

Open Google Play
-
QR code for Switchify on Google Play
Scan to open Google Play and install Switchify.
+

Install Switchify for mobile

Scan to get the app.

Open Google Play
+
QR code for Switchify on Google Play
Get Switchify
-

Already installed? Connect to this computer

  1. Open Switchify on your mobile device and select this computer.
  2. Compare the pairing codes shown in both apps.
  3. Approve the pairing request on this computer only if the codes match.
+

Connect your mobile

  1. Open Switchify on your mobile device and select this computer.
  2. Compare the pairing codes shown in both apps.
  3. Approve the pairing request on this computer only if the codes match.
; } function DevicesView({ state, forget }: { state: AppState; forget: (id: string) => void }) { return

Paired devices

Mobile devices trusted by this computer

{state.pairedDevices.length === 0 ?

No paired devices

Open Switchify on your mobile device to pair while this computer is advertising.

: -
{state.pairedDevices.map((device) =>

{device.deviceName}

{device.lastSeenAt !== null ? `Last connected ${new Date(device.lastSeenAt).toLocaleString()}` : "Not connected yet"}

)}
} +
{state.pairedDevices.map((device) =>

{device.deviceName}

{device.lastSeenAt !== null ? `Last connected ${new Date(device.lastSeenAt).toLocaleString()}` : "Not connected yet"}

)}
}
; } @@ -277,25 +278,25 @@ function ProfileEditor({ profile, profiles, onClose, onSave, onDelete, onDuplica if (confirmation) return

{confirmationTitle}

{confirmationCopy}

-
+
; return
-

{profile.builtIn ? profile.name : "Edit switch profile"}

Map each physical switch to a desktop action.

+

{profile.builtIn ? profile.name : "Edit switch profile"}

Map each physical switch to a desktop action.

{operationError &&
{operationError}
} - +
{draft.bindings.map((binding, index) =>
Switch {binding.switchId} - setBinding(index, event.target.value as typeof binding.type)}> - - {(binding.type === "key" || binding.type === "shortcut") && recordKey(index, binding, event)} />} - {(binding.type === "mouseButton" || binding.type === "mouseClick") && } - {binding.type === "scroll" && } - {binding.type === "media" && } + + {(binding.type === "key" || binding.type === "shortcut") && recordKey(index, binding, event)} />} + {(binding.type === "mouseButton" || binding.type === "mouseClick") && } + {binding.type === "scroll" && } + {binding.type === "media" && } {errors[`binding-${binding.switchId}`] && {errors[`binding-${binding.switchId}`]}}
)}
-
{onDelete && }{!profile.builtIn && }
+
{onDelete && }{!profile.builtIn && }
; } @@ -304,8 +305,8 @@ function ProfilesView({ profiles, platform, saveProfile, deleteProfile, onDirtyC const openerRef = useRef(null); const closeEditor = () => { setEditing(null); requestAnimationFrame(() => (openerRef.current?.isConnected ? openerRef.current : document.querySelector(".page-header button"))?.focus()); }; const openEditor = (profile: SwitchProfile, opener: HTMLButtonElement) => { openerRef.current = opener; setEditing(profile); }; - return

Switch Forwarding

Profiles available to physical switch sessions

-
{profiles.map((profile) => )}
+ return

Switch Forwarding

Profiles available to physical switch sessions

+
{profiles.map((profile) => )}
{platform === "macos" &&

Grid 3 profiles are available on Windows only.

} {editing && { closeEditor(); onConfirmNativeExit(); }} onCancelNativeExit={onCancelNativeExit} onClose={closeEditor} onDuplicate={() => setEditing(duplicateProfile(editing, profiles))} onSave={async (profile) => { await saveProfile(profile); closeEditor(); }} onDelete={editing.builtIn || !profiles.some((profile) => profile.id === editing.id) ? null : async () => { await deleteProfile(editing.id); closeEditor(); }} />}
; @@ -321,7 +322,7 @@ function UpdateBanner({ update, openUpdates }: { update: UpdateState; openUpdate return
; } @@ -329,17 +330,17 @@ const supportTabs = [{ id: "setup", label: "Setup" }, { id: "troubleshooting", l function SupportView({ state, switches, busy, perform, openSetup, openUpdates }: { state: AppState; switches: SwitchController; busy: boolean; perform: (operation: () => Promise) => void; openSetup: () => void; openUpdates: () => void }) { const [tab, setTab] = useState<(typeof supportTabs)[number]["id"]>("setup"); - return

Support

Switch setup and system diagnostics

+ return

Help

- {tab === "setup" ? <>
-

Input access

{state.accessibility === "required" && }
+ {tab === "setup" ? <>
+

Input access

{state.accessibility === "required" && }

Local switches

{switches.state?.settings.bindings.length ?? 0} configured. Open Switches to assign Select, then open Scanning to adjust movement.

Optional mobile connection

{bluetoothLabels[state.bluetooth]}. Open Mobile connection for pairing and forwarding profiles.

:
-

Bluetooth connection

{bluetoothLabels[state.bluetooth]}

-

Input access

{state.accessibility === "required" ? : }
-

Application update

Switchify PC {state.version}

-

Diagnostics

Export sanitized health, capability, and recent event data

+

Bluetooth connection

{bluetoothLabels[state.bluetooth]}

+

Input access

{state.accessibility === "required" ? : }
+

Application update

Switchify PC {state.version}

+

Diagnostics

Export sanitized health, capability, and recent event data

Recent Bluetooth changes

{state.diagnostics.recentBluetooth.length > 0 ? state.diagnostics.recentBluetooth.map((event) => event.status).join(" → ") : "No Bluetooth changes recorded yet"}

Last disconnect

{state.diagnostics.lastDisconnect ? `${state.diagnostics.lastDisconnect.detail ?? state.diagnostics.lastDisconnect.status}` : "No disconnect recorded yet"}

Recent errors

{state.diagnostics.recentErrors.length > 0 ? state.diagnostics.recentErrors.map((event) => event.detail ?? event.status).join(" · ") : "No recent errors"}

@@ -403,15 +404,15 @@ function SetupGuide({ state, switches, suspended, busy, error, skip, finish, acc {error &&
{error}
}
{step === 0 &&
-

Input access

{state.accessibility === "required" && }
+

Input access

{state.accessibility === "required" && }
} {step === 1 && } {step === 2 &&

Use your switches

Focus the application you want to use, then press and release Select to start scanning. Select chooses a point and opens the action menu.

Automatic scanning moves the highlight for you. Use Auto scan rate for grid and menu timing, and Line speed for scanning lines. Holding a switch freezes movement; release runs the action shown.

Escape disables switch control. Assign Stop scanning or Pause / resume in Switches. Holding any switch for {switches.state ? switches.state.escapeHoldMs / 1000 : 4}s disables switch control.

Open Scanning after setup to adjust movement. Mobile connection is optional.

} - {step === 3 &&

Choose startup behavior

Switchify can start quietly when you sign in, ready for your switches.

} - {step === 4 &&

Choose whether to share diagnostics

Optional anonymous app health and sanitized errors help improve Switchify. Typed text, commands, pairing secrets, device names, and full paths are never included.

Privacy policy
} + {step === 3 &&

Choose startup behavior

Switchify can start quietly when you sign in, ready for your switches.

} + {step === 4 &&

Choose whether to share diagnostics

Optional anonymous app health and sanitized errors help improve Switchify. Typed text, commands, pairing secrets, device names, and full paths are never included.

Privacy policy
}

{navigationGuidance}

-
{step === 1 && }
+
{step === 1 && }
; } @@ -473,7 +474,7 @@ function PairingDialog({ requests, connectedDeviceName, busy, error, approve, re return

{request.deviceName}

Verification code

{request.verificationCode} -
+
; })}
@@ -721,8 +722,8 @@ export function App() { useEffect(() => { if (view === "profiles") void api.listProfiles().then(setProfiles).catch((reason) => setError(String(reason))); }, [view]); const nav = useMemo(() => [ - ["home", "Home", ], ["switches", "Switches", ], ["scanning", "Scanning", ], ["mobile", "Mobile connection", ], ["settings", "Settings", ], - ["support", "Support", ], + ["home", "Home", ], ["switches", "Switches", ], ["scanning", "Scanning", ], ["mobile", "Mobile", ], ["settings", "Settings", ], + ["support", "Help", ], ] as const, []); const selectView = (next: View) => { @@ -772,13 +773,13 @@ export function App() { return 0 || switches.capturing || !!switches.state?.capture.active}>
0 || switches.capturing || !!switches.state?.capture.active}>
0}> - {error &&
{error}
} + {error &&
{error}
}

{updateNotice}

{view === "home" && void perform(api.disconnectAll)} onAccessibility={() => void perform(() => api.checkAccessibility(true))} />} {view === "switches" &&

Switches

} {view === "scanning" &&

Scanning

} - {(view === "mobile" || view === "devices" || view === "profiles") &&

Mobile connection

Optional mobile control and switch forwarding

+ {(view === "mobile" || view === "devices" || view === "profiles") &&

Mobile

{view === "mobile" && void perform(api.disconnectAll)} />} {view === "devices" && void perform(() => api.forgetDevice(id))} />} {view === "profiles" && { profileEditorDirty.current = dirty; }} nativeExitRequest={profileExitRequest} onConfirmNativeExit={confirmProfileExit} onCancelNativeExit={cancelProfileExit} />} diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index 0db1eb37..728a8f4e 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -12,6 +12,10 @@ import { type PointScanState, } from "./scanning/useScanning"; import { ScanningSection } from "./settings/ScanningSection"; +function more() { + const button = screen.getByRole("button", { name: "More options" }); + if (button.getAttribute("aria-expanded") !== "true") fireEvent.click(button); +} function PointScan() { const controller = useScanning(); return ; @@ -48,6 +52,7 @@ afterEach(() => { it("shows the backend's reason while scanning is off and offers no toggle", async () => { render(); await screen.findByText("Scanning starts once a switch has the Select action."); + more(); fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); expect(screen.getByRole("button", { name: "Line only" })).toBeEnabled(); }); @@ -60,6 +65,7 @@ it("saves settings without an enabled flag and keeps them editable while scannin ); render(); await screen.findByText("Ready to begin."); + more(); fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); await waitFor(() => @@ -92,6 +98,7 @@ it("keeps newer edits across old events", async () => { resolveSave = resolve; }), ); + more(); fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); await waitFor(() => expect(resolveSave).toBeTypeOf("function")); @@ -109,13 +116,14 @@ it("keeps newer edits across old events", async () => { config: { ...initial.config, mode: "grid", gridSize: 7 }, }), ); - await screen.findByText("Scanning settings save automatically."); + await screen.findByText("Saved automatically."); }); it("retains failed edits and offers a retry", async () => { render(); await screen.findByText(initial.message); mocks.invoke.mockRejectedValueOnce("Disk is full."); + more(); fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); await screen.findByText("Disk is full."); @@ -124,7 +132,7 @@ it("retains failed edits and offers a retry", async () => { ).toHaveAttribute("aria-pressed", "true"); expect(screen.getByText("Scanning settings have unsaved changes.")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Retry save" })); - await screen.findByText("Scanning settings save automatically."); + await screen.findByText("Saved automatically."); }); it("keeps pending saves when its settings panel unmounts", async () => { @@ -145,6 +153,7 @@ it("keeps pending saves when its settings panel unmounts", async () => { resolveSave = resolve; }), ); + more(); fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); await waitFor(() => expect(resolveSave).toBeTypeOf("function")); @@ -153,6 +162,7 @@ it("keeps pending saves when its settings panel unmounts", async () => { resolveSave({ ...initial, config: { ...initial.config, mode: "grid" } }), ); view.rerender(); + more(); fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); expect( screen.getByRole("button", { name: "Grid then line" }), @@ -169,6 +179,7 @@ it("keeps a newer runtime event when an older save response arrives", async () = resolveSave = resolve; }), ); + more(); fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); await waitFor(() => expect(resolveSave).toBeTypeOf("function")); @@ -213,7 +224,9 @@ it.each([ it("saves scanner colour and updates the sample", async () => { render(); await screen.findByText(initial.message); + more(); expect(screen.getByRole("radio", { name: "Blue" })).toBeChecked(); + more(); fireEvent.click(screen.getByRole("radio", { name: "Green" })); expect(screen.getByRole("img", { name: "green scanner highlight sample" })).toBeInTheDocument(); await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { @@ -225,6 +238,7 @@ it("retains a failed colour selection for retry", async () => { render(); await screen.findByText(initial.message); mocks.invoke.mockRejectedValueOnce(new Error("Cannot save colour")); + more(); fireEvent.click(screen.getByRole("radio", { name: "White" })); expect(await screen.findByRole("button", { name: "Retry save" })).toBeEnabled(); expect(screen.getByRole("radio", { name: "White" })).toBeChecked(); @@ -237,8 +251,10 @@ it("retains a failed colour selection for retry", async () => { it("saves auto selection separately from scan movement and validates its delay", async () => { render(); await screen.findByText(initial.message); + more(); fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); expect(screen.queryByRole("spinbutton", { name: "Auto select delay (seconds)" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Click options" })); fireEvent.click(screen.getByRole("checkbox", { name: "Auto select" })); const delay = screen.getByRole("spinbutton", { name: "Auto select delay (seconds)" }); expect(delay).toHaveValue(1); @@ -255,6 +271,7 @@ it("saves auto selection separately from scan movement and validates its delay", it("customises individual settings, restores defaults and returns focus without saving on navigation", async () => { render(); await screen.findByText(initial.message); + more(); fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" })); expect(mocks.invoke).toHaveBeenCalledTimes(1); expect(screen.getByRole("heading", { name: "Keyboard" })).toHaveFocus(); @@ -269,6 +286,7 @@ it("customises individual settings, restores defaults and returns focus without fireEvent.click(screen.getByRole("button", { name: "Back to scanning settings" })); expect(screen.getByRole("button", { name: "Customise keyboard" })).toHaveFocus(); fireEvent.click(screen.getByRole("button", { name: "Reverse" })); + more(); fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" })); expect(screen.getByRole("button", { name: "Reverse" })).toHaveAttribute("aria-pressed", "true"); expect(screen.queryByRole("button", { name: "Use default for initial direction" })).not.toBeInTheDocument(); @@ -277,6 +295,7 @@ it("customises individual settings, restores defaults and returns focus without it("keeps rate values in manual mode and preserves keyboard settings when resetting overrides", async () => { render(); await screen.findByText(initial.message); + more(); fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" })); fireEvent.click(screen.getByRole("checkbox", { name: "Word prediction" })); fireEvent.click(screen.getByRole("button", { name: "Decrease auto scan interval by 0.1 seconds" })); @@ -300,10 +319,12 @@ it("restores overview scroll and leaves point-specific values intact on override render(); await screen.findByText(initial.message); document.documentElement.scrollTop = 380; + more(); fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" })); expect(screen.queryByRole("button", { name: "One item at a time" })).not.toBeInTheDocument(); expect(screen.queryByRole("checkbox", { name: "Word prediction" })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); + fireEvent.click(screen.getByRole("button", { name: "Click options" })); fireEvent.click(screen.getByRole("checkbox", { name: "Auto select" })); fireEvent.click(screen.getByRole("button", { name: "Reverse" })); fireEvent.click(screen.getByRole("button", { name: "Reset scanning overrides" })); @@ -330,7 +351,8 @@ it("brings the area header into view with help expanded and restores the overvie fireEvent.click(screen.getByText("How scanning works")); expect(screen.getByText("How scanning works").closest("details")).toHaveAttribute("open"); document.documentElement.scrollTop = 640; - fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" })); + more(); + fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" })); const heading = screen.getByRole("heading", { name: "Keyboard" }); expect(heading).toHaveFocus(); expect(scrollIntoView).toHaveBeenCalledExactlyOnceWith({ block: "start", behavior: "instant" }); @@ -353,6 +375,7 @@ it("saves the keyboard after-typing choice and preserves it while manual", async render(); await screen.findByText(initial.message); expect(screen.queryByText("After typing")).not.toBeInTheDocument(); + more(); fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" })); expect(screen.getByRole("button", { name: "Continue scanning" })).toHaveAttribute("aria-pressed", "true"); fireEvent.click(screen.getByRole("button", { name: "Wait for Select" })); diff --git a/src/Switches.test.tsx b/src/Switches.test.tsx index f7e4d7af..3c55c427 100644 --- a/src/Switches.test.tsx +++ b/src/Switches.test.tsx @@ -112,6 +112,10 @@ beforeEach(() => { }); }); afterEach(() => Reflect.deleteProperty(window, "__TAURI_INTERNALS__")); +const revealMobile = async () => { + const button = await screen.findByRole("button", { name: "Mobile switches" }); + if (button.getAttribute("aria-expanded") !== "true") fireEvent.click(button); +}; const open = (name: string) => fireEvent.click(screen.getByRole("button", { name: `Edit ${name}` })); it("shows each switch as a summary row and expands one to edit", async () => { @@ -210,6 +214,7 @@ it("shows hold timing computed from the interval", async () => { expect( screen.getByText("Hold 1s for Next, 2s for Stop scanning. Release to run the action shown."), ).toBeTruthy(); + fireEvent.click(screen.getAllByRole("button", { name: "More options" })[0]); fireEvent.click(within(screen.getByRole("group", { name: "Hold action interval" })).getByRole("button", { name: "2s" })); await screen.findByText("Hold 2s for Next, 4s for Stop scanning. Release to run the action shown."); await screen.findByText(/Holding any switch for 8s resets the scan/); @@ -353,6 +358,7 @@ it("keeps focus in the draft when another switch is removed while adding", async }); it("lists remote switches with local ones and edits them in place", async () => { render(); + await revealMobile(); await screen.findByRole("heading", { name: "Remote switch 1" }); expect(screen.getByText("Remote 1")).toBeTruthy(); open("Remote switch 1"); @@ -368,6 +374,7 @@ it("lists remote switches with local ones and edits them in place", async () => }); it("adds a remote switch into the next free slot and can move it", async () => { render(); + await revealMobile(); await screen.findByRole("heading", { name: "Remote switch 1" }); fireEvent.click(screen.getByRole("button", { name: "Add remote switch" })); expect(screen.queryByRole("dialog")).toBeNull(); @@ -387,6 +394,7 @@ it("adds a remote switch into the next free slot and can move it", async () => { }); it("moves focus into the remote draft and back to a usable Add button", async () => { render(); + await revealMobile(); await screen.findByRole("heading", { name: "Remote switch 1" }); fireEvent.click(screen.getByRole("button", { name: "Add remote switch" })); expect(document.activeElement).toBe(screen.getByLabelText("New switch name")); @@ -395,6 +403,7 @@ it("moves focus into the remote draft and back to a usable Add button", async () }); it("removes a remote switch and surfaces a failed remote save with retry", async () => { render(); + await revealMobile(); await screen.findByRole("heading", { name: "Remote switch 1" }); mocks.invoke.mockImplementationOnce(async () => { throw "Remote save failed"; @@ -481,6 +490,7 @@ it("offers recovery when returning to Switches with keyboard typing still active it.each([["Remote switch 1", 0], ["Remote switch 1", 1], ["Head switch", 0], ["Head switch", 1]] as const)("releases keyboard entry when %s is removed using control %s", async (name, index) => { render(); + if (name.startsWith("Remote")) await revealMobile(); fireEvent.click(await screen.findByRole("button", { name: `Edit ${name}` })); fireEvent.click(screen.getByRole("button", { name: "Type with keyboard" })); await screen.findByRole("button", { name: "Resume switch control" }); @@ -512,6 +522,7 @@ it("keeps an edited local draft and restores focus after cancelling discard", as }); it("requires discard after changing a remote draft action, while untouched cancellation is immediate", async () => { render(); + await revealMobile(); await screen.findByRole("heading", { name: "Remote switch 1" }); fireEvent.click(screen.getByRole("button", { name: "Add remote switch" })); fireEvent.change(screen.getByLabelText("New switch action"), { target: { value: "next" } }); @@ -526,6 +537,7 @@ it("requires discard after changing a remote draft action, while untouched cance }); it.each(["Head switch", "Remote switch 1"])("protects both removal controls for %s", async (name) => { render(); + if (name.startsWith("Remote")) await revealMobile(); await screen.findByRole("heading", { name }); const remove = screen.getByRole("button", { name: `Remove ${name}` }); remove.focus(); @@ -558,6 +570,7 @@ it("hands remote draft focus to the committed input before passive work can run" return children; } const view = render(); + await revealMobile(); await screen.findByRole("heading", { name: "Remote switch 1" }); const add = screen.getByRole("button", { name: "Add remote switch" }); add.focus(); @@ -584,6 +597,7 @@ it.each(["remove", "discard"])("restores usable focus after confirmed %s with br }); try { render(); + await revealMobile(); await screen.findByRole("heading", { name: "Remote switch 1" }); if (operation === "remove") { fireEvent.click(screen.getByRole("button", { name: "Remove Head switch" })); @@ -605,6 +619,7 @@ it("separates an empty local configuration from the six remote presets", async ( remote.slots = ["select", "next", "back", "pause", "reverse", "stop", null, null] .map((pressAction) => ({ pressAction, holdActions: [] })); render(); + await revealMobile(); await screen.findByRole("heading", { name: "Remote switch 6" }); expect(screen.getByText(/No local switches configured/)).toBeInTheDocument(); expect(screen.getByText(/not detected physical switches/)).toBeInTheDocument(); @@ -614,6 +629,7 @@ it("separates an empty local configuration from the six remote presets", async ( }); it("keeps source identity after renaming and separates connection from tested input", async () => { const view = render(); + await revealMobile(); await screen.findByRole("heading", { name: "Remote switch 1" }); open("Remote switch 1"); fireEvent.change(screen.getByLabelText("Name for Remote 1"), { target: { value: "Chin" } }); diff --git a/src/Tabs.tsx b/src/Tabs.tsx index 556a767e..ee331655 100644 --- a/src/Tabs.tsx +++ b/src/Tabs.tsx @@ -1,3 +1,4 @@ +import { Button } from "./ui/controls"; import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; // `attention` is the reason a tab needs it. It adds a visual marker without @@ -39,7 +40,7 @@ export function Tabs({ name, tabs, active, onSelect, label }: return <>
- {tabs.map((tab, index) => )} + >{tab.label}{tab.attention &&
{/* Outside the tablist so the reason never becomes part of a tab's name. */} {tabs.filter((tab) => tab.attention).map((tab) => {tab.attention})} diff --git a/src/help/Demonstration.test.tsx b/src/help/Demonstration.test.tsx index e1e26286..3cdea6fd 100644 --- a/src/help/Demonstration.test.tsx +++ b/src/help/Demonstration.test.tsx @@ -6,7 +6,7 @@ import { demonstration, type DemonstrationKind } from "./demonstrations"; let reduced = false; let mediaChange: (() => void) | undefined; const advance = (ms = 3050) => act(() => { vi.advanceTimersByTime(ms); }); -const open = (title: string) => fireEvent.click(screen.getByRole("button", { name: `Show demonstration: ${title}` })); +const open = (title: string) => fireEvent.click(screen.getByRole("button", { name: `Show me how: ${title}` })); const caption = () => document.querySelector(".teaching-caption")!.textContent; beforeEach(() => { @@ -39,7 +39,7 @@ describe("teaching demonstrations", () => { expect(vi.getTimerCount()).toBe(0); fireEvent.click(screen.getByRole("button", { name: "Replay Learn a switch key" })); expect(caption()).toMatch(/^1 \/ 3/); - fireEvent.click(screen.getByRole("button", { name: "Hide demonstration: Learn a switch key" })); + fireEvent.click(screen.getByRole("button", { name: "Hide: Learn a switch key" })); expect(document.querySelector("svg")).toBeNull(); expect(vi.getTimerCount()).toBe(0); }); @@ -51,7 +51,7 @@ describe("teaching demonstrations", () => { expect(vi.getTimerCount()).toBe(0); expect(screen.queryByRole("button", { name: /^Pause/ })).toBeNull(); for (let i = 0; i < 4; i++) fireEvent.click(screen.getByRole("button", { name: /Next illustration/ })); - expect(caption()).toContain("Release to execute"); + expect(caption()).toContain("Release to run it."); expect(screen.getByRole("button", { name: /Next illustration/ })).toBeDisabled(); act(() => { reduced = false; mediaChange?.(); }); fireEvent.click(screen.getByRole("button", { name: /Replay/ })); @@ -98,7 +98,7 @@ describe("teaching demonstrations", () => { it("keeps SVG definition IDs unique when the same artwork appears twice", () => { render(<>); - for (const button of screen.getAllByRole("button", { name: /Show demonstration/ })) fireEvent.click(button); + for (const button of screen.getAllByRole("button", { name: /Show me how/ })) fireEvent.click(button); const ids = [...document.querySelectorAll("svg [id]")].map(el => el.id); expect(ids.length).toBeGreaterThan(0); expect(new Set(ids).size).toBe(ids.length); @@ -110,17 +110,18 @@ describe("teaching demonstrations", () => { const view = render(content("windows")); expect(screen.queryByRole("button")).toBeNull(); view.rerender(content("macos")); open("Allow Accessibility on macOS"); + fireEvent.click(screen.getByRole("button", { name: "Read steps: Allow Accessibility on macOS" })); expect(screen.getByRole("list")).toHaveTextContent("return to the app"); }); it.each(["jack", "usb", "learn", "select", "grid", "line", "hold", "access", "pair", "startup"] as DemonstrationKind[])("renders every illustrated step for %s", kind => { reduced = true; render(); - const { title, steps } = demonstration(kind, "macos"); + const { title, steps, captions } = demonstration(kind, "macos"); open(title); for (let i = 0; i < steps.length; i++) { expect(document.querySelector("svg")).not.toBeNull(); - expect(caption()).toContain(steps[i]); + expect(caption()).toContain(captions[i]); if (i < steps.length - 1) fireEvent.click(screen.getByRole("button", { name: /Next illustration/ })); } if (kind === "pair") expect(document.querySelectorAll(".pair-code-phone, .pair-code-pc")).toHaveLength(2); diff --git a/src/help/Demonstration.tsx b/src/help/Demonstration.tsx index 3e396d84..cc91600c 100644 --- a/src/help/Demonstration.tsx +++ b/src/help/Demonstration.tsx @@ -1,3 +1,4 @@ +import { Button, MoreOptions } from "../ui/controls"; import { createContext, useContext, useEffect, useId, useRef, useState, type ReactNode } from "react"; import { demonstration, type DemonstrationKind, type DemoPlatform } from "./demonstrations"; import { PrototypeArtwork } from "./PrototypeArtwork"; @@ -25,7 +26,7 @@ function useReducedMotion() { function Player({ kind }: { kind: DemonstrationKind }) { const { suspended, platform } = useContext(DemoContext); - const { title, steps } = demonstration(kind, platform); + const { title, steps, captions } = demonstration(kind, platform); const reduced = useReducedMotion(); const [step, setStep] = useState(0); const [progress, setProgress] = useState(0); @@ -69,13 +70,13 @@ function Player({ kind }: { kind: DemonstrationKind }) {
{kind === "grid" || kind === "line" || kind === "hold" ? : }
-

{step + 1} / {steps.length}: {steps[step]}

+

{step + 1} / {steps.length}: {captions[step]}

- {!reduced && <>} - {reduced && <>} + {!reduced && <>} + {reduced && <>}
{reduced &&

Reduced motion: use the buttons to view each still illustration.

} -
    {steps.map((text, index) =>
  1. {text}
  2. )}
+
    {steps.map((text, index) =>
  1. {text}
  2. )}
; } @@ -86,7 +87,7 @@ export function Demonstration({ kind }: { kind: DemonstrationKind }) { if (kind === "access" && platform !== "macos") return null; const { title } = demonstration(kind, platform); return
- + {open &&
}
; } diff --git a/src/help/demonstrations.css b/src/help/demonstrations.css index 34211840..f1b309bf 100644 --- a/src/help/demonstrations.css +++ b/src/help/demonstrations.css @@ -1,3 +1,4 @@ +@layer components { .teaching-disclosure { margin: 12px 0; } .teaching-disclosure > button { text-align: left; white-space: normal; } .teaching-demo { margin-top: 12px; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); max-width: 760px; } @@ -8,9 +9,11 @@ .teaching-art svg text[fill="#fff"] { fill: #fff; } .teaching-art svg g, .teaching-art svg circle, .teaching-art svg rect { transition: transform 500ms ease, opacity 500ms ease, cy 500ms ease, cx 500ms ease, fill 500ms ease; } .teaching-caption { line-height: 1.5; } -.teaching-controls { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; } -.teaching-steps { padding-left: 24px; font-size: 13px; line-height: 1.6; } +.teaching-controls { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0; } +.teaching-steps { padding-left: 24px; font-size: 16px; line-height: 1.6; } .teaching-steps li { margin: 8px 0; } .teaching-steps li[aria-current="step"] { font-weight: 600; } .teaching-demo[data-moving="false"] svg *, .teaching-demo[data-reduced="true"] svg * { transition: none; } @media (prefers-reduced-motion: reduce) { .teaching-art svg * { transition: none; } } + +} diff --git a/src/help/demonstrations.ts b/src/help/demonstrations.ts index ae7ab1de..7965aae0 100644 --- a/src/help/demonstrations.ts +++ b/src/help/demonstrations.ts @@ -15,5 +15,17 @@ export function demonstration(kind: DemonstrationKind, platform: DemoPlatform) { pair: { title: "Pair a mobile device", steps: ["Open Switchify on your mobile device and choose this computer while it is advertising.", "Compare the six-digit codes in both apps. These are example codes, not a live pairing request.", "Approve on this computer only if the codes match. If they differ or you did not request pairing, reject the request.", "A successful pairing trusts the mobile device. Check the real connection status in the app."] }, startup: { title: "Understand startup behavior", steps: ["Start with system is optional. Choose whether Switchify should launch when you sign in.", `When enabled, Switchify starts quietly in the ${location}.`, `Open Switchify from the ${location} when you need its window. Start manually means opening the app yourself.`] }, }; - return examples[kind]; + const captions: Record = { + jack: ["Check the connector.", "Plug in your switch.", "Connect the interface. Then add your switch."], + usb: ["Your switch sends a key.", "Plug it into your computer.", "Add your switch and learn its key."], + learn: ["Choose Add switch, then Learn key.", "Press and release your switch.", "Check the key shown."], + select: ["Open Press and release.", "Choose Select.", "Save your switch."], + grid: ["Press Select to start.", "Select a row.", "Select a cell.", "Select the horizontal position.", "Select the vertical position.", "Auto select: wait to click, or press for actions.", "Without Auto select: choose an action."], + line: ["Press Select to start.", "Select the horizontal position.", "Select the vertical position.", "Auto select: wait to click, or press for actions.", "Without Auto select: choose an action."], + hold: ["Press to freeze movement.", "Release to choose.", "Hold to see more actions.", "Wait for the action you want.", "Release to run it."], + access: ["Open Accessibility Settings.", "Find Switchify PC.", "Turn it on, then return here."], + pair: ["Choose this computer on your mobile.", "Compare the codes in both apps.", "Approve only if the codes match.", "Check the connection status."], + startup: ["Choose whether to start at sign-in.", `Switchify starts in the ${location}.`, `Open its window from the ${location}.`], + }; + return { ...examples[kind], captions: captions[kind] }; } diff --git a/src/scanning/SwitchPractice.tsx b/src/scanning/SwitchPractice.tsx index c209fe1c..420e60cb 100644 --- a/src/scanning/SwitchPractice.tsx +++ b/src/scanning/SwitchPractice.tsx @@ -1,3 +1,4 @@ +import { Button, Select } from "../ui/controls"; import { createPortal } from "react-dom"; import { useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; @@ -56,9 +57,9 @@ export function SwitchPractice({ disabled = false }: { disabled?: boolean }) { }; const highlighted = view?.blocks.filter(block => block.selected).map(block => block.label).join(", "); return
-

Test your saved switches safely with a simple set of practice blocks. Select starts a scan; Next and Previous move the highlight.

- - +

Try your saved switches safely.

+ + {!open && error &&

{error}

} {open && createPortal(
{ e.stopPropagation(); @@ -78,7 +79,7 @@ export function SwitchPractice({ disabled = false }: { disabled?: boolean }) {

Highlighted: {highlighted || "None — use Select to start"}

{error &&

{error}

} -
+
, document.body)}
; } diff --git a/src/settings/CursorSection.tsx b/src/settings/CursorSection.tsx index 3d7f5708..6270c99f 100644 --- a/src/settings/CursorSection.tsx +++ b/src/settings/CursorSection.tsx @@ -1,3 +1,4 @@ +import { Input } from "../ui/controls"; import type { AppSettings } from "../types"; import { OptionGroup, SettingGroup, Toggle, overlaySizeOptions, overlayVisibilityOptions, @@ -10,9 +11,9 @@ export function CursorSection({ settings, update }: { settings: AppSettings; upd
legend="Overlay visibility" disabled={!settings.cursorOverlayEnabled} options={overlayVisibilityOptions} value={settings.cursorOverlayVisibility} onChange={(next) => update("cursorOverlayVisibility", next)} note={{ about: "overlay visibility", summary: "Choose when the overlay stays on screen.", detail: "On input hides shortly after pointer activity stops. While controlling stays visible until the session ends." }} /> - legend="Overlay size" columns="three" disabled={!settings.cursorOverlayEnabled} options={overlaySizeOptions} value={settings.cursorOverlaySize} onChange={(next) => update("cursorOverlaySize", next)} /> + legend="Cursor size" columns="three" disabled={!settings.cursorOverlayEnabled} options={overlaySizeOptions} value={settings.cursorOverlaySize} onChange={(next) => update("cursorOverlaySize", next)} />
Overlay color
- {(["red", "green", "blue", "yellow", "white"] as const).map((value) => )} + {(["red", "green", "blue", "yellow", "white"] as const).map((value) => )}
update("cursorCrosshairs", value)} />
diff --git a/src/settings/PointerSection.tsx b/src/settings/PointerSection.tsx index b5eab0a6..4a309485 100644 --- a/src/settings/PointerSection.tsx +++ b/src/settings/PointerSection.tsx @@ -1,3 +1,4 @@ +import { Button, Select, MoreOptions } from "../ui/controls"; import { useEffect, useId, useState } from "react"; import type { AppSettings } from "../types"; import { @@ -14,20 +15,27 @@ export function PointerSection({ settings, update }: { settings: AppSettings; up const [showExact, setShowExact] = useState(!isPreset); useEffect(() => { if (!isPreset) setShowExact(true); }, [isPreset]); const exactSpeedId = useId(); - return + return
Pointer speed {settings.pointerScalePercent}%
- {pointerSpeedOptions.map((value) => )} + {pointerSpeedOptions.map((value) => )}
setShowExact(!showExact)} controls={exactSpeedId}>
- +
{([{"label":"Small","base":4.5},{"label":"Medium","base":12},{"label":"Large","base":26}] as const).map(({ label, base }) =>
{label}{movementValue(base, settings.pointerScalePercent)}
)}
-
+
+ update("dwellClickEnabled", value)} /> +
+ legend="Wait before clicking" columns="five" disabled={!settings.dwellClickEnabled} options={secondsOptions(dwellDelayOptions)} value={settings.dwellClickDelayMs} onChange={(next) => update("dwellClickDelayMs", next)} + note={{ summary: "After mobile pointer movement stops, a countdown appears and performs one left click. Move again to rearm it." }} /> +
+
+
update("mouseRepeatEnabled", value)} />
legend="Movement interval" columns="four" disabled={!settings.mouseRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.moveRepeatIntervalMs} onChange={(next) => update("moveRepeatIntervalMs", next)} /> @@ -43,12 +51,6 @@ export function PointerSection({ settings, update }: { settings: AppSettings; up note={{ about: "key repeat", summary: "Held navigation keys repeat, like on a keyboard.", detail: "Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down." }} />
-
- update("dwellClickEnabled", value)} /> -
- legend="Dwell delay" columns="five" disabled={!settings.dwellClickEnabled} options={secondsOptions(dwellDelayOptions)} value={settings.dwellClickDelayMs} onChange={(next) => update("dwellClickDelayMs", next)} - note={{ summary: "After mobile pointer movement stops, a countdown appears and performs one left click. Move again to rearm it." }} /> -
-
+
; } diff --git a/src/settings/PrivacySection.tsx b/src/settings/PrivacySection.tsx index 71216857..9bfba8dc 100644 --- a/src/settings/PrivacySection.tsx +++ b/src/settings/PrivacySection.tsx @@ -1,6 +1,7 @@ +import { Button } from "../ui/controls"; import type { AppSettings, AppState } from "../types"; import { SettingGroup, Toggle, type SettingsUpdate } from "./controls"; export function PrivacySection({ state, settings, update, chooseTelemetry, busy }: { state: AppState; settings: AppSettings; update: SettingsUpdate; chooseTelemetry: (enabled: boolean) => void; busy: boolean }) { - return update("shareDiagnostics", value)} />{state.telemetry.consent === "undecided" &&
}

{state.telemetry.available ? state.telemetry.consent === "undecided" ? "No choice recorded yet. Nothing is sent unless you choose Share diagnostics." : state.telemetry.consent === "enabled" ? "Consent recorded. You can turn this off at any time to delete queued reports." : "Opted out. No diagnostic reports are stored or sent." : "Diagnostic reporting is unavailable in this build."} Privacy policy

; + return update("shareDiagnostics", value)} />{state.telemetry.consent === "undecided" &&
}

{state.telemetry.available ? state.telemetry.consent === "undecided" ? "No choice recorded yet. Nothing is sent unless you choose Share diagnostics." : state.telemetry.consent === "enabled" ? "Consent recorded. You can turn this off at any time to delete queued reports." : "Opted out. No diagnostic reports are stored or sent." : "Diagnostic reporting is unavailable in this build."} Privacy policy

; } diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index f23486c3..18260d5f 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -1,3 +1,4 @@ +import { Button, Input, Select, MoreOptions } from "../ui/controls"; import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import type { ScanningController, ScannerColor, PointScanConfig } from '../scanning/useScanning'; import { areaOptions, sharedOptions, defaultScanPreferences, type ScanArea, type ScanOptions } from '../scanning/preferences'; @@ -50,37 +51,23 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl const custom = area !== 'shared' && settings[area][key] != null; return
{area !== 'shared' &&
{custom ? 'Custom' : 'Default'} - {custom && } + {custom && }
} {children(disabled)}
; }; return
- {area === 'shared' &&
-

Customise an area

Give an area different settings, or keep using your defaults.

-
{(['point', 'menu', 'keyboard'] as const).map(key => { - const options = areaOptions(config, key); - const count = Object.entries(settings[key]).filter(([name, value]) => value != null && !(key === 'point' && name === 'pattern')).length; - return