Files
browserlay/docs/superpowers/plans/2026-05-08-browserlay-mvp.md
Michael Chihlas 87843e3be4 docs(plan): drop core:window:allow-set-opacity
Not a real Tauri 2.11.x permission identifier. Discovered when cargo
check listed the actual valid permissions. Opacity is Rust-side only
and routes through our custom command.

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

70 KiB
Raw Blame History

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 asyncPromise<WebviewWindow | null>. Always await.
  • Capability prefixes are core:window:allow-* and core:webview:allow-* (Tauri 2).
  • Rust→JS event emit uses the Emitter trait (use tauri::Emitter).
  • Global shortcut handler fires on both Pressed and Released — filter to Pressed.
  • tauri-plugin-window-state saves alwaysOnTop by default (StateFlags::all()); we override at overlay-create time so the Zustand store is authoritative.
  • Tauri 2 Rust window lookup: app.get_webview_window(label) (from the Manager trait).

File Structure

Frontend (browserlay/src/)

  • Create main.tsx (replace scaffold) — React entry, hydrate persisted state before mounting <App />.
  • Create App.tsx (replace scaffold) — composes <ControlPanel />, mounts the live-wiring effect, mounts click-through-sync hook.
  • Create index.css (replace scaffold) — Tailwind v4 @import + @theme design tokens.
  • Create components/ControlPanel.tsx — top-level form layout.
  • Create components/UrlField.tsx — URL input + validation feedback.
  • Create components/OpacitySlider.tsx — slider + percentage readout.
  • Create components/ToggleRow.tsx — reusable toggle row.
  • Create components/OpenOverlayButton.tsx — Open / Close primary button.
  • Create components/StatusBar.tsx — footer with hotkey hint and warning area.
  • Create hooks/useClickThroughSync.ts — listens for click-through-toggled from Rust.
  • Create hooks/useLiveOverlayWiring.ts — pushes Zustand changes to overlay.
  • Create lib/store.ts — Zustand store.
  • Create lib/overlay.ts — wrapper over WebviewWindow APIs.
  • Create lib/url.tsnormalizeUrl, isAllowedUrl.
  • Create lib/persist.ts — load + debounced save against tauri-plugin-store.
  • Create lib/debounce.ts — small generic debounce helper.
  • Create types/overlay.tsOverlayState, 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.rstoggle_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:

