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