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>
262 lines
11 KiB
Markdown
262 lines
11 KiB
Markdown
# 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 <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):
|
||
|
||
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 />
|
||
├── 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<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-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).
|