# 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).