diff --git a/docs/plans/2026-05-08-browserlay-mvp-design.md b/docs/plans/2026-05-08-browserlay-mvp-design.md new file mode 100644 index 0000000..8d101fd --- /dev/null +++ b/docs/plans/2026-05-08-browserlay-mvp-design.md @@ -0,0 +1,261 @@ +# Browserlay Phase 1 (MVP) — Design + +Date: 2026-05-08 +Status: Approved, ready for implementation planning + +## Goal + +Ship a Windows desktop app that creates a translucent, always-on-top browser overlay. The user types a URL, clicks Open, and gets a frameless transparent window pinned over their other apps. Real-time opacity, always-on-top, and click-through controls. A global hotkey (`Ctrl+Alt+Space`) is the recovery path out of click-through mode. + +## Stack (already scaffolded) + +- Tauri 2.x (Rust backend, single process) +- React 19 + TypeScript + Vite +- Tailwind CSS v4 (dark-mode only, charcoal palette via CSS variables) +- `lucide-react` for icons +- Bundle id `com.chihlas.browserlay` + +## Architecture + +Two top-level webview windows in a single Tauri process. + +``` +┌─────────────────────────────┐ ┌──────────────────────────────┐ +│ Control Window (`main`) │ │ Overlay Window (`overlay`) │ +│ React app, Tailwind v4 │ │ Tauri WebviewWindow │ +│ │ spawns │ • frameless │ +│ • URL input │ ───────►│ • transparent │ +│ • Opacity slider │ │ • alwaysOnTop=true │ +│ • AOT toggle │ │ • loads directly │ +│ • Click-through toggle │ drives │ • no React shell on top │ +│ • Open / Close button │ ◀──────►│ │ +└─────────────────────────────┘ └──────────────────────────────┘ + │ ▲ + │ direct calls via WebviewWindow.getByLabel │ + │ .setOpacity / .setAlwaysOnTop / │ + │ .setIgnoreCursorEvents │ + └────────────────────────────────────────────┘ + + ┌──────────────────────────────────────┐ + │ Global shortcut (Ctrl+Alt+Space) │ + │ Rust handler → toggles click-through│ + │ + opacity pulse on overlay window │ + └──────────────────────────────────────┘ +``` + +**Key decisions** (made during brainstorming): + +1. Overlay creation lives in JS via `WebviewWindow`. No Rust command for window creation. +2. State management: Zustand from day one (anticipating Phase 2 presets/hotkeys). +3. Persistence: debounced (~300ms) writes to `tauri-plugin-store` on every change. +4. Overlay loads the external URL directly. No in-overlay React shell or toolbar in Phase 1 — controls live entirely on the control panel + the global hotkey. (In-overlay toolbar deferred to Phase 2.) +5. Click-through hotkey is **toggle** (press to flip), with **opacity-pulse** feedback (~180ms dip and restore). +6. URL validation: forgiving (prepends `https://` if scheme missing) but only `http`/`https` allowed. `tauri://`, `file://`, `javascript:`, `data:` rejected. +7. Click-through hotkey handler lives in Rust, not JS — global shortcut runs regardless of focus and Rust is the reliable handler path. + +## Frontend structure + +``` +src/ +├── main.tsx # React entry, mounts +├── App.tsx # Layout shell only +├── index.css # Tailwind v4 import + :root design tokens +├── components/ +│ ├── ControlPanel.tsx # Top-level form layout +│ ├── UrlField.tsx # URL input + validation feedback +│ ├── OpacitySlider.tsx # Slider + numeric readout +│ ├── ToggleRow.tsx # Reusable toggle (label + switch + hint) +│ ├── OpenOverlayButton.tsx # Primary action (Open / Close) +│ └── StatusBar.tsx # Footer: hotkey hint, error states +├── hooks/ +│ └── useClickThroughSync.ts# Subscribes to Rust 'click-through-toggled' event +├── lib/ +│ ├── store.ts # Zustand store +│ ├── overlay.ts # Wrapper over WebviewWindow APIs +│ ├── url.ts # normalizeUrl + isAllowedUrl +│ └── persist.ts # tauri-plugin-store load/save with debounce +└── types/ + └── overlay.ts # OverlayState, OverlayActions +``` + +### Zustand store + +```ts +type OverlayState = { + url: string; + opacity: number; // 0.1 – 1.0 + alwaysOnTop: boolean; + clickThrough: boolean; + isOpen: boolean; // overlay window currently exists + urlError: string | null; +}; + +type OverlayActions = { + setUrl: (raw: string) => void; + setOpacity: (v: number) => void; + setAlwaysOnTop: (v: boolean) => void; + setClickThrough: (v: boolean) => void; + _setClickThroughFromBackend: (v: boolean) => void; // silent setter, no apply + openOverlay: () => Promise; + closeOverlay: () => Promise; +}; +``` + +### `lib/overlay.ts` (single Tauri-API boundary) + +``` +openOverlay(url, {opacity, alwaysOnTop, clickThrough}): Promise +closeOverlay(): Promise +applyOpacity(v): Promise +applyAlwaysOnTop(v): Promise +applyClickThrough(v): Promise +pulseOpacity(target): Promise +``` + +Each function looks up `WebviewWindow.getByLabel('overlay')`. If null, no-op — store still reflects state and reopening picks it up. + +### Live wiring + +Zustand subscribers (or `useEffect` per controllable property) watch the store and call the matching `applyX` function whenever values change. UI changes the store; store changes propagate. + +### Validation + +`setUrl(raw)` runs `normalizeUrl` → `isAllowedUrl`, sets `url` and `urlError` together. Open button disabled when `urlError` set or field empty. + +## Rust backend + +``` +src-tauri/src/ +├── main.rs # Unchanged — calls browserlay_lib::run() +├── lib.rs # Plugin registration + builder +└── commands/ + ├── mod.rs + └── overlay.rs # toggle_click_through + helpers +``` + +### Plugin registration (in `lib.rs`) + +- `tauri-plugin-store` +- `tauri-plugin-window-state` +- `tauri-plugin-global-shortcut` (with Rust handler) +- `tauri-plugin-opener` (already there from scaffold; keep) + +### Click-through toggle (`commands/overlay.rs`) + +A private `do_toggle(app: &AppHandle)` function is the single implementation. Both the global-shortcut handler and the `toggle_click_through` Tauri command delegate to it. + +`do_toggle` flow: + +1. Look up overlay window by label. If absent → emit `click-through-no-overlay` event, return. +2. Read current `ignore_cursor_events` state. +3. Flip via `set_ignore_cursor_events(!current)`. +4. Read current opacity. Pulse: `set_opacity(0.4)` → `tokio::time::sleep(180ms)` → `set_opacity(prev)`. +5. Emit `click-through-toggled` event with `{ clickThrough: bool }` payload. + +The control panel's `useClickThroughSync` listens to that event and calls `_setClickThroughFromBackend`, which updates the store *without* re-triggering the apply effect (avoiding ping-pong). + +### Capabilities + +`capabilities/default.json` — control window: + +```json +{ + "windows": ["main"], + "permissions": [ + "core:default", + "core:webview:allow-create-webview-window", + "core:window:allow-set-opacity", + "core:window:allow-set-always-on-top", + "core:window:allow-set-ignore-cursor-events", + "core:window:allow-close", + "store:default", + "global-shortcut:allow-register", + "global-shortcut:allow-unregister", + "global-shortcut:allow-is-registered" + ] +} +``` + +`capabilities/overlay.json` — overlay window only, minimal/empty allowlist. The loaded external page must not be able to call Tauri APIs. + +> Exact permission identifiers will be reconciled against current Tauri 2 docs at implementation time. + +## Persistence + +### `tauri-plugin-store` (user prefs) + +```json +{ + "url": "https://twitch.tv/somechannel", + "opacity": 0.65, + "alwaysOnTop": true, + "clickThrough": false +} +``` + +- On control-window mount: `lib/persist.ts` reads store and hydrates Zustand. Empty store → defaults. +- On any store change: debounced 300ms write back. `isOpen` and `urlError` not persisted. +- **Not** using Zustand persist middleware — using `tauri-plugin-store` directly to avoid hydration race. + +### `tauri-plugin-window-state` (geometry) + +Tracks position/size for both `main` and `overlay` automatically. `alwaysOnTop` would be saved by this plugin too, but our store is authoritative — on overlay create we explicitly call `setAlwaysOnTop(store.alwaysOnTop)` after geometry restore. + +### First-run defaults + +- `url: ""` (Open disabled until typed) +- `opacity: 1.0` +- `alwaysOnTop: true` +- `clickThrough: false` + +### First-open overlay placement + +Top-right of primary monitor, 800×600, 24px margin. After that, `tauri-plugin-window-state` remembers wherever the user dragged it. + +## Click-through escape hatch + +The safety-critical piece. + +- Hotkey: `Ctrl+Alt+Space`. Hardcoded in Phase 1. +- Registered at app startup via `tauri-plugin-global-shortcut` in Rust. +- Handler delegates to `do_toggle` (above). +- If overlay isn't open: no-op + brief toast on control panel. +- If hotkey registration fails (conflict with another app): surface in StatusBar — "⚠ Hotkey unavailable — click-through escape disabled". **Silent failure is the worst outcome; we explicitly surface this.** +- Phase 2 will add configurable hotkeys. + +## Testing & definition of done + +No automated tests in Phase 1 — most logic is OS integration where mocks would test mocks. Manual verification: + +1. Cold start: only control window opens. +2. URL validation: `twitch.tv/foo` → normalized; `not a url` → error + Open disabled; `tauri://`, `javascript:` rejected. +3. Overlay opens frameless, transparent, top-right of primary monitor. +4. Opacity slider 1.0 → 0.3 fades overlay in real time. +5. AOT off → other windows can cover overlay; AOT on → raised. +6. Click-through off: clicks on overlay scroll/click the loaded page. +7. Click-through on: clicks pass through to apps underneath. +8. With click-through on and our windows unfocused, `Ctrl+Alt+Space` flips it off; overlay opacity blinks; control toggle reflects new state. +9. Persistence: change URL/opacity/toggles → close → reopen → restored. +10. Geometry: drag overlay → close → reopen → position/size restored. + +Plus safety-net checks: + +- Hotkey conflict surfaces a warning rather than failing silently. +- Closing the overlay via taskbar X recovers correctly on next apply. + +**Ship criteria**: + +- All 10 checks pass on Windows 11. +- `npm run tauri build` produces a working installer. +- README updated: what the app does, dev setup, build, hotkey documented. +- `main` branch on origin (Gitea) tagged `v0.1.0`. + +## Out of scope for Phase 1 + +Tray icon, presets, configurable hotkeys, edge snap, multi-monitor placement UI, in-overlay toolbar, auto-update. All deferred to Phase 2. + +## Workflow notes + +- Conventional Commits (`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`). +- Frequent commits per meaningful chunk. +- Push to `origin/main` after user approves each chunk (will check before pushing the first few times — pushes are externally visible).