Captures the brainstormed design for the two-window overlay app: architecture, frontend structure, Rust backend split, persistence model, click-through escape hatch, and the manual verification checklist that defines "Phase 1 done." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
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-reactfor 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 <user URL> 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):
- Overlay creation lives in JS via
WebviewWindow. No Rust command for window creation. - State management: Zustand from day one (anticipating Phase 2 presets/hotkeys).
- Persistence: debounced (~300ms) writes to
tauri-plugin-storeon every change. - 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.)
- Click-through hotkey is toggle (press to flip), with opacity-pulse feedback (~180ms dip and restore).
- URL validation: forgiving (prepends
https://if scheme missing) but onlyhttp/httpsallowed.tauri://,file://,javascript:,data:rejected. - 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 />
├── 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
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<void>;
closeOverlay: () => Promise<void>;
};
lib/overlay.ts (single Tauri-API boundary)
openOverlay(url, {opacity, alwaysOnTop, clickThrough}): Promise<void>
closeOverlay(): Promise<void>
applyOpacity(v): Promise<void>
applyAlwaysOnTop(v): Promise<void>
applyClickThrough(v): Promise<void>
pulseOpacity(target): Promise<void>
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-storetauri-plugin-window-statetauri-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:
- Look up overlay window by label. If absent → emit
click-through-no-overlayevent, return. - Read current
ignore_cursor_eventsstate. - Flip via
set_ignore_cursor_events(!current). - Read current opacity. Pulse:
set_opacity(0.4)→tokio::time::sleep(180ms)→set_opacity(prev). - Emit
click-through-toggledevent 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:
{
"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)
{
"url": "https://twitch.tv/somechannel",
"opacity": 0.65,
"alwaysOnTop": true,
"clickThrough": false
}
- On control-window mount:
lib/persist.tsreads store and hydrates Zustand. Empty store → defaults. - On any store change: debounced 300ms write back.
isOpenandurlErrornot persisted. - Not using Zustand persist middleware — using
tauri-plugin-storedirectly 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.0alwaysOnTop: trueclickThrough: 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-shortcutin 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:
- Cold start: only control window opens.
- URL validation:
twitch.tv/foo→ normalized;not a url→ error + Open disabled;tauri://,javascript:rejected. - Overlay opens frameless, transparent, top-right of primary monitor.
- Opacity slider 1.0 → 0.3 fades overlay in real time.
- AOT off → other windows can cover overlay; AOT on → raised.
- Click-through off: clicks on overlay scroll/click the loaded page.
- Click-through on: clicks pass through to apps underneath.
- With click-through on and our windows unfocused,
Ctrl+Alt+Spaceflips it off; overlay opacity blinks; control toggle reflects new state. - Persistence: change URL/opacity/toggles → close → reopen → restored.
- 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 buildproduces a working installer.- README updated: what the app does, dev setup, build, hotkey documented.
mainbranch on origin (Gitea) taggedv0.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/mainafter user approves each chunk (will check before pushing the first few times — pushes are externally visible).