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:
Michael Chihlas
2026-05-08 16:05:31 -04:00
parent a8255b90d5
commit 605b5dc860

View 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).