{
  "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:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/tauri.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Browserlay</title>
    <style>
      html, body, #root { height: 100%; margin: 0; }
      html { background: #0b0b0d; color-scheme: dark; }
      body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
  • Step 3: Verify build still passes

Run from browserlay/: npm run build Expected: TypeScript compiles, Vite emits dist/. (Scaffold App still references App.css — that's fine for now; we delete it in Task 4.)

  • Step 4: Commit
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/:

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
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
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:

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:

@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
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

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
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:

export type OverlayPersistedState = {
  url: string;
  opacity: number;
  alwaysOnTop: boolean;
  clickThrough: boolean;
};

export type OverlayState = OverlayPersistedState & {
  isOpen: boolean;
  urlError: string | null;
  hotkeyError: string | null;
};

export type OverlayActions = {
  setUrl: (raw: string) => void;
  setOpacity: (v: number) => void;
  setAlwaysOnTop: (v: boolean) => void;
  setClickThrough: (v: boolean) => void;
  /** Internal: applies a click-through change that originated in Rust.
   *  Skips the live-wiring effect to avoid ping-pong with the backend. */
  _setClickThroughFromBackend: (v: boolean) => void;
  setHotkeyError: (msg: string | null) => void;
  setIsOpen: (v: boolean) => void;
  hydrate: (s: OverlayPersistedState) => void;
};

export type ClickThroughToggledPayload = { clickThrough: boolean };
export type ClickThroughNoOverlayPayload = Record<string, never>;

export const STORE_FILE = "browserlay.json";
export const STORE_KEY = "state";
export const OVERLAY_LABEL = "overlay";
export const EVT_CLICK_THROUGH_TOGGLED = "click-through-toggled";
export const EVT_CLICK_THROUGH_NO_OVERLAY = "click-through-no-overlay";
  • Step 2: Confirm tsc accepts the file

Run from browserlay/: npx tsc --noEmit Expected: 0 errors. (Other files don't reference these types yet.)

  • Step 3: Commit
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/:

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:

"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:

/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

const host = process.env.TAURI_DEV_HOST;

export default defineConfig(async () => ({
  plugins: [react(), tailwindcss()],
  clearScreen: false,
  server: {
    port: 1420,
    strictPort: true,
    host: host || false,
    hmr: host
      ? { protocol: "ws", host, port: 1421 }
      : undefined,
    watch: { ignored: ["**/src-tauri/**"] },
  },
  test: {
    environment: "node",
    include: ["src/**/*.test.ts"],
  },
}));
  • Step 4: Write the failing tests

Create browserlay/src/lib/url.test.ts with:

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:

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
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:

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:

export type Debounced<Args extends unknown[]> = ((...args: Args) => void) & {
  flush: () => void;
  cancel: () => void;
};

export function debounce<Args extends unknown[]>(
  fn: (...args: Args) => void,
  waitMs: number
): Debounced<Args> {
  let timer: ReturnType<typeof setTimeout> | null = null;
  let pendingArgs: Args | null = null;

  const debounced = (...args: Args): void => {
    pendingArgs = args;
    if (timer !== null) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      const a = pendingArgs!;
      pendingArgs = null;
      fn(...a);
    }, waitMs);
  };

  debounced.flush = (): void => {
    if (timer === null) return;
    clearTimeout(timer);
    timer = null;
    const a = pendingArgs!;
    pendingArgs = null;
    fn(...a);
  };

  debounced.cancel = (): void => {
    if (timer !== null) clearTimeout(timer);
    timer = null;
    pendingArgs = null;
  };

  return debounced;
}
  • Step 4: Run tests to verify they pass

Run: npm test Expected: all tests pass (URL tests + debounce tests).

  • Step 5: Commit
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:

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:

import { create } from "zustand";
import type { OverlayActions, OverlayPersistedState, OverlayState } from "../types/overlay";
import { validateUrl } from "./url";

const clamp = (v: number, min: number, max: number): number =>
  Math.min(max, Math.max(min, v));

export const useOverlayStore = create<OverlayState & OverlayActions>()((set) => ({
  url: "",
  opacity: 1.0,
  alwaysOnTop: true,
  clickThrough: false,
  isOpen: false,
  urlError: null,
  hotkeyError: null,

  setUrl: (raw) => {
    const result = validateUrl(raw);
    set({
      url: raw,
      urlError: result.ok ? null : raw.trim() === "" ? null : result.error,
    });
  },

  setOpacity: (v) => set({ opacity: clamp(v, 0.1, 1.0) }),

  setAlwaysOnTop: (v) => set({ alwaysOnTop: v }),

  setClickThrough: (v) => set({ clickThrough: v }),

  _setClickThroughFromBackend: (v) =>
    set((s) => (s.clickThrough === v ? s : { clickThrough: v })),

  setHotkeyError: (msg) => set({ hotkeyError: msg }),

  setIsOpen: (v) => set({ isOpen: v }),

  hydrate: (p: OverlayPersistedState) =>
    set({
      url: p.url,
      opacity: clamp(p.opacity, 0.1, 1.0),
      alwaysOnTop: p.alwaysOnTop,
      clickThrough: p.clickThrough,
      urlError: null,
    }),
}));
  • Step 4: Run tests to verify they pass

Run: npm test Expected: all tests pass.

  • Step 5: Commit
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:

import { invoke } from "@tauri-apps/api/core";
import {
  WebviewWindow,
  getCurrentWebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import { currentMonitor } from "@tauri-apps/api/window";
import { OVERLAY_LABEL } from "../types/overlay";

export type OpenOptions = {
  url: string;
  opacity: number;
  alwaysOnTop: boolean;
  clickThrough: boolean;
  /** Called when the overlay window is destroyed by *any* path (taskbar close,
   *  Alt+F4, our own closeOverlay, OS forced close). Use it to reconcile
   *  control-panel state. Fires exactly once per open. */
  onDestroyed: () => void;
};

const FIRST_OPEN_W = 800;
const FIRST_OPEN_H = 600;
const FIRST_OPEN_MARGIN = 24;

async function getOverlay(): Promise<WebviewWindow | null> {
  return await WebviewWindow.getByLabel(OVERLAY_LABEL);
}

export async function openOverlay(opts: OpenOptions): Promise<void> {
  const existing = await getOverlay();
  if (existing) {
    await closeOverlay();
  }

  // Default to top-right of primary monitor on first open;
  // tauri-plugin-window-state will restore prior geometry on subsequent opens.
  let x = 100;
  let y = 100;
  try {
    const monitor = await currentMonitor();
    if (monitor) {
      const scale = monitor.scaleFactor;
      const logicalW = monitor.size.width / scale;
      x = Math.round(logicalW - FIRST_OPEN_W - FIRST_OPEN_MARGIN);
      y = FIRST_OPEN_MARGIN;
    }
  } catch {
    // best-effort placement; fall through to defaults
  }

  const w = new WebviewWindow(OVERLAY_LABEL, {
    url: opts.url,
    title: "Browserlay overlay",
    width: FIRST_OPEN_W,
    height: FIRST_OPEN_H,
    x,
    y,
    decorations: false,
    transparent: true,
    alwaysOnTop: opts.alwaysOnTop,
    resizable: true,
    visible: true,
    skipTaskbar: false,
  });

  // Wait for the window to actually exist (or surface an OS error). Both
  // tauri://created and tauri://error fire at most once per window; we
  // unsubscribe whichever didn't win.
  await new Promise<void>((resolve, reject) => {
    let settled = false;
    let unlistenCreated: (() => void) | null = null;
    let unlistenError: (() => void) | null = null;

    void w.once("tauri://created", () => {
      if (settled) return;
      settled = true;
      unlistenError?.();
      resolve();
    }).then((u) => {
      unlistenCreated = u;
      if (settled) u();
    });

    void w.once("tauri://error", (e) => {
      if (settled) return;
      settled = true;
      unlistenCreated?.();
      reject(new Error(`Failed to create overlay window: ${JSON.stringify(e.payload)}`));
    }).then((u) => {
      unlistenError = u;
      if (settled) u();
    });
  });

  // Single source of truth for "overlay went away" — fires for *any* close
  // path (our closeOverlay, taskbar X, Alt+F4, OS-forced destruction).
  void w.once("tauri://destroyed", () => {
    opts.onDestroyed();
  });

  // Apply runtime-only state that's not part of the constructor.
  await w.setOpacity(opts.opacity);
  await w.setIgnoreCursorEvents(opts.clickThrough);
  // Seed the Rust-side click-through cache so the global shortcut handler
  // knows the current value when it computes the inverse.
  await syncClickThroughCache(opts.clickThrough);
}

export async function closeOverlay(): Promise<void> {
  const w = await getOverlay();
  if (!w) return;
  await w.close();
}

export async function applyOpacity(v: number): Promise<void> {
  const w = await getOverlay();
  if (!w) return;
  await w.setOpacity(v);
}

export async function applyAlwaysOnTop(v: boolean): Promise<void> {
  const w = await getOverlay();
  if (!w) return;
  await w.setAlwaysOnTop(v);
}

export async function applyClickThrough(v: boolean): Promise<void> {
  const w = await getOverlay();
  if (!w) return;
  await w.setIgnoreCursorEvents(v);
}

/** Returns true if the overlay window currently exists. */
export async function isOverlayOpen(): Promise<boolean> {
  return (await getOverlay()) !== null;
}

/** Convenience for the control window's own handle if needed elsewhere. */
export function getControlWindow(): WebviewWindow {
  return getCurrentWebviewWindow();
}

export async function syncClickThroughCache(value: boolean): Promise<void> {
  try {
    await invoke("sync_click_through_cache", { value });
  } catch (err) {
    console.warn("Failed to sync click-through cache to backend", err);
  }
}
  • Step 2: Verify tsc accepts the file

Run: npx tsc --noEmit Expected: 0 errors.

  • Step 3: Commit
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:

import { load, type Store } from "@tauri-apps/plugin-store";
import { STORE_FILE, STORE_KEY, type OverlayPersistedState } from "../types/overlay";
import { useOverlayStore } from "./store";
import { debounce } from "./debounce";

const DEFAULTS: OverlayPersistedState = {
  url: "",
  opacity: 1.0,
  alwaysOnTop: true,
  clickThrough: false,
};

let storePromise: Promise<Store> | null = null;
function getStore(): Promise<Store> {
  if (!storePromise) {
    storePromise = load(STORE_FILE, { autoSave: false });
  }
  return storePromise;
}

function isPersistedShape(v: unknown): v is OverlayPersistedState {
  if (!v || typeof v !== "object") return false;
  const o = v as Record<string, unknown>;
  return (
    typeof o.url === "string" &&
    typeof o.opacity === "number" &&
    typeof o.alwaysOnTop === "boolean" &&
    typeof o.clickThrough === "boolean"
  );
}

/** Load persisted state from disk and hydrate the Zustand store. Safe on first run. */
export async function hydrateFromDisk(): Promise<void> {
  try {
    const s = await getStore();
    const raw = await s.get<unknown>(STORE_KEY);
    const value = isPersistedShape(raw) ? raw : DEFAULTS;
    useOverlayStore.getState().hydrate(value);
  } catch (err) {
    console.warn("Failed to load persisted state; using defaults", err);
    useOverlayStore.getState().hydrate(DEFAULTS);
  }
}

// We persist on a 300ms debounce. The mirror below tracks "latest known
// pending state" so a synchronous flush at beforeunload time has something
// to issue — even though the actual write is unavoidably async.
let latestPending: OverlayPersistedState | null = null;

async function writeNow(state: OverlayPersistedState): Promise<void> {
  try {
    const s = await getStore();
    await s.set(STORE_KEY, state);
    await s.save();
  } catch (err) {
    console.warn("Failed to persist state", err);
  }
}

const writeDebounced = debounce((state: OverlayPersistedState): void => {
  latestPending = null;
  void writeNow(state);
}, 300);

/** Subscribe to store changes and persist the four user-pref fields. */
export function startPersistSubscription(): () => void {
  return useOverlayStore.subscribe((s) => {
    const snapshot: OverlayPersistedState = {
      url: s.url,
      opacity: s.opacity,
      alwaysOnTop: s.alwaysOnTop,
      clickThrough: s.clickThrough,
    };
    latestPending = snapshot;
    writeDebounced(snapshot);
  });
}

/**
 * Best-effort synchronous flush for beforeunload. The actual disk write is
 * unavoidably async — we fire-and-forget it. If the window is destroyed
 * before the write lands, the prior debounce-tick's value (at most 300ms
 * stale) will be loaded next session. That bounded staleness is the
 * accepted trade-off for a Phase 1 utility app.
 */
export function flushPendingPersistSync(): void {
  if (latestPending === null) return;
  const snapshot = latestPending;
  latestPending = null;
  writeDebounced.cancel();
  void writeNow(snapshot);
}
  • Step 2: Verify tsc accepts the file

Run: npx tsc --noEmit Expected: 0 errors.

  • Step 3: Commit
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:

import { useEffect, useRef } from "react";
import { useOverlayStore } from "../lib/store";
import {
  applyAlwaysOnTop,
  applyClickThrough,
  applyOpacity,
} from "../lib/overlay";

/** Pushes Zustand changes to the overlay window in real time. No-op when overlay is closed. */
export function useLiveOverlayWiring(): void {
  const opacity = useOverlayStore((s) => s.opacity);
  const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
  const clickThrough = useOverlayStore((s) => s.clickThrough);
  const isOpen = useOverlayStore((s) => s.isOpen);

  // Track whether the *previous* clickThrough value came from the backend so we
  // don't bounce it back. A simple ref counter works because the silent setter
  // is the only path that calls _setClickThroughFromBackend.
  const lastAppliedClickThrough = useRef<boolean | null>(null);

  useEffect(() => {
    if (!isOpen) return;
    void applyOpacity(opacity);
  }, [opacity, isOpen]);

  useEffect(() => {
    if (!isOpen) return;
    void applyAlwaysOnTop(alwaysOnTop);
  }, [alwaysOnTop, isOpen]);

  useEffect(() => {
    if (!isOpen) return;
    if (lastAppliedClickThrough.current === clickThrough) return;
    lastAppliedClickThrough.current = clickThrough;
    void applyClickThrough(clickThrough);
  }, [clickThrough, isOpen]);
}

Note: The "did this change come from the backend" guard above is informal — it relies on lastAppliedClickThrough being equal to the value just written by _setClickThroughFromBackend. The sync hook below will set this ref before updating the store, so the useEffect no-ops.

  • Step 2: Implement click-through sync hook

Create browserlay/src/hooks/useClickThroughSync.ts with:

import { useEffect } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useOverlayStore } from "../lib/store";
import { applyOpacity } from "../lib/overlay";
import {
  EVT_CLICK_THROUGH_NO_OVERLAY,
  EVT_CLICK_THROUGH_TOGGLED,
  type ClickThroughToggledPayload,
} from "../types/overlay";

/** Subscribes to backend events about click-through state and toast about no-overlay attempts. */
export function useClickThroughSync(
  onNoOverlay: () => void
): void {
  useEffect(() => {
    let unlistenToggled: UnlistenFn | undefined;
    let unlistenNoOverlay: UnlistenFn | undefined;
    let cancelled = false;

    (async () => {
      unlistenToggled = await listen<ClickThroughToggledPayload>(
        EVT_CLICK_THROUGH_TOGGLED,
        (e) => {
          if (cancelled) return;
          useOverlayStore.getState()._setClickThroughFromBackend(
            e.payload.clickThrough
          );
          // Restore the user's target opacity. Rust performed the dip-half of
          // the pulse and emitted this event after sleeping ~180ms; we own
          // the restore. Reading from the live store guarantees we restore
          // to whatever the slider says *now*, not whatever it said when the
          // hotkey was pressed.
          void applyOpacity(useOverlayStore.getState().opacity);
        }
      );
      unlistenNoOverlay = await listen(EVT_CLICK_THROUGH_NO_OVERLAY, () => {
        if (cancelled) return;
        onNoOverlay();
      });
    })().catch((err) => console.warn("event listen failed", err));

    return () => {
      cancelled = true;
      unlistenToggled?.();
      unlistenNoOverlay?.();
    };
  }, [onNoOverlay]);
}
  • Step 3: Verify tsc accepts the files

Run: npx tsc --noEmit Expected: 0 errors. (onNoOverlay callback type is inferred as () => void.)

  • Step 4: Commit
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:

import { Globe } from "lucide-react";
import { useOverlayStore } from "../lib/store";

export function UrlField(): JSX.Element {
  const url = useOverlayStore((s) => s.url);
  const urlError = useOverlayStore((s) => s.urlError);
  const setUrl = useOverlayStore((s) => s.setUrl);

  return (
    <div className="flex flex-col gap-1.5">
      <label htmlFor="overlay-url" className="text-xs uppercase tracking-wide text-[var(--color-muted)]">
        URL
      </label>
      <div
        className={`flex items-center gap-2 rounded-[var(--radius-md)] border px-3 py-2 transition-colors
                    bg-[var(--color-surface)]
                    ${urlError ? "border-[var(--color-danger)]" : "border-[var(--color-border)] focus-within:border-[var(--color-border-strong)]"}`}
      >
        <Globe size={16} className="text-[var(--color-muted)]" aria-hidden="true" />
        <input
          id="overlay-url"
          type="text"
          inputMode="url"
          autoComplete="off"
          spellCheck={false}
          placeholder="twitch.tv/somechannel"
          value={url}
          onChange={(e) => setUrl(e.currentTarget.value)}
          className="w-full bg-transparent outline-none text-[var(--color-text)] placeholder:text-[var(--color-muted)]"
        />
      </div>
      {urlError && (
        <p className="text-xs text-[var(--color-danger)]">{urlError}</p>
      )}
    </div>
  );
}
  • Step 2: OpacitySlider

Create browserlay/src/components/OpacitySlider.tsx with:

import { useOverlayStore } from "../lib/store";

export function OpacitySlider(): JSX.Element {
  const opacity = useOverlayStore((s) => s.opacity);
  const setOpacity = useOverlayStore((s) => s.setOpacity);
  const pct = Math.round(opacity * 100);

  return (
    <div className="flex flex-col gap-1.5">
      <div className="flex items-center justify-between">
        <label htmlFor="opacity" className="text-xs uppercase tracking-wide text-[var(--color-muted)]">
          Opacity
        </label>
        <span className="text-xs tabular-nums text-[var(--color-muted-strong)]">{pct}%</span>
      </div>
      <input
        id="opacity"
        type="range"
        min={0.1}
        max={1.0}
        step={0.01}
        value={opacity}
        onChange={(e) => setOpacity(Number(e.currentTarget.value))}
        className="w-full accent-[var(--color-accent)]"
      />
    </div>
  );
}
  • Step 3: ToggleRow

Create browserlay/src/components/ToggleRow.tsx with:

import type { ReactNode } from "react";

export type ToggleRowProps = {
  label: string;
  description?: string;
  icon?: ReactNode;
  checked: boolean;
  onChange: (v: boolean) => void;
};

export function ToggleRow({ label, description, icon, checked, onChange }: ToggleRowProps): JSX.Element {
  return (
    <label className="flex items-center justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5 cursor-pointer hover:border-[var(--color-border-strong)] transition-colors">
      <span className="flex items-center gap-2.5 min-w-0">
        {icon && <span className="text-[var(--color-muted-strong)]" aria-hidden="true">{icon}</span>}
        <span className="flex flex-col min-w-0">
          <span className="text-sm text-[var(--color-text)]">{label}</span>
          {description && (
            <span className="text-xs text-[var(--color-muted)] truncate">{description}</span>
          )}
        </span>
      </span>
      <span
        role="switch"
        aria-checked={checked}
        aria-label={label}
        onClick={(e) => {
          e.preventDefault();
          onChange(!checked);
        }}
        className={`relative h-5 w-9 shrink-0 rounded-full transition-colors
                    ${checked ? "bg-[var(--color-accent)]" : "bg-[var(--color-border-strong)]"}`}
      >
        <span
          className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all
                      ${checked ? "left-[calc(100%-1.125rem)]" : "left-0.5"}`}
        />
      </span>
      <input
        type="checkbox"
        checked={checked}
        onChange={(e) => onChange(e.currentTarget.checked)}
        className="sr-only"
      />
    </label>
  );
}
  • Step 4: OpenOverlayButton

Create browserlay/src/components/OpenOverlayButton.tsx with:

import { Play, Square } from "lucide-react";
import { useOverlayStore } from "../lib/store";
import { closeOverlay, openOverlay } from "../lib/overlay";
import { validateUrl } from "../lib/url";

export function OpenOverlayButton(): JSX.Element {
  const url = useOverlayStore((s) => s.url);
  const isOpen = useOverlayStore((s) => s.isOpen);
  const opacity = useOverlayStore((s) => s.opacity);
  const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
  const clickThrough = useOverlayStore((s) => s.clickThrough);
  const setIsOpen = useOverlayStore((s) => s.setIsOpen);

  const validation = validateUrl(url);
  const disabled = !isOpen && !validation.ok;

  async function handleClick(): Promise<void> {
    if (isOpen) {
      await closeOverlay();
      // The destroyed listener registered in openOverlay will also flip
      // isOpen, but call it here too so the UI reflects intent immediately.
      setIsOpen(false);
      return;
    }
    if (!validation.ok) return;
    await openOverlay({
      url: validation.url,
      opacity,
      alwaysOnTop,
      clickThrough,
      onDestroyed: () => {
        useOverlayStore.getState().setIsOpen(false);
      },
    });
    setIsOpen(true);
  }

  return (
    <button
      type="button"
      onClick={() => {
        void handleClick();
      }}
      disabled={disabled}
      className={`mt-1 flex items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 py-2.5 text-sm font-medium transition-colors
                  ${disabled
                    ? "bg-[var(--color-surface-elevated)] text-[var(--color-muted)] cursor-not-allowed"
                    : isOpen
                    ? "bg-[var(--color-surface-elevated)] text-[var(--color-text)] hover:bg-[var(--color-border-strong)]"
                    : "bg-[var(--color-accent)] text-black hover:bg-[var(--color-accent-hover)]"}`}
    >
      {isOpen ? <Square size={14} /> : <Play size={14} />}
      {isOpen ? "Close overlay" : "Open overlay"}
    </button>
  );
}
  • Step 5: StatusBar

Create browserlay/src/components/StatusBar.tsx with:

import { AlertTriangle, Keyboard } from "lucide-react";
import { useOverlayStore } from "../lib/store";

export type StatusBarProps = { transient: string | null };

export function StatusBar({ transient }: StatusBarProps): JSX.Element {
  const hotkeyError = useOverlayStore((s) => s.hotkeyError);
  const isOpen = useOverlayStore((s) => s.isOpen);

  return (
    <footer className="border-t border-[var(--color-border)] px-4 py-2 text-xs flex items-center justify-between text-[var(--color-muted)]">
      <span className="flex items-center gap-2">
        <span
          className={`h-1.5 w-1.5 rounded-full ${isOpen ? "bg-[var(--color-accent)]" : "bg-[var(--color-border-strong)]"}`}
          aria-hidden="true"
        />
        {isOpen ? "Overlay open" : "Overlay closed"}
      </span>
      <span className="flex items-center gap-3">
        {hotkeyError ? (
          <span className="flex items-center gap-1 text-[var(--color-warning)]">
            <AlertTriangle size={12} aria-hidden="true" /> {hotkeyError}
          </span>
        ) : (
          <span className="flex items-center gap-1">
            <Keyboard size={12} aria-hidden="true" /> Ctrl+Alt+Space toggles click-through
          </span>
        )}
        {transient && <span className="text-[var(--color-muted-strong)]">{transient}</span>}
      </span>
    </footer>
  );
}
  • Step 6: ControlPanel

Create browserlay/src/components/ControlPanel.tsx with:

import { Layers, MousePointerClick } from "lucide-react";
import { UrlField } from "./UrlField";
import { OpacitySlider } from "./OpacitySlider";
import { ToggleRow } from "./ToggleRow";
import { OpenOverlayButton } from "./OpenOverlayButton";
import { useOverlayStore } from "../lib/store";

export function ControlPanel(): JSX.Element {
  const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
  const setAlwaysOnTop = useOverlayStore((s) => s.setAlwaysOnTop);
  const clickThrough = useOverlayStore((s) => s.clickThrough);
  const setClickThrough = useOverlayStore((s) => s.setClickThrough);

  return (
    <section className="flex flex-1 flex-col gap-4 p-4">
      <header className="flex items-center gap-2">
        <span className="text-base font-semibold tracking-tight text-[var(--color-text)]">Browserlay</span>
      </header>
      <UrlField />
      <OpacitySlider />
      <div className="flex flex-col gap-2">
        <ToggleRow
          label="Always on top"
          description="Keep the overlay above other windows"
          icon={<Layers size={14} />}
          checked={alwaysOnTop}
          onChange={setAlwaysOnTop}
        />
        <ToggleRow
          label="Click-through"
          description="Mouse passes through to apps below"
          icon={<MousePointerClick size={14} />}
          checked={clickThrough}
          onChange={setClickThrough}
        />
      </div>
      <OpenOverlayButton />
    </section>
  );
}
  • Step 7: Verify tsc accepts the files

Run: npx tsc --noEmit Expected: 0 errors.

  • Step 8: Commit
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:

import { useCallback, useEffect, useState } from "react";
import { ControlPanel } from "./components/ControlPanel";
import { StatusBar } from "./components/StatusBar";
import { useLiveOverlayWiring } from "./hooks/useLiveOverlayWiring";
import { useClickThroughSync } from "./hooks/useClickThroughSync";
import { useOverlayStore } from "./lib/store";
import { isOverlayOpen } from "./lib/overlay";

function App(): JSX.Element {
  useLiveOverlayWiring();
  const setIsOpen = useOverlayStore((s) => s.setIsOpen);
  const [transient, setTransient] = useState<string | null>(null);

  // Reconcile isOpen with reality on mount (e.g. user closed overlay via taskbar last session).
  useEffect(() => {
    void isOverlayOpen().then(setIsOpen);
  }, [setIsOpen]);

  const handleNoOverlay = useCallback(() => {
    setTransient("No overlay to toggle");
    const t = setTimeout(() => setTransient(null), 2500);
    return () => clearTimeout(t);
  }, []);

  useClickThroughSync(handleNoOverlay);

  return (
    <main className="flex h-screen flex-col bg-[var(--color-bg)]">
      <ControlPanel />
      <StatusBar transient={transient} />
    </main>
  );
}

export default App;
  • Step 2: Replace main.tsx

Open browserlay/src/main.tsx and replace its contents with:

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { hydrateFromDisk, startPersistSubscription, flushPendingPersistSync } from "./lib/persist";
import { fetchHotkeyStatus } from "./lib/hotkey";

async function bootstrap(): Promise<void> {
  await hydrateFromDisk();
  startPersistSubscription();
  // Pull the hotkey registration result. Pull-style avoids the
  // listener-not-yet-registered race that an event-based approach would have.
  void fetchHotkeyStatus();

  // beforeunload handlers must be synchronous; we use a sync flush helper
  // that issues the write. The async save will race with window destruction
  // — whichever wins is fine, the write either lands or we retry next session
  // (the prior persisted value is at most 300ms stale).
  window.addEventListener("beforeunload", () => {
    flushPendingPersistSync();
  });

  ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
}

void bootstrap();
  • Step 3: Create the hotkey status puller

Pull-style intentionally: emitting the result from Rust setup would race against the JS listener registration, so we let JS pull on its own schedule.

Create browserlay/src/lib/hotkey.ts with:

import { invoke } from "@tauri-apps/api/core";
import { useOverlayStore } from "./store";

type HotkeyStatus =
  | { kind: "pending" }
  | { kind: "ok" }
  | { kind: "failed"; error: string };

/** Polls the backend until hotkey registration is decided, then surfaces any error
 *  via the store. Bounded retry: setup-time decision is essentially synchronous
 *  on the Rust side, so a missed first call is at worst a few-ms wait. */
export async function fetchHotkeyStatus(): Promise<void> {
  const maxAttempts = 20; // ~1 second total worst case
  for (let i = 0; i < maxAttempts; i++) {
    let status: HotkeyStatus;
    try {
      status = await invoke<HotkeyStatus>("get_hotkey_status");
    } catch (err) {
      console.warn("get_hotkey_status invocation failed", err);
      return;
    }
    if (status.kind === "pending") {
      await new Promise((r) => setTimeout(r, 50));
      continue;
    }
    useOverlayStore.getState().setHotkeyError(
      status.kind === "failed" ? status.error : null,
    );
    return;
  }
  // Setup never completed — leave hotkeyError null and log; user can retry.
  console.warn("hotkey status remained pending after retries");
}
  • Step 4: Verify tsc accepts everything

Run: npx tsc --noEmit Expected: 0 errors.

  • Step 5: Run frontend tests

Run: npm test Expected: all tests pass (URL + debounce + store).

  • Step 6: Commit
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:

[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
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:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main control window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "core:webview:allow-create-webview-window",
    "core:window:allow-set-always-on-top",
    "core:window:allow-set-ignore-cursor-events",
    "core:window:allow-close",
    "core:window:allow-current-monitor",
    "opener:default",
    "store:default",
    "global-shortcut:allow-register",
    "global-shortcut:allow-unregister",
    "global-shortcut:allow-is-registered"
  ]
}

Note: core:window:allow-set-opacity is NOT a valid Tauri 2.11.x permission identifier — opacity is only exposed on the Rust WebviewWindow, not via Tauri's IPC ACL. Our custom set_window_opacity command (Task 16) doesn't need a per-command capability since core:default includes invocation of arbitrary app commands.

  • Step 2: Create the overlay capability

Create browserlay/src-tauri/capabilities/overlay.json with:

{
  "$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
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:

pub mod overlay;
  • Step 2: Implement overlay commands and the shared toggle helper

Create browserlay/src-tauri/src/commands/overlay.rs with:

use std::time::Duration;

use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, Runtime};

pub const OVERLAY_LABEL: &str = "overlay";
pub const EVT_CLICK_THROUGH_TOGGLED: &str = "click-through-toggled";
pub const EVT_CLICK_THROUGH_NO_OVERLAY: &str = "click-through-no-overlay";

#[derive(Clone, Serialize)]
pub struct ClickThroughToggledPayload {
    #[serde(rename = "clickThrough")]
    pub click_through: bool,
}

/// Applies a specific click-through state to the overlay (sets ignore_cursor_events,
/// performs the opacity pulse, emits the toggled event). Returns the applied state.
///
/// Tauri 2's WebviewWindow doesn't expose getters for `ignore_cursor_events` or
/// `opacity`, so callers must compute the new state themselves: the JS-side
/// `toggle_click_through` command receives `current` from the front end and
/// passes `!current`; the global shortcut handler maintains its own cache via
/// `AppState.click_through`.
pub async fn apply_state<R: Runtime>(
    app: &AppHandle<R>,
    new_state: bool,
) -> Result<Option<bool>, String> {
    let Some(overlay) = app.get_webview_window(OVERLAY_LABEL) else {
        let _ = app.emit(EVT_CLICK_THROUGH_NO_OVERLAY, ());
        return Ok(None);
    };

    overlay
        .set_ignore_cursor_events(new_state)
        .map_err(|e| e.to_string())?;

    // Opacity dip half of the visual pulse. The JS click-through-toggled
    // handler is responsible for restoring opacity to the user's actual
    // target — Rust deliberately does NOT call set_opacity again, so there
    // is no possibility of the global-shortcut path "snapping back" to a
    // wrong value (we don't have a current-opacity getter).
    let _ = overlay.set_opacity(0.4);
    tokio::time::sleep(Duration::from_millis(180)).await;

    app.emit(
        EVT_CLICK_THROUGH_TOGGLED,
        ClickThroughToggledPayload {
            click_through: new_state,
        },
    )
    .map_err(|e| e.to_string())?;

    Ok(Some(new_state))
}

/// Tauri command — invoked from JS. The JS side passes the *current* state so
/// we can compute the inverse without needing a getter on the Tauri side.
#[tauri::command]
pub async fn toggle_click_through<R: Runtime>(
    app: AppHandle<R>,
    current: bool,
) -> Result<Option<bool>, String> {
    apply_state(&app, !current).await
}

/// Stateless setter — used by the global shortcut handler, which doesn't have
/// access to the JS-side current value. Reads the *intended* state from a
/// shared `Mutex<bool>` kept in app state.
pub async fn toggle_via_global_shortcut<R: Runtime>(
    app: &AppHandle<R>,
) -> Result<(), String> {
    let state = app.state::<crate::AppState>();
    let new_value = {
        let mut guard = state.click_through.lock().expect("poisoned");
        *guard = !*guard;
        *guard
    };
    apply_state(app, new_value).await?;
    Ok(())
}

/// Sync the Rust-side click-through cache with what the JS side just applied.
/// Called on overlay open/close and whenever the JS toggle changes user-side.
#[tauri::command]
pub fn sync_click_through_cache(
    app: AppHandle,
    value: bool,
) -> Result<(), String> {
    let state = app.state::<crate::AppState>();
    let mut guard = state.click_through.lock().map_err(|_| "poisoned".to_string())?;
    *guard = value;
    Ok(())
}

/// Pull the hotkey-registration outcome that was decided during setup.
/// Pull-style avoids the listener-not-yet-registered race that an event-based
/// approach would have.
#[tauri::command]
pub fn get_hotkey_status(app: AppHandle) -> Result<crate::HotkeyStatus, String> {
    let state = app.state::<crate::AppState>();
    let guard = state.hotkey_status.lock().map_err(|_| "poisoned".to_string())?;
    Ok(guard.clone())
}

/// Set window opacity. Tauri 2's JS WebviewWindow does NOT expose setOpacity
/// (only the Rust side does), so the JS layer routes through this command.
#[tauri::command]
pub fn set_window_opacity<R: Runtime>(
    app: AppHandle<R>,
    label: String,
    opacity: f32,
) -> Result<(), String> {
    let Some(window) = app.get_webview_window(&label) else {
        return Err(format!("window not found: {label}"));
    };
    window.set_opacity(opacity).map_err(|e| e.to_string())
}

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
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:

mod commands;

use std::sync::Mutex;

use serde::Serialize;
use tauri::Manager;

#[cfg(desktop)]
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};

pub struct AppState {
    pub click_through: Mutex<bool>,
    /// Hotkey registration outcome, set during setup. JS pulls this via
    /// `get_hotkey_status` after the listener-not-yet-registered window is
    /// safely past, so the result is never lost to a race.
    pub hotkey_status: Mutex<HotkeyStatus>,
}

#[derive(Clone, Serialize, Default)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum HotkeyStatus {
    #[default]
    Pending,
    Ok,
    Failed { error: String },
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(AppState {
            click_through: Mutex::new(false),
            hotkey_status: Mutex::new(HotkeyStatus::Pending),
        })
        .plugin(tauri_plugin_opener::init())
        .plugin(tauri_plugin_store::Builder::new().build())
        .setup(|app| {
            #[cfg(desktop)]
            {
                app.handle()
                    .plugin(tauri_plugin_window_state::Builder::default().build())?;

                let shortcut = Shortcut::new(
                    Some(Modifiers::CONTROL | Modifiers::ALT),
                    Code::Space,
                );
                let shortcut_for_handler = shortcut;

                app.handle().plugin(
                    tauri_plugin_global_shortcut::Builder::new()
                        .with_handler(move |app, sc, event| {
                            if sc != &shortcut_for_handler {
                                return;
                            }
                            if event.state() != ShortcutState::Pressed {
                                return;
                            }
                            let app_handle = app.clone();
                            tauri::async_runtime::spawn(async move {
                                if let Err(err) =
                                    commands::overlay::toggle_via_global_shortcut(&app_handle).await
                                {
                                    eprintln!("global shortcut toggle failed: {err}");
                                }
                            });
                        })
                        .build(),
                )?;

                let registration_result = app.global_shortcut().register(shortcut);
                let status = match &registration_result {
                    Ok(()) => HotkeyStatus::Ok,
                    Err(e) => {
                        eprintln!("Failed to register Ctrl+Alt+Space: {e}");
                        HotkeyStatus::Failed {
                            error: "Hotkey unavailable — click-through escape disabled".to_string(),
                        }
                    }
                };
                {
                    let state = app.state::<AppState>();
                    *state.hotkey_status.lock().expect("poisoned") = status;
                }
            }
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            commands::overlay::toggle_click_through,
            commands::overlay::sync_click_through_cache,
            commands::overlay::get_hotkey_status,
            commands::overlay::set_window_opacity,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
  • Step 2: Verify the crate compiles

Run from browserlay/src-tauri/: cargo check Expected: compiles cleanly.

  • Step 3: Commit
git add browserlay/src-tauri/src/lib.rs
git commit -m "feat(rust): register plugins, global shortcut, commands"

Task 18: Sync click-through cache on every JS-driven change

Files:

  • Modify: browserlay/src/hooks/useLiveOverlayWiring.ts

openOverlay (Task 9) already exports syncClickThroughCache and seeds the Rust-side cache on open. This task ensures the cache stays in sync whenever the user toggles click-through from the control panel — without it, the first global-shortcut press after a UI toggle would compute the inverse from a stale cache.

  • Step 1: Update useLiveOverlayWiring to sync on every JS-driven change

Open browserlay/src/hooks/useLiveOverlayWiring.ts and replace its contents with:

import { useEffect, useRef } from "react";
import { useOverlayStore } from "../lib/store";
import {
  applyAlwaysOnTop,
  applyClickThrough,
  applyOpacity,
  syncClickThroughCache,
} from "../lib/overlay";

/** Pushes Zustand changes to the overlay window in real time. No-op when overlay is closed. */
export function useLiveOverlayWiring(): void {
  const opacity = useOverlayStore((s) => s.opacity);
  const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
  const clickThrough = useOverlayStore((s) => s.clickThrough);
  const isOpen = useOverlayStore((s) => s.isOpen);

  const lastAppliedClickThrough = useRef<boolean | null>(null);

  useEffect(() => {
    if (!isOpen) return;
    void applyOpacity(opacity);
  }, [opacity, isOpen]);

  useEffect(() => {
    if (!isOpen) return;
    void applyAlwaysOnTop(alwaysOnTop);
  }, [alwaysOnTop, isOpen]);

  useEffect(() => {
    if (!isOpen) return;
    if (lastAppliedClickThrough.current === clickThrough) return;
    lastAppliedClickThrough.current = clickThrough;
    void applyClickThrough(clickThrough);
    void syncClickThroughCache(clickThrough);
  }, [clickThrough, isOpen]);
}
  • Step 2: Re-run tests + tsc

Run: npm test && npx tsc --noEmit Expected: all pass.

  • Step 3: Commit
git add browserlay/src/hooks/useLiveOverlayWiring.ts
git commit -m "feat: keep rust click-through cache synced with UI toggle"

Task 19: Manual verification of MVP

Files: none (manual testing)

  • Step 1: Run the app in dev mode

From browserlay/: npm run tauri dev Expected: control window opens with the new dark UI. No overlay window.

  • Step 2: URL validation checks

In the URL field, type each of these and observe the result:

  • twitch.tv/foo → no error, Open enabled.

  • not a url → "That doesn't look like a URL" shown, Open disabled.

  • javascript:alert(1) → "Only http and https URLs are allowed", Open disabled.

  • tauri://localhost → same error.

  • https://example.com → no error, Open enabled.

  • Step 3: Open overlay

Type https://example.com, click "Open overlay". Expected: a frameless transparent window appears top-right of the primary monitor, ~800×600, showing example.com. The status bar pip turns active and the button changes to "Close overlay".

  • Step 4: Live opacity

Drag the opacity slider from 100% down to ~30%. Expected: the overlay fades visibly in real time. Drag back up to 100% — restored.

  • Step 5: Always-on-top toggle

Toggle AOT off. Click another window (e.g. an editor) so it covers where the overlay sits. Expected: the editor covers the overlay. Toggle AOT back on. Expected: the overlay raises above.

  • Step 6: Click-through off (interactive)

Ensure click-through is off. Click on a link or button on the loaded page. Expected: it activates / scrolls / responds.

  • Step 7: Click-through on (pass-through)

Toggle click-through on. Click where the overlay sits. Expected: the click goes through to the app underneath the overlay.

  • Step 8: Global hotkey escape

With click-through still on, click on a different app entirely (so neither browserlay window has focus). Press Ctrl+Alt+Space. Expected: the overlay opacity blinks briefly; click-through is now off; the control panel toggle reflects the new state.

  • Step 9: Persistence — close and reopen

Set URL to https://example.com, opacity to 50%, AOT off, click-through on. Close the app entirely. Reopen with npm run tauri dev. Expected: control panel shows the same URL, opacity, and toggle states.

  • Step 10: Window geometry persistence

Open the overlay. Drag it to a different position and resize it. Close the overlay (via Close button). Reopen the overlay (same URL). Expected: it appears at the same position and size as before.

  • Step 11: Hotkey-conflict surfacing

(Optional, only if you have a way to register Ctrl+Alt+Space from another app.) Run that other app first, then start browserlay. Expected: the StatusBar shows "⚠ Hotkey unavailable — click-through escape disabled".

  • Step 12: Closing the overlay via taskbar

Open the overlay. Right-click its taskbar entry → Close window. Expected: control panel still functions; clicking Open again creates a fresh overlay.

  • Step 13: Production build

Run from browserlay/: npm run tauri build Expected: build completes; src-tauri/target/release/bundle/msi/ (or nsis/) contains an installer. Run the installer; the installed app launches and behaves identically.

  • Step 14: Commit any fixes discovered during verification

If steps 113 surfaced bugs, fix them with conventional-commit-style commits. If everything passes as-is, no commit needed for this step.


Task 20: README + version tag

Files:

  • Modify: browserlay/README.md

  • Step 1: Replace README.md

Open browserlay/README.md and replace its contents with:

# Browserlay

A Windows desktop app that creates a translucent, always-on-top browser overlay. Type a URL, drop the opacity, toggle click-through — keep a stream, dashboard, or doc page floating over your work.

## Features (Phase 1 / MVP)

- Frameless, transparent overlay window pinned over other apps.
- Real-time opacity slider (10%100%).
- Always-on-top toggle.
- Click-through toggle — mouse events pass through to the app underneath.
- **`Ctrl+Alt+Space`** global hotkey to toggle click-through from anywhere (the escape hatch when you can't click the overlay).
- Last URL, opacity, and toggle states persist between sessions.
- Window position and size remembered between sessions.

## Dev setup

Prereqs: Node 18+, Rust toolchain (`rustup`), Tauri 2 prereqs for Windows ([WebView2 runtime ships with Windows 11](https://tauri.app/start/prerequisites/)).

```bash
cd browserlay
npm install
npm run tauri dev

Frontend tests:

npm test

Build

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 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
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:

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.