# 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 { WebviewWindow, getCurrentWebviewWindow, } from "@tauri-apps/api/webviewWindow"; import { LogicalPosition, LogicalSize, currentMonitor } from "@tauri-apps/api/window"; import { OVERLAY_LABEL } from "../types/overlay"; export type OpenOptions = { url: string; opacity: number; alwaysOnTop: boolean; clickThrough: boolean; }; 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, }); await new Promise((resolve, reject) => { const onCreated = w.once("tauri://created", () => { onError.then((u) => u()); resolve(); }); const onError = w.once("tauri://error", (e) => { onCreated.then((u) => u()); reject(new Error(`Failed to create overlay window: ${JSON.stringify(e.payload)}`)); }); }); // Apply runtime-only state that's not part of the constructor. await w.setOpacity(opts.opacity); await w.setIgnoreCursorEvents(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; } /** Used to listen for the overlay's destruction so we can sync isOpen. */ export async function onOverlayClosed(handler: () => void): Promise<() => void> { const w = await getOverlay(); if (!w) { handler(); return () => undefined; } const unlisten = await w.onCloseRequested(() => { handler(); }); return unlisten; } /** Convenience for the control window's own handle if needed elsewhere. */ export function getControlWindow(): WebviewWindow { return getCurrentWebviewWindow(); } ``` - [ ] **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); } } const writeToDisk = debounce(async (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); } }, 300); /** Subscribe to store changes and persist the four user-pref fields. */ export function startPersistSubscription(): () => void { return useOverlayStore.subscribe((s) => { writeToDisk({ url: s.url, opacity: s.opacity, alwaysOnTop: s.alwaysOnTop, clickThrough: s.clickThrough, }); }); } /** Force-write any pending debounced save (call on app exit). */ export function flushPendingPersist(): void { writeToDisk.flush(); } ``` - [ ] **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 { 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 ); } ); 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 urlError = useOverlayStore((s) => s.urlError); 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(); setIsOpen(false); return; } if (!validation.ok) return; await openOverlay({ url: validation.url, opacity, alwaysOnTop, clickThrough, }); 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, flushPendingPersist } from "./lib/persist"; import { registerHotkeyErrorListener } from "./lib/hotkey"; async function bootstrap(): Promise { await hydrateFromDisk(); startPersistSubscription(); await registerHotkeyErrorListener(); window.addEventListener("beforeunload", () => { flushPendingPersist(); }); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ); } void bootstrap(); ``` - [ ] **Step 3: Stub the hotkey error listener (real impl in Task 16)** Create `browserlay/src/lib/hotkey.ts` with: ```ts import { listen } from "@tauri-apps/api/event"; import { useOverlayStore } from "./store"; export const EVT_HOTKEY_REGISTRATION = "hotkey-registration"; export type HotkeyRegistrationPayload = | { ok: true } | { ok: false; error: string }; /** Listens for backend reports about whether the global shortcut registered successfully. */ export async function registerHotkeyErrorListener(): Promise { await listen(EVT_HOTKEY_REGISTRATION, (e) => { if (e.payload.ok) { useOverlayStore.getState().setHotkeyError(null); } else { useOverlayStore.getState().setHotkeyError(e.payload.error); } }); } ``` - [ ] **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-opacity", "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" ] } ``` - [ ] **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 pulse for visual feedback. // We don't have a getter for current opacity in Tauri 2's API, so we // restore to a value the JS side passes in via the command (or to 1.0 // for the global shortcut path, which we accept as a small visual quirk). // The JS side will re-apply the user's true target opacity via the // live-wiring effect immediately after the toggled event. let _ = overlay.set_opacity(0.4); tokio::time::sleep(Duration::from_millis(180)).await; // Best-effort restore; JS live-wiring will re-apply the real target. let _ = overlay.set_opacity(1.0); 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(()) } ``` > **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::{Emitter, Manager}; #[cfg(desktop)] use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; pub struct AppState { pub click_through: Mutex, } #[derive(Clone, Serialize)] struct HotkeyRegistrationPayload<'a> { ok: bool, error: Option<&'a str>, } const EVT_HOTKEY_REGISTRATION: &str = "hotkey-registration"; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .manage(AppState { click_through: Mutex::new(false), }) .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 payload = match ®istration_result { Ok(()) => HotkeyRegistrationPayload { ok: true, error: None, }, Err(e) => HotkeyRegistrationPayload { ok: false, error: Some( "Hotkey unavailable — click-through escape disabled", ), }, }; let _ = app.emit(EVT_HOTKEY_REGISTRATION, payload); if let Err(e) = registration_result { eprintln!("Failed to register Ctrl+Alt+Space: {e}"); } } Ok(()) }) .invoke_handler(tauri::generate_handler![ commands::overlay::toggle_click_through, commands::overlay::sync_click_through_cache, ]) .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. Warnings about unused `e` binding in the `Err(e)` arm are expected — silence by changing `Err(e) =>` to `Err(_) =>`. - [ ] **Step 3: Address the `Err(_)` warning** In the `match ®istration_result` block, change `Err(e) => HotkeyRegistrationPayload {` to `Err(_) => HotkeyRegistrationPayload {`. Re-run `cargo check`. Expected: clean. - [ ] **Step 4: Commit** ```bash git add browserlay/src-tauri/src/lib.rs git commit -m "feat(rust): register plugins, global shortcut, commands" ``` --- ## Task 18: Sync the Rust click-through cache from JS **Files:** - Modify: `browserlay/src/lib/overlay.ts` - Modify: `browserlay/src/hooks/useLiveOverlayWiring.ts` - Modify: `browserlay/src/components/OpenOverlayButton.tsx` The Rust handler needs to know the JS-side click-through truth so the global shortcut can toggle correctly when the JS side hasn't been the one driving the change. We sync on every JS-driven change. - [ ] **Step 1: Add a sync helper to lib/overlay.ts** Open `browserlay/src/lib/overlay.ts` and append this export at the bottom: ```ts import { invoke } from "@tauri-apps/api/core"; 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); } } ``` > Move the `import { invoke } from "@tauri-apps/api/core";` to the top of the file with the other imports. - [ ] **Step 2: Sync on overlay open** In `browserlay/src/lib/overlay.ts`, in `openOverlay`, after the `await w.setIgnoreCursorEvents(opts.clickThrough);` line, add: ```ts await syncClickThroughCache(opts.clickThrough); ``` - [ ] **Step 3: Sync on every JS-driven click-through change** Open `browserlay/src/hooks/useLiveOverlayWiring.ts` and replace the `useEffect` that handles click-through with: ```ts useEffect(() => { if (!isOpen) return; if (lastAppliedClickThrough.current === clickThrough) return; lastAppliedClickThrough.current = clickThrough; void applyClickThrough(clickThrough); void syncClickThroughCache(clickThrough); }, [clickThrough, isOpen]); ``` Add to imports at top: `import { applyAlwaysOnTop, applyClickThrough, applyOpacity, syncClickThroughCache } from "../lib/overlay";` - [ ] **Step 4: Re-run tests + tsc** Run: `npm test && npx tsc --noEmit` Expected: all pass. - [ ] **Step 6: Commit** ```bash git add browserlay/src/lib/overlay.ts browserlay/src/hooks/useLiveOverlayWiring.ts git commit -m "feat: sync JS click-through state to rust cache for global shortcut" ``` --- ## 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. ![Phase 1 MVP](docs/screenshot.png) ## 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.