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>
This commit is contained in:
261
docs/plans/2026-05-08-browserlay-mvp-design.md
Normal file
261
docs/plans/2026-05-08-browserlay-mvp-design.md
Normal file
@@ -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 <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).
|
||||||
Reference in New Issue
Block a user