Files
browserlay/docs/plans/2026-05-08-browserlay-mvp-design.md
Michael Chihlas 605b5dc860 docs: add Phase 1 MVP design
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>
2026-05-08 16:05:31 -04:00

262 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).