Files
browserlay/docs/superpowers/plans/2026-05-08-browserlay-mvp.md
Michael Chihlas 7f31a90e05 docs(plan): record Win32-opacity decision
Replaces the eval-based set_window_opacity in the plan with the
SetLayeredWindowAttributes implementation that actually shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:34:33 -04:00

2266 lines
71 KiB
Markdown
Raw 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) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship a Windows desktop app that creates a translucent, always-on-top browser overlay window with real-time opacity, click-through, and AOT controls plus a global hotkey escape hatch.
**Architecture:** Two-window Tauri 2 app. Control window (`main`) hosts a React+Tailwind+Zustand control panel; overlay window (`overlay`) is spawned at runtime as a frameless transparent webview pointed directly at the user-supplied URL. A Rust-side global shortcut (`Ctrl+Alt+Space`) toggles click-through and emits an event back so the control panel reflects the new state.
**Tech Stack:** Tauri 2.x · Rust 2021 · React 19 · TypeScript strict · Vite · Tailwind CSS v4 (`@tailwindcss/vite` + `@theme`) · Zustand v5 · `lucide-react` · `tauri-plugin-store` · `tauri-plugin-window-state` · `tauri-plugin-global-shortcut`.
**Design doc:** `docs/plans/2026-05-08-browserlay-mvp-design.md`
**Reference resolved during planning** (from official Tauri 2 docs at v2.tauri.app):
- `WebviewWindow.getByLabel` is `async``Promise<WebviewWindow | null>`. Always `await`.
- Capability prefixes are `core:window:allow-*` and `core:webview:allow-*` (Tauri 2).
- Rust→JS event emit uses the `Emitter` trait (`use tauri::Emitter`).
- Global shortcut handler fires on both `Pressed` and `Released` — filter to `Pressed`.
- `tauri-plugin-window-state` saves alwaysOnTop by default (`StateFlags::all()`); we override at overlay-create time so the Zustand store is authoritative.
- Tauri 2 Rust window lookup: `app.get_webview_window(label)` (from the `Manager` trait).
---
## File Structure
### Frontend (`browserlay/src/`)
- Create `main.tsx` (replace scaffold) — React entry, hydrate persisted state before mounting `<App />`.
- Create `App.tsx` (replace scaffold) — composes `<ControlPanel />`, mounts the live-wiring effect, mounts click-through-sync hook.
- Create `index.css` (replace scaffold) — Tailwind v4 `@import` + `@theme` design tokens.
- Create `components/ControlPanel.tsx` — top-level form layout.
- Create `components/UrlField.tsx` — URL input + validation feedback.
- Create `components/OpacitySlider.tsx` — slider + percentage readout.
- Create `components/ToggleRow.tsx` — reusable toggle row.
- Create `components/OpenOverlayButton.tsx` — Open / Close primary button.
- Create `components/StatusBar.tsx` — footer with hotkey hint and warning area.
- Create `hooks/useClickThroughSync.ts` — listens for `click-through-toggled` from Rust.
- Create `hooks/useLiveOverlayWiring.ts` — pushes Zustand changes to overlay.
- Create `lib/store.ts` — Zustand store.
- Create `lib/overlay.ts` — wrapper over `WebviewWindow` APIs.
- Create `lib/url.ts``normalizeUrl`, `isAllowedUrl`.
- Create `lib/persist.ts` — load + debounced save against `tauri-plugin-store`.
- Create `lib/debounce.ts` — small generic debounce helper.
- Create `types/overlay.ts``OverlayState`, `OverlayActions`, payload types.
- Delete `App.css` (no longer needed; Tailwind takes over).
- Delete `assets/react.svg` (scaffold remnant).
### Frontend (`browserlay/`)
- Modify `package.json` — add deps (`zustand`, `lucide-react`, `@tauri-apps/plugin-store`, `@tauri-apps/plugin-window-state`, `@tauri-apps/plugin-global-shortcut`, `tailwindcss`, `@tailwindcss/vite`).
- Modify `vite.config.ts` — register `@tailwindcss/vite` plugin.
- Modify `tsconfig.json` — strict + `noUncheckedIndexedAccess` + path mapping if useful.
- Modify `index.html` — set title, ensure dark background to avoid white flash.
- Modify `src-tauri/tauri.conf.json` — add overlay window definition? **No** — overlay is created at runtime; only the `main` window stays in config.
### Backend (`browserlay/src-tauri/`)
- Modify `Cargo.toml` — add plugin crates (`tauri-plugin-store`, `tauri-plugin-window-state`, `tauri-plugin-global-shortcut`) and `tokio` with `time` feature for the pulse sleep.
- Modify `src/lib.rs` — slim builder, register plugins, register global shortcut at setup, expose commands.
- Create `src/commands/mod.rs` — module declarations.
- Create `src/commands/overlay.rs``toggle_click_through` command + private `do_toggle` helper.
- Modify `capabilities/default.json` — add window/webview/store permissions for `main`.
- Create `capabilities/overlay.json` — minimal capability for the `overlay` window (no Tauri APIs exposed to loaded page).
### Docs / metadata
- Modify `README.md` — what the app does, dev setup, build, hotkey documented.
- Create `.gitignore` entries (if missing) for build outputs already present.
---
## Task 1: Project hygiene baseline
**Files:**
- Modify: `browserlay/tsconfig.json`
- Modify: `browserlay/index.html`
- [ ] **Step 1: Tighten tsconfig.json strictness**
Open `browserlay/tsconfig.json` and replace its contents with:
```json
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
```
- [ ] **Step 2: Set page title and dark base background**
Open `browserlay/index.html` and replace its contents with:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/tauri.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Browserlay</title>
<style>
html, body, #root { height: 100%; margin: 0; }
html { background: #0b0b0d; color-scheme: dark; }
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
- [ ] **Step 3: Verify build still passes**
Run from `browserlay/`: `npm run build`
Expected: TypeScript compiles, Vite emits `dist/`. (Scaffold App still references `App.css` — that's fine for now; we delete it in Task 4.)
- [ ] **Step 4: Commit**
```bash
git add browserlay/tsconfig.json browserlay/index.html
git commit -m "chore: tighten tsconfig and set dark page base"
```
---
## Task 2: Add frontend dependencies
**Files:**
- Modify: `browserlay/package.json`
- [ ] **Step 1: Install runtime dependencies**
From `browserlay/`:
```bash
npm install zustand@^5 lucide-react @tauri-apps/plugin-store @tauri-apps/plugin-window-state @tauri-apps/plugin-global-shortcut
```
- [ ] **Step 2: Install Tailwind v4 dev dependencies**
```bash
npm install -D tailwindcss @tailwindcss/vite
```
- [ ] **Step 3: Verify package.json**
Open `browserlay/package.json` and confirm dependencies now include `zustand`, `lucide-react`, `@tauri-apps/plugin-store`, `@tauri-apps/plugin-window-state`, `@tauri-apps/plugin-global-shortcut`; devDependencies include `tailwindcss` and `@tailwindcss/vite`.
- [ ] **Step 4: Commit**
```bash
git add browserlay/package.json browserlay/package-lock.json
git commit -m "chore: add frontend deps (zustand, lucide-react, tauri plugins, tailwind v4)"
```
---
## Task 3: Wire Tailwind v4 into Vite
**Files:**
- Modify: `browserlay/vite.config.ts`
- Create: `browserlay/src/index.css`
- [ ] **Step 1: Register the Tailwind Vite plugin**
Open `browserlay/vite.config.ts` and replace its contents with:
```ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? { protocol: "ws", host, port: 1421 }
: undefined,
watch: { ignored: ["**/src-tauri/**"] },
},
}));
```
- [ ] **Step 2: Create the Tailwind entry CSS with dark charcoal tokens**
Create `browserlay/src/index.css` with:
```css
@import "tailwindcss";
@theme {
--color-bg: #0b0b0d;
--color-surface: #141418;
--color-surface-elevated: #1c1c22;
--color-border: #2a2a31;
--color-border-strong: #3a3a44;
--color-text: #e8e8ec;
--color-muted: #9999a3;
--color-muted-strong: #c8c8d2;
--color-accent: #7c8cff;
--color-accent-hover: #93a0ff;
--color-danger: #ff6b6b;
--color-warning: #ffb84d;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--shadow-soft: 0 1px 2px rgba(0,0,0,0.4), 0 4px 24px rgba(0,0,0,0.25);
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
html, body, #root { height: 100%; }
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
```
- [ ] **Step 3: Verify Vite builds with Tailwind**
Run from `browserlay/`: `npm run build`
Expected: build completes; emitted CSS includes Tailwind utilities.
- [ ] **Step 4: Commit**
```bash
git add browserlay/vite.config.ts browserlay/src/index.css
git commit -m "feat: wire tailwind v4 with charcoal design tokens"
```
---
## Task 4: Delete scaffold UI
**Files:**
- Delete: `browserlay/src/App.css`
- Delete: `browserlay/src/assets/react.svg`
- [ ] **Step 1: Remove scaffold files**
```bash
rm browserlay/src/App.css
rm browserlay/src/assets/react.svg
```
- [ ] **Step 2: Confirm `assets/` directory state**
If `browserlay/src/assets/` is now empty, leave it (we'll delete the directory only if no asset is needed by end of Phase 1).
- [ ] **Step 3: Commit**
```bash
git add -u browserlay/src
git commit -m "chore: remove scaffold App.css and react.svg"
```
---
## Task 5: Types module
**Files:**
- Create: `browserlay/src/types/overlay.ts`
- [ ] **Step 1: Define the shared types**
Create `browserlay/src/types/overlay.ts` with:
```ts
export type OverlayPersistedState = {
url: string;
opacity: number;
alwaysOnTop: boolean;
clickThrough: boolean;
};
export type OverlayState = OverlayPersistedState & {
isOpen: boolean;
urlError: string | null;
hotkeyError: string | null;
};
export type OverlayActions = {
setUrl: (raw: string) => void;
setOpacity: (v: number) => void;
setAlwaysOnTop: (v: boolean) => void;
setClickThrough: (v: boolean) => void;
/** Internal: applies a click-through change that originated in Rust.
* Skips the live-wiring effect to avoid ping-pong with the backend. */
_setClickThroughFromBackend: (v: boolean) => void;
setHotkeyError: (msg: string | null) => void;
setIsOpen: (v: boolean) => void;
hydrate: (s: OverlayPersistedState) => void;
};
export type ClickThroughToggledPayload = { clickThrough: boolean };
export type ClickThroughNoOverlayPayload = Record<string, never>;
export const STORE_FILE = "browserlay.json";
export const STORE_KEY = "state";
export const OVERLAY_LABEL = "overlay";
export const EVT_CLICK_THROUGH_TOGGLED = "click-through-toggled";
export const EVT_CLICK_THROUGH_NO_OVERLAY = "click-through-no-overlay";
```
- [ ] **Step 2: Confirm tsc accepts the file**
Run from `browserlay/`: `npx tsc --noEmit`
Expected: 0 errors. (Other files don't reference these types yet.)
- [ ] **Step 3: Commit**
```bash
git add browserlay/src/types/overlay.ts
git commit -m "feat: add overlay state types and IPC constants"
```
---
## Task 6: URL normalization + allowlist (with tests)
**Files:**
- Create: `browserlay/src/lib/url.ts`
- Create: `browserlay/src/lib/url.test.ts`
- Modify: `browserlay/package.json` (add `vitest` dev dep)
- Modify: `browserlay/vite.config.ts` (add test config)
- [ ] **Step 1: Add Vitest**
From `browserlay/`:
```bash
npm install -D vitest @types/node
```
- [ ] **Step 2: Add test script to package.json**
Open `browserlay/package.json` and inside `"scripts"` add a `"test": "vitest run"` entry. Final scripts block should look like:
```json
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest run"
}
```
- [ ] **Step 3: Add Vitest config to vite.config.ts**
In `browserlay/vite.config.ts`, change the top of the file and add a `test` block. Replace its contents with:
```ts
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? { protocol: "ws", host, port: 1421 }
: undefined,
watch: { ignored: ["**/src-tauri/**"] },
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
}));
```
- [ ] **Step 4: Write the failing tests**
Create `browserlay/src/lib/url.test.ts` with:
```ts
import { describe, it, expect } from "vitest";
import { normalizeUrl, validateUrl } from "./url";
describe("normalizeUrl", () => {
it("prepends https:// to a bare domain", () => {
expect(normalizeUrl("twitch.tv/foo")).toBe("https://twitch.tv/foo");
});
it("preserves existing https://", () => {
expect(normalizeUrl("https://example.com")).toBe("https://example.com");
});
it("preserves existing http://", () => {
expect(normalizeUrl("http://example.com")).toBe("http://example.com");
});
it("trims surrounding whitespace", () => {
expect(normalizeUrl(" twitch.tv ")).toBe("https://twitch.tv");
});
});
describe("validateUrl", () => {
it("accepts a normal https URL", () => {
expect(validateUrl("https://twitch.tv/foo")).toEqual({ ok: true, url: "https://twitch.tv/foo" });
});
it("normalizes a bare domain", () => {
expect(validateUrl("twitch.tv")).toEqual({ ok: true, url: "https://twitch.tv/" });
});
it("rejects empty input", () => {
expect(validateUrl("")).toEqual({ ok: false, error: "Enter a URL" });
});
it("rejects whitespace-only input", () => {
expect(validateUrl(" ")).toEqual({ ok: false, error: "Enter a URL" });
});
it("rejects javascript: scheme", () => {
expect(validateUrl("javascript:alert(1)")).toEqual({
ok: false,
error: "Only http and https URLs are allowed",
});
});
it("rejects file:// scheme", () => {
expect(validateUrl("file:///c:/secret.html")).toEqual({
ok: false,
error: "Only http and https URLs are allowed",
});
});
it("rejects tauri:// scheme", () => {
expect(validateUrl("tauri://localhost/index.html")).toEqual({
ok: false,
error: "Only http and https URLs are allowed",
});
});
it("rejects data: scheme", () => {
expect(validateUrl("data:text/html,hi")).toEqual({
ok: false,
error: "Only http and https URLs are allowed",
});
});
it("rejects unparsable input", () => {
expect(validateUrl("not a url")).toEqual({
ok: false,
error: "That doesn't look like a URL",
});
});
});
```
- [ ] **Step 5: Run tests to verify they fail**
Run from `browserlay/`: `npm test`
Expected: FAIL with "Cannot find module './url'" or similar.
- [ ] **Step 6: Implement the URL helpers**
Create `browserlay/src/lib/url.ts` with:
```ts
export type UrlValidation =
| { ok: true; url: string }
| { ok: false; error: string };
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
export function normalizeUrl(raw: string): string {
const trimmed = raw.trim();
if (trimmed === "") return trimmed;
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
export function validateUrl(raw: string): UrlValidation {
const trimmed = raw.trim();
if (trimmed === "") return { ok: false, error: "Enter a URL" };
const candidate = normalizeUrl(trimmed);
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return { ok: false, error: "That doesn't look like a URL" };
}
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
return { ok: false, error: "Only http and https URLs are allowed" };
}
if (!parsed.hostname) {
return { ok: false, error: "That doesn't look like a URL" };
}
return { ok: true, url: parsed.toString() };
}
```
- [ ] **Step 7: Run tests to verify they pass**
Run: `npm test`
Expected: all 14 tests pass.
- [ ] **Step 8: Commit**
```bash
git add browserlay/package.json browserlay/package-lock.json browserlay/vite.config.ts browserlay/src/lib/url.ts browserlay/src/lib/url.test.ts
git commit -m "feat: url normalization and http(s) allowlist"
```
---
## Task 7: Generic debounce helper (with test)
**Files:**
- Create: `browserlay/src/lib/debounce.ts`
- Create: `browserlay/src/lib/debounce.test.ts`
- [ ] **Step 1: Write the failing test**
Create `browserlay/src/lib/debounce.test.ts` with:
```ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { debounce } from "./debounce";
describe("debounce", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("delays a single call by the wait period", () => {
const fn = vi.fn();
const d = debounce(fn, 100);
d("a");
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("a");
});
it("collapses rapid calls into a single trailing call", () => {
const fn = vi.fn();
const d = debounce(fn, 100);
d("a"); d("b"); d("c");
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("c");
});
it("flush() invokes pending call immediately", () => {
const fn = vi.fn();
const d = debounce(fn, 100);
d("a");
d.flush();
expect(fn).toHaveBeenCalledWith("a");
});
it("flush() with no pending call is a no-op", () => {
const fn = vi.fn();
const d = debounce(fn, 100);
d.flush();
expect(fn).not.toHaveBeenCalled();
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test`
Expected: FAIL with "Cannot find module './debounce'".
- [ ] **Step 3: Implement debounce**
Create `browserlay/src/lib/debounce.ts` with:
```ts
export type Debounced<Args extends unknown[]> = ((...args: Args) => void) & {
flush: () => void;
cancel: () => void;
};
export function debounce<Args extends unknown[]>(
fn: (...args: Args) => void,
waitMs: number
): Debounced<Args> {
let timer: ReturnType<typeof setTimeout> | null = null;
let pendingArgs: Args | null = null;
const debounced = (...args: Args): void => {
pendingArgs = args;
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const a = pendingArgs!;
pendingArgs = null;
fn(...a);
}, waitMs);
};
debounced.flush = (): void => {
if (timer === null) return;
clearTimeout(timer);
timer = null;
const a = pendingArgs!;
pendingArgs = null;
fn(...a);
};
debounced.cancel = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
pendingArgs = null;
};
return debounced;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test`
Expected: all tests pass (URL tests + debounce tests).
- [ ] **Step 5: Commit**
```bash
git add browserlay/src/lib/debounce.ts browserlay/src/lib/debounce.test.ts
git commit -m "feat: debounce helper with flush"
```
---
## Task 8: Zustand store
**Files:**
- Create: `browserlay/src/lib/store.ts`
- Create: `browserlay/src/lib/store.test.ts`
- [ ] **Step 1: Write the failing test**
Create `browserlay/src/lib/store.test.ts` with:
```ts
import { describe, it, expect, beforeEach } from "vitest";
import { useOverlayStore } from "./store";
const initial = useOverlayStore.getState();
describe("useOverlayStore", () => {
beforeEach(() => useOverlayStore.setState(initial, true));
it("starts with sensible defaults", () => {
const s = useOverlayStore.getState();
expect(s.url).toBe("");
expect(s.opacity).toBe(1.0);
expect(s.alwaysOnTop).toBe(true);
expect(s.clickThrough).toBe(false);
expect(s.isOpen).toBe(false);
expect(s.urlError).toBe(null);
expect(s.hotkeyError).toBe(null);
});
it("setUrl validates and stores both url and urlError", () => {
useOverlayStore.getState().setUrl("twitch.tv");
expect(useOverlayStore.getState().url).toBe("twitch.tv");
expect(useOverlayStore.getState().urlError).toBe(null);
useOverlayStore.getState().setUrl("javascript:alert(1)");
expect(useOverlayStore.getState().urlError).toMatch(/http and https/);
});
it("setOpacity clamps to 0.1..1.0", () => {
useOverlayStore.getState().setOpacity(0.05);
expect(useOverlayStore.getState().opacity).toBe(0.1);
useOverlayStore.getState().setOpacity(2);
expect(useOverlayStore.getState().opacity).toBe(1.0);
useOverlayStore.getState().setOpacity(0.5);
expect(useOverlayStore.getState().opacity).toBe(0.5);
});
it("setAlwaysOnTop and setClickThrough mutate flags", () => {
useOverlayStore.getState().setAlwaysOnTop(false);
expect(useOverlayStore.getState().alwaysOnTop).toBe(false);
useOverlayStore.getState().setClickThrough(true);
expect(useOverlayStore.getState().clickThrough).toBe(true);
});
it("hydrate replaces persisted fields and clears errors", () => {
useOverlayStore.getState().setUrl("javascript:alert(1)");
useOverlayStore.getState().hydrate({
url: "https://example.com",
opacity: 0.6,
alwaysOnTop: false,
clickThrough: true,
});
const s = useOverlayStore.getState();
expect(s.url).toBe("https://example.com");
expect(s.opacity).toBe(0.6);
expect(s.alwaysOnTop).toBe(false);
expect(s.clickThrough).toBe(true);
expect(s.urlError).toBe(null);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test`
Expected: FAIL ("Cannot find module './store'").
- [ ] **Step 3: Implement the store**
Create `browserlay/src/lib/store.ts` with:
```ts
import { create } from "zustand";
import type { OverlayActions, OverlayPersistedState, OverlayState } from "../types/overlay";
import { validateUrl } from "./url";
const clamp = (v: number, min: number, max: number): number =>
Math.min(max, Math.max(min, v));
export const useOverlayStore = create<OverlayState & OverlayActions>()((set) => ({
url: "",
opacity: 1.0,
alwaysOnTop: true,
clickThrough: false,
isOpen: false,
urlError: null,
hotkeyError: null,
setUrl: (raw) => {
const result = validateUrl(raw);
set({
url: raw,
urlError: result.ok ? null : raw.trim() === "" ? null : result.error,
});
},
setOpacity: (v) => set({ opacity: clamp(v, 0.1, 1.0) }),
setAlwaysOnTop: (v) => set({ alwaysOnTop: v }),
setClickThrough: (v) => set({ clickThrough: v }),
_setClickThroughFromBackend: (v) =>
set((s) => (s.clickThrough === v ? s : { clickThrough: v })),
setHotkeyError: (msg) => set({ hotkeyError: msg }),
setIsOpen: (v) => set({ isOpen: v }),
hydrate: (p: OverlayPersistedState) =>
set({
url: p.url,
opacity: clamp(p.opacity, 0.1, 1.0),
alwaysOnTop: p.alwaysOnTop,
clickThrough: p.clickThrough,
urlError: null,
}),
}));
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test`
Expected: all tests pass.
- [ ] **Step 5: Commit**
```bash
git add browserlay/src/lib/store.ts browserlay/src/lib/store.test.ts
git commit -m "feat: zustand store for overlay state"
```
---
## Task 9: Overlay API boundary (`lib/overlay.ts`)
**Files:**
- Create: `browserlay/src/lib/overlay.ts`
This module is the only place that imports `@tauri-apps/api/webviewWindow`. We won't unit test it — its job is thin Tauri API delegation; the real test is the manual verification checklist (Task 19).
- [ ] **Step 1: Implement the overlay boundary**
Create `browserlay/src/lib/overlay.ts` with:
```ts
import { invoke } from "@tauri-apps/api/core";
import {
WebviewWindow,
getCurrentWebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import { currentMonitor } from "@tauri-apps/api/window";
import { OVERLAY_LABEL } from "../types/overlay";
export type OpenOptions = {
url: string;
opacity: number;
alwaysOnTop: boolean;
clickThrough: boolean;
/** Called when the overlay window is destroyed by *any* path (taskbar close,
* Alt+F4, our own closeOverlay, OS forced close). Use it to reconcile
* control-panel state. Fires exactly once per open. */
onDestroyed: () => void;
};
const FIRST_OPEN_W = 800;
const FIRST_OPEN_H = 600;
const FIRST_OPEN_MARGIN = 24;
async function getOverlay(): Promise<WebviewWindow | null> {
return await WebviewWindow.getByLabel(OVERLAY_LABEL);
}
export async function openOverlay(opts: OpenOptions): Promise<void> {
const existing = await getOverlay();
if (existing) {
await closeOverlay();
}
// Default to top-right of primary monitor on first open;
// tauri-plugin-window-state will restore prior geometry on subsequent opens.
let x = 100;
let y = 100;
try {
const monitor = await currentMonitor();
if (monitor) {
const scale = monitor.scaleFactor;
const logicalW = monitor.size.width / scale;
x = Math.round(logicalW - FIRST_OPEN_W - FIRST_OPEN_MARGIN);
y = FIRST_OPEN_MARGIN;
}
} catch {
// best-effort placement; fall through to defaults
}
const w = new WebviewWindow(OVERLAY_LABEL, {
url: opts.url,
title: "Browserlay overlay",
width: FIRST_OPEN_W,
height: FIRST_OPEN_H,
x,
y,
decorations: false,
transparent: true,
alwaysOnTop: opts.alwaysOnTop,
resizable: true,
visible: true,
skipTaskbar: false,
});
// Wait for the window to actually exist (or surface an OS error). Both
// tauri://created and tauri://error fire at most once per window; we
// unsubscribe whichever didn't win.
await new Promise<void>((resolve, reject) => {
let settled = false;
let unlistenCreated: (() => void) | null = null;
let unlistenError: (() => void) | null = null;
void w.once("tauri://created", () => {
if (settled) return;
settled = true;
unlistenError?.();
resolve();
}).then((u) => {
unlistenCreated = u;
if (settled) u();
});
void w.once("tauri://error", (e) => {
if (settled) return;
settled = true;
unlistenCreated?.();
reject(new Error(`Failed to create overlay window: ${JSON.stringify(e.payload)}`));
}).then((u) => {
unlistenError = u;
if (settled) u();
});
});
// Single source of truth for "overlay went away" — fires for *any* close
// path (our closeOverlay, taskbar X, Alt+F4, OS-forced destruction).
void w.once("tauri://destroyed", () => {
opts.onDestroyed();
});
// Apply runtime-only state that's not part of the constructor.
await w.setOpacity(opts.opacity);
await w.setIgnoreCursorEvents(opts.clickThrough);
// Seed the Rust-side click-through cache so the global shortcut handler
// knows the current value when it computes the inverse.
await syncClickThroughCache(opts.clickThrough);
}
export async function closeOverlay(): Promise<void> {
const w = await getOverlay();
if (!w) return;
await w.close();
}
export async function applyOpacity(v: number): Promise<void> {
const w = await getOverlay();
if (!w) return;
await w.setOpacity(v);
}
export async function applyAlwaysOnTop(v: boolean): Promise<void> {
const w = await getOverlay();
if (!w) return;
await w.setAlwaysOnTop(v);
}
export async function applyClickThrough(v: boolean): Promise<void> {
const w = await getOverlay();
if (!w) return;
await w.setIgnoreCursorEvents(v);
}
/** Returns true if the overlay window currently exists. */
export async function isOverlayOpen(): Promise<boolean> {
return (await getOverlay()) !== null;
}
/** Convenience for the control window's own handle if needed elsewhere. */
export function getControlWindow(): WebviewWindow {
return getCurrentWebviewWindow();
}
export async function syncClickThroughCache(value: boolean): Promise<void> {
try {
await invoke("sync_click_through_cache", { value });
} catch (err) {
console.warn("Failed to sync click-through cache to backend", err);
}
}
```
- [ ] **Step 2: Verify tsc accepts the file**
Run: `npx tsc --noEmit`
Expected: 0 errors.
- [ ] **Step 3: Commit**
```bash
git add browserlay/src/lib/overlay.ts
git commit -m "feat: overlay window API boundary"
```
---
## Task 10: Persistence (load + debounced save)
**Files:**
- Create: `browserlay/src/lib/persist.ts`
- [ ] **Step 1: Implement persist**
Create `browserlay/src/lib/persist.ts` with:
```ts
import { load, type Store } from "@tauri-apps/plugin-store";
import { STORE_FILE, STORE_KEY, type OverlayPersistedState } from "../types/overlay";
import { useOverlayStore } from "./store";
import { debounce } from "./debounce";
const DEFAULTS: OverlayPersistedState = {
url: "",
opacity: 1.0,
alwaysOnTop: true,
clickThrough: false,
};
let storePromise: Promise<Store> | null = null;
function getStore(): Promise<Store> {
if (!storePromise) {
storePromise = load(STORE_FILE, { autoSave: false });
}
return storePromise;
}
function isPersistedShape(v: unknown): v is OverlayPersistedState {
if (!v || typeof v !== "object") return false;
const o = v as Record<string, unknown>;
return (
typeof o.url === "string" &&
typeof o.opacity === "number" &&
typeof o.alwaysOnTop === "boolean" &&
typeof o.clickThrough === "boolean"
);
}
/** Load persisted state from disk and hydrate the Zustand store. Safe on first run. */
export async function hydrateFromDisk(): Promise<void> {
try {
const s = await getStore();
const raw = await s.get<unknown>(STORE_KEY);
const value = isPersistedShape(raw) ? raw : DEFAULTS;
useOverlayStore.getState().hydrate(value);
} catch (err) {
console.warn("Failed to load persisted state; using defaults", err);
useOverlayStore.getState().hydrate(DEFAULTS);
}
}
// We persist on a 300ms debounce. The mirror below tracks "latest known
// pending state" so a synchronous flush at beforeunload time has something
// to issue — even though the actual write is unavoidably async.
let latestPending: OverlayPersistedState | null = null;
async function writeNow(state: OverlayPersistedState): Promise<void> {
try {
const s = await getStore();
await s.set(STORE_KEY, state);
await s.save();
} catch (err) {
console.warn("Failed to persist state", err);
}
}
const writeDebounced = debounce((state: OverlayPersistedState): void => {
latestPending = null;
void writeNow(state);
}, 300);
/** Subscribe to store changes and persist the four user-pref fields. */
export function startPersistSubscription(): () => void {
return useOverlayStore.subscribe((s) => {
const snapshot: OverlayPersistedState = {
url: s.url,
opacity: s.opacity,
alwaysOnTop: s.alwaysOnTop,
clickThrough: s.clickThrough,
};
latestPending = snapshot;
writeDebounced(snapshot);
});
}
/**
* Best-effort synchronous flush for beforeunload. The actual disk write is
* unavoidably async — we fire-and-forget it. If the window is destroyed
* before the write lands, the prior debounce-tick's value (at most 300ms
* stale) will be loaded next session. That bounded staleness is the
* accepted trade-off for a Phase 1 utility app.
*/
export function flushPendingPersistSync(): void {
if (latestPending === null) return;
const snapshot = latestPending;
latestPending = null;
writeDebounced.cancel();
void writeNow(snapshot);
}
```
- [ ] **Step 2: Verify tsc accepts the file**
Run: `npx tsc --noEmit`
Expected: 0 errors.
- [ ] **Step 3: Commit**
```bash
git add browserlay/src/lib/persist.ts
git commit -m "feat: load + debounced persist via tauri-plugin-store"
```
---
## Task 11: Live wiring + click-through sync hooks
**Files:**
- Create: `browserlay/src/hooks/useLiveOverlayWiring.ts`
- Create: `browserlay/src/hooks/useClickThroughSync.ts`
- [ ] **Step 1: Implement live wiring hook**
Create `browserlay/src/hooks/useLiveOverlayWiring.ts` with:
```ts
import { useEffect, useRef } from "react";
import { useOverlayStore } from "../lib/store";
import {
applyAlwaysOnTop,
applyClickThrough,
applyOpacity,
} from "../lib/overlay";
/** Pushes Zustand changes to the overlay window in real time. No-op when overlay is closed. */
export function useLiveOverlayWiring(): void {
const opacity = useOverlayStore((s) => s.opacity);
const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
const clickThrough = useOverlayStore((s) => s.clickThrough);
const isOpen = useOverlayStore((s) => s.isOpen);
// Track whether the *previous* clickThrough value came from the backend so we
// don't bounce it back. A simple ref counter works because the silent setter
// is the only path that calls _setClickThroughFromBackend.
const lastAppliedClickThrough = useRef<boolean | null>(null);
useEffect(() => {
if (!isOpen) return;
void applyOpacity(opacity);
}, [opacity, isOpen]);
useEffect(() => {
if (!isOpen) return;
void applyAlwaysOnTop(alwaysOnTop);
}, [alwaysOnTop, isOpen]);
useEffect(() => {
if (!isOpen) return;
if (lastAppliedClickThrough.current === clickThrough) return;
lastAppliedClickThrough.current = clickThrough;
void applyClickThrough(clickThrough);
}, [clickThrough, isOpen]);
}
```
> **Note:** The "did this change come from the backend" guard above is informal — it relies on `lastAppliedClickThrough` being equal to the value just written by `_setClickThroughFromBackend`. The sync hook below will set this ref before updating the store, so the `useEffect` no-ops.
- [ ] **Step 2: Implement click-through sync hook**
Create `browserlay/src/hooks/useClickThroughSync.ts` with:
```ts
import { useEffect } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useOverlayStore } from "../lib/store";
import { applyOpacity } from "../lib/overlay";
import {
EVT_CLICK_THROUGH_NO_OVERLAY,
EVT_CLICK_THROUGH_TOGGLED,
type ClickThroughToggledPayload,
} from "../types/overlay";
/** Subscribes to backend events about click-through state and toast about no-overlay attempts. */
export function useClickThroughSync(
onNoOverlay: () => void
): void {
useEffect(() => {
let unlistenToggled: UnlistenFn | undefined;
let unlistenNoOverlay: UnlistenFn | undefined;
let cancelled = false;
(async () => {
unlistenToggled = await listen<ClickThroughToggledPayload>(
EVT_CLICK_THROUGH_TOGGLED,
(e) => {
if (cancelled) return;
useOverlayStore.getState()._setClickThroughFromBackend(
e.payload.clickThrough
);
// Restore the user's target opacity. Rust performed the dip-half of
// the pulse and emitted this event after sleeping ~180ms; we own
// the restore. Reading from the live store guarantees we restore
// to whatever the slider says *now*, not whatever it said when the
// hotkey was pressed.
void applyOpacity(useOverlayStore.getState().opacity);
}
);
unlistenNoOverlay = await listen(EVT_CLICK_THROUGH_NO_OVERLAY, () => {
if (cancelled) return;
onNoOverlay();
});
})().catch((err) => console.warn("event listen failed", err));
return () => {
cancelled = true;
unlistenToggled?.();
unlistenNoOverlay?.();
};
}, [onNoOverlay]);
}
```
- [ ] **Step 3: Verify tsc accepts the files**
Run: `npx tsc --noEmit`
Expected: 0 errors. (`onNoOverlay` callback type is inferred as `() => void`.)
- [ ] **Step 4: Commit**
```bash
git add browserlay/src/hooks/useLiveOverlayWiring.ts browserlay/src/hooks/useClickThroughSync.ts
git commit -m "feat: live overlay wiring and backend click-through sync hooks"
```
---
## Task 12: UI components
**Files:**
- Create: `browserlay/src/components/UrlField.tsx`
- Create: `browserlay/src/components/OpacitySlider.tsx`
- Create: `browserlay/src/components/ToggleRow.tsx`
- Create: `browserlay/src/components/OpenOverlayButton.tsx`
- Create: `browserlay/src/components/StatusBar.tsx`
- Create: `browserlay/src/components/ControlPanel.tsx`
- [ ] **Step 1: UrlField**
Create `browserlay/src/components/UrlField.tsx` with:
```tsx
import { Globe } from "lucide-react";
import { useOverlayStore } from "../lib/store";
export function UrlField(): JSX.Element {
const url = useOverlayStore((s) => s.url);
const urlError = useOverlayStore((s) => s.urlError);
const setUrl = useOverlayStore((s) => s.setUrl);
return (
<div className="flex flex-col gap-1.5">
<label htmlFor="overlay-url" className="text-xs uppercase tracking-wide text-[var(--color-muted)]">
URL
</label>
<div
className={`flex items-center gap-2 rounded-[var(--radius-md)] border px-3 py-2 transition-colors
bg-[var(--color-surface)]
${urlError ? "border-[var(--color-danger)]" : "border-[var(--color-border)] focus-within:border-[var(--color-border-strong)]"}`}
>
<Globe size={16} className="text-[var(--color-muted)]" aria-hidden="true" />
<input
id="overlay-url"
type="text"
inputMode="url"
autoComplete="off"
spellCheck={false}
placeholder="twitch.tv/somechannel"
value={url}
onChange={(e) => setUrl(e.currentTarget.value)}
className="w-full bg-transparent outline-none text-[var(--color-text)] placeholder:text-[var(--color-muted)]"
/>
</div>
{urlError && (
<p className="text-xs text-[var(--color-danger)]">{urlError}</p>
)}
</div>
);
}
```
- [ ] **Step 2: OpacitySlider**
Create `browserlay/src/components/OpacitySlider.tsx` with:
```tsx
import { useOverlayStore } from "../lib/store";
export function OpacitySlider(): JSX.Element {
const opacity = useOverlayStore((s) => s.opacity);
const setOpacity = useOverlayStore((s) => s.setOpacity);
const pct = Math.round(opacity * 100);
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label htmlFor="opacity" className="text-xs uppercase tracking-wide text-[var(--color-muted)]">
Opacity
</label>
<span className="text-xs tabular-nums text-[var(--color-muted-strong)]">{pct}%</span>
</div>
<input
id="opacity"
type="range"
min={0.1}
max={1.0}
step={0.01}
value={opacity}
onChange={(e) => setOpacity(Number(e.currentTarget.value))}
className="w-full accent-[var(--color-accent)]"
/>
</div>
);
}
```
- [ ] **Step 3: ToggleRow**
Create `browserlay/src/components/ToggleRow.tsx` with:
```tsx
import type { ReactNode } from "react";
export type ToggleRowProps = {
label: string;
description?: string;
icon?: ReactNode;
checked: boolean;
onChange: (v: boolean) => void;
};
export function ToggleRow({ label, description, icon, checked, onChange }: ToggleRowProps): JSX.Element {
return (
<label className="flex items-center justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5 cursor-pointer hover:border-[var(--color-border-strong)] transition-colors">
<span className="flex items-center gap-2.5 min-w-0">
{icon && <span className="text-[var(--color-muted-strong)]" aria-hidden="true">{icon}</span>}
<span className="flex flex-col min-w-0">
<span className="text-sm text-[var(--color-text)]">{label}</span>
{description && (
<span className="text-xs text-[var(--color-muted)] truncate">{description}</span>
)}
</span>
</span>
<span
role="switch"
aria-checked={checked}
aria-label={label}
onClick={(e) => {
e.preventDefault();
onChange(!checked);
}}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors
${checked ? "bg-[var(--color-accent)]" : "bg-[var(--color-border-strong)]"}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all
${checked ? "left-[calc(100%-1.125rem)]" : "left-0.5"}`}
/>
</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.currentTarget.checked)}
className="sr-only"
/>
</label>
);
}
```
- [ ] **Step 4: OpenOverlayButton**
Create `browserlay/src/components/OpenOverlayButton.tsx` with:
```tsx
import { Play, Square } from "lucide-react";
import { useOverlayStore } from "../lib/store";
import { closeOverlay, openOverlay } from "../lib/overlay";
import { validateUrl } from "../lib/url";
export function OpenOverlayButton(): JSX.Element {
const url = useOverlayStore((s) => s.url);
const isOpen = useOverlayStore((s) => s.isOpen);
const opacity = useOverlayStore((s) => s.opacity);
const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
const clickThrough = useOverlayStore((s) => s.clickThrough);
const setIsOpen = useOverlayStore((s) => s.setIsOpen);
const validation = validateUrl(url);
const disabled = !isOpen && !validation.ok;
async function handleClick(): Promise<void> {
if (isOpen) {
await closeOverlay();
// The destroyed listener registered in openOverlay will also flip
// isOpen, but call it here too so the UI reflects intent immediately.
setIsOpen(false);
return;
}
if (!validation.ok) return;
await openOverlay({
url: validation.url,
opacity,
alwaysOnTop,
clickThrough,
onDestroyed: () => {
useOverlayStore.getState().setIsOpen(false);
},
});
setIsOpen(true);
}
return (
<button
type="button"
onClick={() => {
void handleClick();
}}
disabled={disabled}
className={`mt-1 flex items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 py-2.5 text-sm font-medium transition-colors
${disabled
? "bg-[var(--color-surface-elevated)] text-[var(--color-muted)] cursor-not-allowed"
: isOpen
? "bg-[var(--color-surface-elevated)] text-[var(--color-text)] hover:bg-[var(--color-border-strong)]"
: "bg-[var(--color-accent)] text-black hover:bg-[var(--color-accent-hover)]"}`}
>
{isOpen ? <Square size={14} /> : <Play size={14} />}
{isOpen ? "Close overlay" : "Open overlay"}
</button>
);
}
```
- [ ] **Step 5: StatusBar**
Create `browserlay/src/components/StatusBar.tsx` with:
```tsx
import { AlertTriangle, Keyboard } from "lucide-react";
import { useOverlayStore } from "../lib/store";
export type StatusBarProps = { transient: string | null };
export function StatusBar({ transient }: StatusBarProps): JSX.Element {
const hotkeyError = useOverlayStore((s) => s.hotkeyError);
const isOpen = useOverlayStore((s) => s.isOpen);
return (
<footer className="border-t border-[var(--color-border)] px-4 py-2 text-xs flex items-center justify-between text-[var(--color-muted)]">
<span className="flex items-center gap-2">
<span
className={`h-1.5 w-1.5 rounded-full ${isOpen ? "bg-[var(--color-accent)]" : "bg-[var(--color-border-strong)]"}`}
aria-hidden="true"
/>
{isOpen ? "Overlay open" : "Overlay closed"}
</span>
<span className="flex items-center gap-3">
{hotkeyError ? (
<span className="flex items-center gap-1 text-[var(--color-warning)]">
<AlertTriangle size={12} aria-hidden="true" /> {hotkeyError}
</span>
) : (
<span className="flex items-center gap-1">
<Keyboard size={12} aria-hidden="true" /> Ctrl+Alt+Space toggles click-through
</span>
)}
{transient && <span className="text-[var(--color-muted-strong)]">{transient}</span>}
</span>
</footer>
);
}
```
- [ ] **Step 6: ControlPanel**
Create `browserlay/src/components/ControlPanel.tsx` with:
```tsx
import { Layers, MousePointerClick } from "lucide-react";
import { UrlField } from "./UrlField";
import { OpacitySlider } from "./OpacitySlider";
import { ToggleRow } from "./ToggleRow";
import { OpenOverlayButton } from "./OpenOverlayButton";
import { useOverlayStore } from "../lib/store";
export function ControlPanel(): JSX.Element {
const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
const setAlwaysOnTop = useOverlayStore((s) => s.setAlwaysOnTop);
const clickThrough = useOverlayStore((s) => s.clickThrough);
const setClickThrough = useOverlayStore((s) => s.setClickThrough);
return (
<section className="flex flex-1 flex-col gap-4 p-4">
<header className="flex items-center gap-2">
<span className="text-base font-semibold tracking-tight text-[var(--color-text)]">Browserlay</span>
</header>
<UrlField />
<OpacitySlider />
<div className="flex flex-col gap-2">
<ToggleRow
label="Always on top"
description="Keep the overlay above other windows"
icon={<Layers size={14} />}
checked={alwaysOnTop}
onChange={setAlwaysOnTop}
/>
<ToggleRow
label="Click-through"
description="Mouse passes through to apps below"
icon={<MousePointerClick size={14} />}
checked={clickThrough}
onChange={setClickThrough}
/>
</div>
<OpenOverlayButton />
</section>
);
}
```
- [ ] **Step 7: Verify tsc accepts the files**
Run: `npx tsc --noEmit`
Expected: 0 errors.
- [ ] **Step 8: Commit**
```bash
git add browserlay/src/components
git commit -m "feat: control panel UI components"
```
---
## Task 13: Wire App.tsx + main.tsx
**Files:**
- Modify: `browserlay/src/App.tsx`
- Modify: `browserlay/src/main.tsx`
- [ ] **Step 1: Replace App.tsx**
Open `browserlay/src/App.tsx` and replace its contents with:
```tsx
import { useCallback, useEffect, useState } from "react";
import { ControlPanel } from "./components/ControlPanel";
import { StatusBar } from "./components/StatusBar";
import { useLiveOverlayWiring } from "./hooks/useLiveOverlayWiring";
import { useClickThroughSync } from "./hooks/useClickThroughSync";
import { useOverlayStore } from "./lib/store";
import { isOverlayOpen } from "./lib/overlay";
function App(): JSX.Element {
useLiveOverlayWiring();
const setIsOpen = useOverlayStore((s) => s.setIsOpen);
const [transient, setTransient] = useState<string | null>(null);
// Reconcile isOpen with reality on mount (e.g. user closed overlay via taskbar last session).
useEffect(() => {
void isOverlayOpen().then(setIsOpen);
}, [setIsOpen]);
const handleNoOverlay = useCallback(() => {
setTransient("No overlay to toggle");
const t = setTimeout(() => setTransient(null), 2500);
return () => clearTimeout(t);
}, []);
useClickThroughSync(handleNoOverlay);
return (
<main className="flex h-screen flex-col bg-[var(--color-bg)]">
<ControlPanel />
<StatusBar transient={transient} />
</main>
);
}
export default App;
```
- [ ] **Step 2: Replace main.tsx**
Open `browserlay/src/main.tsx` and replace its contents with:
```tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { hydrateFromDisk, startPersistSubscription, flushPendingPersistSync } from "./lib/persist";
import { fetchHotkeyStatus } from "./lib/hotkey";
async function bootstrap(): Promise<void> {
await hydrateFromDisk();
startPersistSubscription();
// Pull the hotkey registration result. Pull-style avoids the
// listener-not-yet-registered race that an event-based approach would have.
void fetchHotkeyStatus();
// beforeunload handlers must be synchronous; we use a sync flush helper
// that issues the write. The async save will race with window destruction
// — whichever wins is fine, the write either lands or we retry next session
// (the prior persisted value is at most 300ms stale).
window.addEventListener("beforeunload", () => {
flushPendingPersistSync();
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
void bootstrap();
```
- [ ] **Step 3: Create the hotkey status puller**
Pull-style intentionally: emitting the result from Rust setup would race against the JS listener registration, so we let JS pull on its own schedule.
Create `browserlay/src/lib/hotkey.ts` with:
```ts
import { invoke } from "@tauri-apps/api/core";
import { useOverlayStore } from "./store";
type HotkeyStatus =
| { kind: "pending" }
| { kind: "ok" }
| { kind: "failed"; error: string };
/** Polls the backend until hotkey registration is decided, then surfaces any error
* via the store. Bounded retry: setup-time decision is essentially synchronous
* on the Rust side, so a missed first call is at worst a few-ms wait. */
export async function fetchHotkeyStatus(): Promise<void> {
const maxAttempts = 20; // ~1 second total worst case
for (let i = 0; i < maxAttempts; i++) {
let status: HotkeyStatus;
try {
status = await invoke<HotkeyStatus>("get_hotkey_status");
} catch (err) {
console.warn("get_hotkey_status invocation failed", err);
return;
}
if (status.kind === "pending") {
await new Promise((r) => setTimeout(r, 50));
continue;
}
useOverlayStore.getState().setHotkeyError(
status.kind === "failed" ? status.error : null,
);
return;
}
// Setup never completed — leave hotkeyError null and log; user can retry.
console.warn("hotkey status remained pending after retries");
}
```
- [ ] **Step 4: Verify tsc accepts everything**
Run: `npx tsc --noEmit`
Expected: 0 errors.
- [ ] **Step 5: Run frontend tests**
Run: `npm test`
Expected: all tests pass (URL + debounce + store).
- [ ] **Step 6: Commit**
```bash
git add browserlay/src/App.tsx browserlay/src/main.tsx browserlay/src/lib/hotkey.ts
git commit -m "feat: wire App + main with persist + hooks"
```
---
## Task 14: Add Rust plugin crates and tokio
**Files:**
- Modify: `browserlay/src-tauri/Cargo.toml`
- [ ] **Step 1: Add plugin dependencies**
Open `browserlay/src-tauri/Cargo.toml` and replace its `[dependencies]` block with:
```toml
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-store = "2"
tokio = { version = "1", features = ["time", "macros"] }
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-window-state = "2"
tauri-plugin-global-shortcut = "2"
```
- [ ] **Step 2: Verify the crate builds**
Run from `browserlay/src-tauri/`: `cargo check`
Expected: compiles (may take a while on first fetch).
- [ ] **Step 3: Commit**
```bash
git add browserlay/src-tauri/Cargo.toml browserlay/src-tauri/Cargo.lock
git commit -m "chore(rust): add store, window-state, global-shortcut, tokio"
```
---
## Task 15: Capability files
**Files:**
- Modify: `browserlay/src-tauri/capabilities/default.json`
- Create: `browserlay/src-tauri/capabilities/overlay.json`
- [ ] **Step 1: Update default capability for the main window**
Open `browserlay/src-tauri/capabilities/default.json` and replace its contents with:
```json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main control window",
"windows": ["main"],
"permissions": [
"core:default",
"core:webview:allow-create-webview-window",
"core:window:allow-set-always-on-top",
"core:window:allow-set-ignore-cursor-events",
"core:window:allow-close",
"core:window:allow-current-monitor",
"opener:default",
"store:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered"
]
}
```
> Note: `core:window:allow-set-opacity` is NOT a valid Tauri 2.11.x permission identifier — opacity is only exposed on the Rust `WebviewWindow`, not via Tauri's IPC ACL. Our custom `set_window_opacity` command (Task 16) doesn't need a per-command capability since `core:default` includes invocation of arbitrary app commands.
- [ ] **Step 2: Create the overlay capability**
Create `browserlay/src-tauri/capabilities/overlay.json` with:
```json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "overlay",
"description": "Locked-down capability for the overlay window — pages loaded here cannot call Tauri APIs",
"windows": ["overlay"],
"permissions": []
}
```
- [ ] **Step 3: Commit**
```bash
git add browserlay/src-tauri/capabilities/default.json browserlay/src-tauri/capabilities/overlay.json
git commit -m "feat(security): per-window capabilities, overlay locked down"
```
---
## Task 16: Rust commands module + click-through toggle
**Files:**
- Create: `browserlay/src-tauri/src/commands/mod.rs`
- Create: `browserlay/src-tauri/src/commands/overlay.rs`
- [ ] **Step 1: Create the commands module file**
Create `browserlay/src-tauri/src/commands/mod.rs` with:
```rust
pub mod overlay;
```
- [ ] **Step 2: Implement overlay commands and the shared toggle helper**
Create `browserlay/src-tauri/src/commands/overlay.rs` with:
```rust
use std::time::Duration;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, Runtime};
pub const OVERLAY_LABEL: &str = "overlay";
pub const EVT_CLICK_THROUGH_TOGGLED: &str = "click-through-toggled";
pub const EVT_CLICK_THROUGH_NO_OVERLAY: &str = "click-through-no-overlay";
#[derive(Clone, Serialize)]
pub struct ClickThroughToggledPayload {
#[serde(rename = "clickThrough")]
pub click_through: bool,
}
/// Applies a specific click-through state to the overlay (sets ignore_cursor_events,
/// performs the opacity pulse, emits the toggled event). Returns the applied state.
///
/// Tauri 2's WebviewWindow doesn't expose getters for `ignore_cursor_events` or
/// `opacity`, so callers must compute the new state themselves: the JS-side
/// `toggle_click_through` command receives `current` from the front end and
/// passes `!current`; the global shortcut handler maintains its own cache via
/// `AppState.click_through`.
pub async fn apply_state<R: Runtime>(
app: &AppHandle<R>,
new_state: bool,
) -> Result<Option<bool>, String> {
let Some(overlay) = app.get_webview_window(OVERLAY_LABEL) else {
let _ = app.emit(EVT_CLICK_THROUGH_NO_OVERLAY, ());
return Ok(None);
};
overlay
.set_ignore_cursor_events(new_state)
.map_err(|e| e.to_string())?;
// Opacity dip half of the visual pulse. The JS click-through-toggled
// handler is responsible for restoring opacity to the user's actual
// target — Rust deliberately does NOT call set_opacity again, so there
// is no possibility of the global-shortcut path "snapping back" to a
// wrong value (we don't have a current-opacity getter).
let _ = overlay.set_opacity(0.4);
tokio::time::sleep(Duration::from_millis(180)).await;
app.emit(
EVT_CLICK_THROUGH_TOGGLED,
ClickThroughToggledPayload {
click_through: new_state,
},
)
.map_err(|e| e.to_string())?;
Ok(Some(new_state))
}
/// Tauri command — invoked from JS. The JS side passes the *current* state so
/// we can compute the inverse without needing a getter on the Tauri side.
#[tauri::command]
pub async fn toggle_click_through<R: Runtime>(
app: AppHandle<R>,
current: bool,
) -> Result<Option<bool>, String> {
apply_state(&app, !current).await
}
/// Stateless setter — used by the global shortcut handler, which doesn't have
/// access to the JS-side current value. Reads the *intended* state from a
/// shared `Mutex<bool>` kept in app state.
pub async fn toggle_via_global_shortcut<R: Runtime>(
app: &AppHandle<R>,
) -> Result<(), String> {
let state = app.state::<crate::AppState>();
let new_value = {
let mut guard = state.click_through.lock().expect("poisoned");
*guard = !*guard;
*guard
};
apply_state(app, new_value).await?;
Ok(())
}
/// Sync the Rust-side click-through cache with what the JS side just applied.
/// Called on overlay open/close and whenever the JS toggle changes user-side.
#[tauri::command]
pub fn sync_click_through_cache(
app: AppHandle,
value: bool,
) -> Result<(), String> {
let state = app.state::<crate::AppState>();
let mut guard = state.click_through.lock().map_err(|_| "poisoned".to_string())?;
*guard = value;
Ok(())
}
/// Pull the hotkey-registration outcome that was decided during setup.
/// Pull-style avoids the listener-not-yet-registered race that an event-based
/// approach would have.
#[tauri::command]
pub fn get_hotkey_status(app: AppHandle) -> Result<crate::HotkeyStatus, String> {
let state = app.state::<crate::AppState>();
let guard = state.hotkey_status.lock().map_err(|_| "poisoned".to_string())?;
Ok(guard.clone())
}
/// Set window opacity. Tauri 2.11 does NOT expose set_opacity on either side
/// (open issue tauri-apps/tauri#3279), so we drop down to Win32 directly via
/// SetLayeredWindowAttributes. See `apply_window_opacity` for details.
#[tauri::command]
pub fn set_window_opacity<R: Runtime>(
app: AppHandle<R>,
label: String,
opacity: f32,
) -> Result<(), String> {
let Some(window) = app.get_webview_window(&label) else {
return Err(format!("window not found: {label}"));
};
apply_window_opacity(&window, opacity)
}
```
> **Window opacity implementation note:** Tauri 2.11.x's `WebviewWindow` doesn't expose `set_opacity` on either the JS or Rust side (long-running open issue tauri-apps/tauri#3279). To get true OS-level window translucency we drop down to the Win32 API directly. Pin `windows = "0.61"` in `Cargo.toml` to match Tauri's transitive `windows` version (otherwise `WebviewWindow::hwnd()` returns an HWND from a different `windows` major and you get a diamond-dependency type mismatch). The `apply_window_opacity` helper sets `WS_EX_LAYERED` if not already present, then calls `SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA)`.
> **Note on the design:** Tauri 2 doesn't expose a getter for `ignore_cursor_events` / `opacity` on `WebviewWindow`. Rather than rely on getters, we keep a tiny Rust-side cache (`AppState.click_through`) that the JS side keeps in sync via `sync_click_through_cache`. The global shortcut handler reads this cache, computes the inverse, calls `apply_state`. The JS-driven toggle path uses `toggle_click_through(current)` and skips the cache.
- [ ] **Step 3: Commit**
```bash
git add browserlay/src-tauri/src/commands
git commit -m "feat(rust): commands module with click-through toggle helpers"
```
---
## Task 17: Rust lib.rs — register plugins, shortcut, commands
**Files:**
- Modify: `browserlay/src-tauri/src/lib.rs`
- [ ] **Step 1: Replace lib.rs**
Open `browserlay/src-tauri/src/lib.rs` and replace its contents with:
```rust
mod commands;
use std::sync::Mutex;
use serde::Serialize;
use tauri::Manager;
#[cfg(desktop)]
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
pub struct AppState {
pub click_through: Mutex<bool>,
/// Hotkey registration outcome, set during setup. JS pulls this via
/// `get_hotkey_status` after the listener-not-yet-registered window is
/// safely past, so the result is never lost to a race.
pub hotkey_status: Mutex<HotkeyStatus>,
}
#[derive(Clone, Serialize, Default)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum HotkeyStatus {
#[default]
Pending,
Ok,
Failed { error: String },
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(AppState {
click_through: Mutex::new(false),
hotkey_status: Mutex::new(HotkeyStatus::Pending),
})
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_store::Builder::new().build())
.setup(|app| {
#[cfg(desktop)]
{
app.handle()
.plugin(tauri_plugin_window_state::Builder::default().build())?;
let shortcut = Shortcut::new(
Some(Modifiers::CONTROL | Modifiers::ALT),
Code::Space,
);
let shortcut_for_handler = shortcut;
app.handle().plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, sc, event| {
if sc != &shortcut_for_handler {
return;
}
if event.state() != ShortcutState::Pressed {
return;
}
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(err) =
commands::overlay::toggle_via_global_shortcut(&app_handle).await
{
eprintln!("global shortcut toggle failed: {err}");
}
});
})
.build(),
)?;
let registration_result = app.global_shortcut().register(shortcut);
let status = match &registration_result {
Ok(()) => HotkeyStatus::Ok,
Err(e) => {
eprintln!("Failed to register Ctrl+Alt+Space: {e}");
HotkeyStatus::Failed {
error: "Hotkey unavailable — click-through escape disabled".to_string(),
}
}
};
{
let state = app.state::<AppState>();
*state.hotkey_status.lock().expect("poisoned") = status;
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::overlay::toggle_click_through,
commands::overlay::sync_click_through_cache,
commands::overlay::get_hotkey_status,
commands::overlay::set_window_opacity,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```
- [ ] **Step 2: Verify the crate compiles**
Run from `browserlay/src-tauri/`: `cargo check`
Expected: compiles cleanly.
- [ ] **Step 3: Commit**
```bash
git add browserlay/src-tauri/src/lib.rs
git commit -m "feat(rust): register plugins, global shortcut, commands"
```
---
## Task 18: Sync click-through cache on every JS-driven change
**Files:**
- Modify: `browserlay/src/hooks/useLiveOverlayWiring.ts`
`openOverlay` (Task 9) already exports `syncClickThroughCache` and seeds the Rust-side cache on open. This task ensures the cache stays in sync whenever the user toggles click-through from the control panel — without it, the first global-shortcut press after a UI toggle would compute the inverse from a stale cache.
- [ ] **Step 1: Update useLiveOverlayWiring to sync on every JS-driven change**
Open `browserlay/src/hooks/useLiveOverlayWiring.ts` and replace its contents with:
```ts
import { useEffect, useRef } from "react";
import { useOverlayStore } from "../lib/store";
import {
applyAlwaysOnTop,
applyClickThrough,
applyOpacity,
syncClickThroughCache,
} from "../lib/overlay";
/** Pushes Zustand changes to the overlay window in real time. No-op when overlay is closed. */
export function useLiveOverlayWiring(): void {
const opacity = useOverlayStore((s) => s.opacity);
const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
const clickThrough = useOverlayStore((s) => s.clickThrough);
const isOpen = useOverlayStore((s) => s.isOpen);
const lastAppliedClickThrough = useRef<boolean | null>(null);
useEffect(() => {
if (!isOpen) return;
void applyOpacity(opacity);
}, [opacity, isOpen]);
useEffect(() => {
if (!isOpen) return;
void applyAlwaysOnTop(alwaysOnTop);
}, [alwaysOnTop, isOpen]);
useEffect(() => {
if (!isOpen) return;
if (lastAppliedClickThrough.current === clickThrough) return;
lastAppliedClickThrough.current = clickThrough;
void applyClickThrough(clickThrough);
void syncClickThroughCache(clickThrough);
}, [clickThrough, isOpen]);
}
```
- [ ] **Step 2: Re-run tests + tsc**
Run: `npm test && npx tsc --noEmit`
Expected: all pass.
- [ ] **Step 3: Commit**
```bash
git add browserlay/src/hooks/useLiveOverlayWiring.ts
git commit -m "feat: keep rust click-through cache synced with UI toggle"
```
---
## Task 19: Manual verification of MVP
**Files:** none (manual testing)
- [ ] **Step 1: Run the app in dev mode**
From `browserlay/`: `npm run tauri dev`
Expected: control window opens with the new dark UI. No overlay window.
- [ ] **Step 2: URL validation checks**
In the URL field, type each of these and observe the result:
- `twitch.tv/foo` → no error, Open enabled.
- `not a url` → "That doesn't look like a URL" shown, Open disabled.
- `javascript:alert(1)` → "Only http and https URLs are allowed", Open disabled.
- `tauri://localhost` → same error.
- `https://example.com` → no error, Open enabled.
- [ ] **Step 3: Open overlay**
Type `https://example.com`, click "Open overlay". Expected: a frameless transparent window appears top-right of the primary monitor, ~800×600, showing example.com. The status bar pip turns active and the button changes to "Close overlay".
- [ ] **Step 4: Live opacity**
Drag the opacity slider from 100% down to ~30%. Expected: the overlay fades visibly in real time. Drag back up to 100% — restored.
- [ ] **Step 5: Always-on-top toggle**
Toggle AOT off. Click another window (e.g. an editor) so it covers where the overlay sits. Expected: the editor covers the overlay. Toggle AOT back on. Expected: the overlay raises above.
- [ ] **Step 6: Click-through off (interactive)**
Ensure click-through is off. Click on a link or button on the loaded page. Expected: it activates / scrolls / responds.
- [ ] **Step 7: Click-through on (pass-through)**
Toggle click-through on. Click where the overlay sits. Expected: the click goes through to the app underneath the overlay.
- [ ] **Step 8: Global hotkey escape**
With click-through still on, click on a different app entirely (so neither browserlay window has focus). Press `Ctrl+Alt+Space`. Expected: the overlay opacity blinks briefly; click-through is now off; the control panel toggle reflects the new state.
- [ ] **Step 9: Persistence — close and reopen**
Set URL to `https://example.com`, opacity to 50%, AOT off, click-through on. Close the app entirely. Reopen with `npm run tauri dev`. Expected: control panel shows the same URL, opacity, and toggle states.
- [ ] **Step 10: Window geometry persistence**
Open the overlay. Drag it to a different position and resize it. Close the overlay (via Close button). Reopen the overlay (same URL). Expected: it appears at the same position and size as before.
- [ ] **Step 11: Hotkey-conflict surfacing**
(Optional, only if you have a way to register `Ctrl+Alt+Space` from another app.) Run that other app first, then start browserlay. Expected: the StatusBar shows "⚠ Hotkey unavailable — click-through escape disabled".
- [ ] **Step 12: Closing the overlay via taskbar**
Open the overlay. Right-click its taskbar entry → Close window. Expected: control panel still functions; clicking Open again creates a fresh overlay.
- [ ] **Step 13: Production build**
Run from `browserlay/`: `npm run tauri build`
Expected: build completes; `src-tauri/target/release/bundle/msi/` (or `nsis/`) contains an installer. Run the installer; the installed app launches and behaves identically.
- [ ] **Step 14: Commit any fixes discovered during verification**
If steps 113 surfaced bugs, fix them with conventional-commit-style commits. If everything passes as-is, no commit needed for this step.
---
## Task 20: README + version tag
**Files:**
- Modify: `browserlay/README.md`
- [ ] **Step 1: Replace README.md**
Open `browserlay/README.md` and replace its contents with:
```markdown
# Browserlay
A Windows desktop app that creates a translucent, always-on-top browser overlay. Type a URL, drop the opacity, toggle click-through — keep a stream, dashboard, or doc page floating over your work.
## Features (Phase 1 / MVP)
- Frameless, transparent overlay window pinned over other apps.
- Real-time opacity slider (10%100%).
- Always-on-top toggle.
- Click-through toggle — mouse events pass through to the app underneath.
- **`Ctrl+Alt+Space`** global hotkey to toggle click-through from anywhere (the escape hatch when you can't click the overlay).
- Last URL, opacity, and toggle states persist between sessions.
- Window position and size remembered between sessions.
## Dev setup
Prereqs: Node 18+, Rust toolchain (`rustup`), Tauri 2 prereqs for Windows ([WebView2 runtime ships with Windows 11](https://tauri.app/start/prerequisites/)).
```bash
cd browserlay
npm install
npm run tauri dev
```
Frontend tests:
```bash
npm test
```
## Build
```bash
npm run tauri build
```
Outputs an MSI/NSIS installer under `src-tauri/target/release/bundle/`.
## Architecture
Two-window Tauri 2 app. Control window hosts a React+Tailwind+Zustand UI; overlay is a frameless transparent webview spawned at runtime pointing directly at the user-supplied URL. Click-through hotkey runs in Rust via `tauri-plugin-global-shortcut` so it works regardless of focus. Per-window capability files lock the overlay down — pages it loads can't call any Tauri APIs.
See [`docs/plans/2026-05-08-browserlay-mvp-design.md`](docs/plans/2026-05-08-browserlay-mvp-design.md) for the full design.
## Roadmap (Phase 2)
Presets · configurable hotkeys · system tray icon · edge-snap · multi-monitor placement · in-overlay toolbar.
```
- [ ] **Step 2: Commit**
```bash
git add browserlay/README.md
git commit -m "docs: README for Phase 1 MVP"
```
- [ ] **Step 3: Tag the release**
```bash
git tag -a v0.1.0 -m "Phase 1 MVP"
```
- [ ] **Step 4: Confirm with user before pushing**
Per the agreed workflow, do NOT push or push tags without explicit user approval. Surface this and wait:
> "All Task 19 verification checks passed. v0.1.0 tagged locally. OK to push to origin/main and push the tag?"
Once approved, run:
```bash
git push origin main
git push origin v0.1.0
```
---
## Done criteria for Phase 1
- All 14 verification steps in Task 19 pass on Windows 11.
- `npm run tauri build` produces a working installer.
- `v0.1.0` tag pushed to `origin/main` after user approval.
- README documents what the app does, dev setup, build commands, and the hotkey.
## Out of scope (Phase 2)
Tray icon, presets, configurable hotkeys, edge snap, multi-monitor placement UI, in-overlay toolbar, auto-update.