Patches: - Wire tauri://destroyed listener through onDestroyed callback so taskbar-close keeps isOpen in sync (was: defined onOverlayClosed but never wired). - Drop Rust-side opacity restore in apply_state; JS event handler reads useOverlayStore.getState().opacity and restores. Fixes hotkey-from-50%-opacity stuck at 100%. - Replace event-based hotkey-registration signal with pull-style AppState.hotkey_status + get_hotkey_status command. Eliminates the listener-not-yet-registered race. - Remove unused LogicalPosition/LogicalSize imports (would have failed tsc under noUnusedLocals). - Remove unused urlError selector in OpenOverlayButton (same). - Replace flushPendingPersist with flushPendingPersistSync that fires the write fire-and-forget; document the bounded staleness trade-off (<= 300ms loss on crash). - Drop broken docs/screenshot.png reference from README. - Collapse Task 18 down: syncClickThroughCache now lives in Task 9 alongside lib/overlay.ts; Task 18 only touches the live-wiring hook to keep the cache in sync on UI-side toggles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
69 KiB
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.getByLabelisasync→Promise<WebviewWindow | null>. Alwaysawait.- Capability prefixes are
core:window:allow-*andcore:webview:allow-*(Tauri 2). - Rust→JS event emit uses the
Emittertrait (use tauri::Emitter). - Global shortcut handler fires on both
PressedandReleased— filter toPressed. tauri-plugin-window-statesaves 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 theManagertrait).
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+@themedesign 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 forclick-through-toggledfrom Rust. - Create
hooks/useLiveOverlayWiring.ts— pushes Zustand changes to overlay. - Create
lib/store.ts— Zustand store. - Create
lib/overlay.ts— wrapper overWebviewWindowAPIs. - Create
lib/url.ts—normalizeUrl,isAllowedUrl. - Create
lib/persist.ts— load + debounced save againsttauri-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/viteplugin. - 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 themainwindow stays in config.
Backend (browserlay/src-tauri/)
- Modify
Cargo.toml— add plugin crates (tauri-plugin-store,tauri-plugin-window-state,tauri-plugin-global-shortcut) andtokiowithtimefeature 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_throughcommand + privatedo_togglehelper. - Modify
capabilities/default.json— add window/webview/store permissions formain. - Create
capabilities/overlay.json— minimal capability for theoverlaywindow (no Tauri APIs exposed to loaded page).
Docs / metadata
- Modify
README.md— what the app does, dev setup, build, hotkey documented. - Create
.gitignoreentries (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(addvitestdev 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
lastAppliedClickThroughbeing equal to the value just written by_setClickThroughFromBackend. The sync hook below will set this ref before updating the store, so theuseEffectno-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-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:
{
"$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())
}
Note on the design: Tauri 2 doesn't expose a getter for
ignore_cursor_events/opacityonWebviewWindow. Rather than rely on getters, we keep a tiny Rust-side cache (AppState.click_through) that the JS side keeps in sync viasync_click_through_cache. The global shortcut handler reads this cache, computes the inverse, callsapply_state. The JS-driven toggle path usestoggle_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 ®istration_result {
Ok(()) => HotkeyStatus::Ok,
Err(e) => {
eprintln!("Failed to register Ctrl+Alt+Space: {e}");
HotkeyStatus::Failed {
error: "Hotkey unavailable — click-through escape disabled".to_string(),
}
}
};
{
let state = app.state::<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,
])
.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 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:
# 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 buildproduces a working installer.v0.1.0tag pushed toorigin/mainafter 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.