diff --git a/docs/superpowers/plans/2026-05-08-browserlay-mvp.md b/docs/superpowers/plans/2026-05-08-browserlay-mvp.md new file mode 100644 index 0000000..7fdcb5c --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-browserlay-mvp.md @@ -0,0 +1,2160 @@ +# 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.