# 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`. 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 ``. - Create `App.tsx` (replace scaffold) — composes ``, 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 Browserlay
``` - [ ] **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; 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 /// 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: Args) => void) & { flush: () => void; cancel: () => void; }; export function debounce( fn: (...args: Args) => void, waitMs: number ): Debounced { let timer: ReturnType | 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()((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 { return await WebviewWindow.getByLabel(OVERLAY_LABEL); } export async function openOverlay(opts: OpenOptions): Promise { 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((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 { const w = await getOverlay(); if (!w) return; await w.close(); } export async function applyOpacity(v: number): Promise { const w = await getOverlay(); if (!w) return; await w.setOpacity(v); } export async function applyAlwaysOnTop(v: boolean): Promise { const w = await getOverlay(); if (!w) return; await w.setAlwaysOnTop(v); } export async function applyClickThrough(v: boolean): Promise { const w = await getOverlay(); if (!w) return; await w.setIgnoreCursorEvents(v); } /** Returns true if the overlay window currently exists. */ export async function isOverlayOpen(): Promise { 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 { 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 | null = null; function getStore(): Promise { 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; 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 { try { const s = await getStore(); const raw = await s.get(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 { 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(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( 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 (
{urlError && (

{urlError}

)}
); } ``` - [ ] **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 (
{pct}%
setOpacity(Number(e.currentTarget.value))} className="w-full accent-[var(--color-accent)]" />
); } ``` - [ ] **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 ( ); } ``` - [ ] **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 { 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 ( ); } ``` - [ ] **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 (
{hotkeyError ? ( ) : ( )} {transient && {transient}}
); } ``` - [ ] **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 (
Browserlay
} checked={alwaysOnTop} onChange={setAlwaysOnTop} /> } checked={clickThrough} onChange={setClickThrough} />
); } ``` - [ ] **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(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 (
); } 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 { 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( ); } 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 { const maxAttempts = 20; // ~1 second total worst case for (let i = 0; i < maxAttempts; i++) { let status: HotkeyStatus; try { status = await invoke("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( app: &AppHandle, new_state: bool, ) -> Result, 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( app: AppHandle, current: bool, ) -> Result, 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` kept in app state. pub async fn toggle_via_global_shortcut( app: &AppHandle, ) -> Result<(), String> { let state = app.state::(); 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::(); 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 { let state = app.state::(); 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( app: AppHandle, 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, /// 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, } #[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 ®istration_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::(); *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(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 1–13 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.