Twenty bite-sized tasks covering project hygiene, Tailwind v4 setup, Zustand store + tested URL/debounce helpers, the WebviewWindow API boundary, persistence, Rust plugin registration with click-through toggle, capability files (overlay locked down), manual verification checklist, and README + tag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2161 lines
65 KiB
Markdown
2161 lines
65 KiB
Markdown
# Browserlay Phase 1 (MVP) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Ship a Windows desktop app that creates a translucent, always-on-top browser overlay window with real-time opacity, click-through, and AOT controls plus a global hotkey escape hatch.
|
||
|
||
**Architecture:** Two-window Tauri 2 app. Control window (`main`) hosts a React+Tailwind+Zustand control panel; overlay window (`overlay`) is spawned at runtime as a frameless transparent webview pointed directly at the user-supplied URL. A Rust-side global shortcut (`Ctrl+Alt+Space`) toggles click-through and emits an event back so the control panel reflects the new state.
|
||
|
||
**Tech Stack:** Tauri 2.x · Rust 2021 · React 19 · TypeScript strict · Vite · Tailwind CSS v4 (`@tailwindcss/vite` + `@theme`) · Zustand v5 · `lucide-react` · `tauri-plugin-store` · `tauri-plugin-window-state` · `tauri-plugin-global-shortcut`.
|
||
|
||
**Design doc:** `docs/plans/2026-05-08-browserlay-mvp-design.md`
|
||
|
||
**Reference resolved during planning** (from official Tauri 2 docs at v2.tauri.app):
|
||
- `WebviewWindow.getByLabel` is `async` → `Promise<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.ts` — `normalizeUrl`, `isAllowedUrl`.
|
||
- Create `lib/persist.ts` — load + debounced save against `tauri-plugin-store`.
|
||
- Create `lib/debounce.ts` — small generic debounce helper.
|
||
- Create `types/overlay.ts` — `OverlayState`, `OverlayActions`, payload types.
|
||
- Delete `App.css` (no longer needed; Tailwind takes over).
|
||
- Delete `assets/react.svg` (scaffold remnant).
|
||
|
||
### Frontend (`browserlay/`)
|
||
- Modify `package.json` — add deps (`zustand`, `lucide-react`, `@tauri-apps/plugin-store`, `@tauri-apps/plugin-window-state`, `@tauri-apps/plugin-global-shortcut`, `tailwindcss`, `@tailwindcss/vite`).
|
||
- Modify `vite.config.ts` — register `@tailwindcss/vite` plugin.
|
||
- Modify `tsconfig.json` — strict + `noUncheckedIndexedAccess` + path mapping if useful.
|
||
- Modify `index.html` — set title, ensure dark background to avoid white flash.
|
||
- Modify `src-tauri/tauri.conf.json` — add overlay window definition? **No** — overlay is created at runtime; only the `main` window stays in config.
|
||
|
||
### Backend (`browserlay/src-tauri/`)
|
||
- Modify `Cargo.toml` — add plugin crates (`tauri-plugin-store`, `tauri-plugin-window-state`, `tauri-plugin-global-shortcut`) and `tokio` with `time` feature for the pulse sleep.
|
||
- Modify `src/lib.rs` — slim builder, register plugins, register global shortcut at setup, expose commands.
|
||
- Create `src/commands/mod.rs` — module declarations.
|
||
- Create `src/commands/overlay.rs` — `toggle_click_through` command + private `do_toggle` helper.
|
||
- Modify `capabilities/default.json` — add window/webview/store permissions for `main`.
|
||
- Create `capabilities/overlay.json` — minimal capability for the `overlay` window (no Tauri APIs exposed to loaded page).
|
||
|
||
### Docs / metadata
|
||
- Modify `README.md` — what the app does, dev setup, build, hotkey documented.
|
||
- Create `.gitignore` entries (if missing) for build outputs already present.
|
||
|
||
---
|
||
|
||
## Task 1: Project hygiene baseline
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/tsconfig.json`
|
||
- Modify: `browserlay/index.html`
|
||
|
||
- [ ] **Step 1: Tighten tsconfig.json strictness**
|
||
|
||
Open `browserlay/tsconfig.json` and replace its contents with:
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2022",
|
||
"useDefineForClassFields": true,
|
||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||
"module": "ESNext",
|
||
"skipLibCheck": true,
|
||
"moduleResolution": "bundler",
|
||
"allowImportingTsExtensions": true,
|
||
"resolveJsonModule": true,
|
||
"isolatedModules": true,
|
||
"noEmit": true,
|
||
"jsx": "react-jsx",
|
||
"strict": true,
|
||
"noUnusedLocals": true,
|
||
"noUnusedParameters": true,
|
||
"noFallthroughCasesInSwitch": true,
|
||
"noImplicitReturns": true,
|
||
"noUncheckedIndexedAccess": true,
|
||
"exactOptionalPropertyTypes": true
|
||
},
|
||
"include": ["src"],
|
||
"references": [{ "path": "./tsconfig.node.json" }]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Set page title and dark base background**
|
||
|
||
Open `browserlay/index.html` and replace its contents with:
|
||
|
||
```html
|
||
<!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**
|
||
|
||
```bash
|
||
git add browserlay/tsconfig.json browserlay/index.html
|
||
git commit -m "chore: tighten tsconfig and set dark page base"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Add frontend dependencies
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/package.json`
|
||
|
||
- [ ] **Step 1: Install runtime dependencies**
|
||
|
||
From `browserlay/`:
|
||
|
||
```bash
|
||
npm install zustand@^5 lucide-react @tauri-apps/plugin-store @tauri-apps/plugin-window-state @tauri-apps/plugin-global-shortcut
|
||
```
|
||
|
||
- [ ] **Step 2: Install Tailwind v4 dev dependencies**
|
||
|
||
```bash
|
||
npm install -D tailwindcss @tailwindcss/vite
|
||
```
|
||
|
||
- [ ] **Step 3: Verify package.json**
|
||
|
||
Open `browserlay/package.json` and confirm dependencies now include `zustand`, `lucide-react`, `@tauri-apps/plugin-store`, `@tauri-apps/plugin-window-state`, `@tauri-apps/plugin-global-shortcut`; devDependencies include `tailwindcss` and `@tailwindcss/vite`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add browserlay/package.json browserlay/package-lock.json
|
||
git commit -m "chore: add frontend deps (zustand, lucide-react, tauri plugins, tailwind v4)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Wire Tailwind v4 into Vite
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/vite.config.ts`
|
||
- Create: `browserlay/src/index.css`
|
||
|
||
- [ ] **Step 1: Register the Tailwind Vite plugin**
|
||
|
||
Open `browserlay/vite.config.ts` and replace its contents with:
|
||
|
||
```ts
|
||
import { defineConfig } from "vite";
|
||
import react from "@vitejs/plugin-react";
|
||
import tailwindcss from "@tailwindcss/vite";
|
||
|
||
const host = process.env.TAURI_DEV_HOST;
|
||
|
||
export default defineConfig(async () => ({
|
||
plugins: [react(), tailwindcss()],
|
||
clearScreen: false,
|
||
server: {
|
||
port: 1420,
|
||
strictPort: true,
|
||
host: host || false,
|
||
hmr: host
|
||
? { protocol: "ws", host, port: 1421 }
|
||
: undefined,
|
||
watch: { ignored: ["**/src-tauri/**"] },
|
||
},
|
||
}));
|
||
```
|
||
|
||
- [ ] **Step 2: Create the Tailwind entry CSS with dark charcoal tokens**
|
||
|
||
Create `browserlay/src/index.css` with:
|
||
|
||
```css
|
||
@import "tailwindcss";
|
||
|
||
@theme {
|
||
--color-bg: #0b0b0d;
|
||
--color-surface: #141418;
|
||
--color-surface-elevated: #1c1c22;
|
||
--color-border: #2a2a31;
|
||
--color-border-strong: #3a3a44;
|
||
--color-text: #e8e8ec;
|
||
--color-muted: #9999a3;
|
||
--color-muted-strong: #c8c8d2;
|
||
--color-accent: #7c8cff;
|
||
--color-accent-hover: #93a0ff;
|
||
--color-danger: #ff6b6b;
|
||
--color-warning: #ffb84d;
|
||
|
||
--radius-sm: 6px;
|
||
--radius-md: 10px;
|
||
--radius-lg: 14px;
|
||
|
||
--shadow-soft: 0 1px 2px rgba(0,0,0,0.4), 0 4px 24px rgba(0,0,0,0.25);
|
||
|
||
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
}
|
||
|
||
html, body, #root { height: 100%; }
|
||
body {
|
||
background: var(--color-bg);
|
||
color: var(--color-text);
|
||
font-family: var(--font-sans);
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify Vite builds with Tailwind**
|
||
|
||
Run from `browserlay/`: `npm run build`
|
||
Expected: build completes; emitted CSS includes Tailwind utilities.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add browserlay/vite.config.ts browserlay/src/index.css
|
||
git commit -m "feat: wire tailwind v4 with charcoal design tokens"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Delete scaffold UI
|
||
|
||
**Files:**
|
||
- Delete: `browserlay/src/App.css`
|
||
- Delete: `browserlay/src/assets/react.svg`
|
||
|
||
- [ ] **Step 1: Remove scaffold files**
|
||
|
||
```bash
|
||
rm browserlay/src/App.css
|
||
rm browserlay/src/assets/react.svg
|
||
```
|
||
|
||
- [ ] **Step 2: Confirm `assets/` directory state**
|
||
|
||
If `browserlay/src/assets/` is now empty, leave it (we'll delete the directory only if no asset is needed by end of Phase 1).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -u browserlay/src
|
||
git commit -m "chore: remove scaffold App.css and react.svg"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Types module
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/types/overlay.ts`
|
||
|
||
- [ ] **Step 1: Define the shared types**
|
||
|
||
Create `browserlay/src/types/overlay.ts` with:
|
||
|
||
```ts
|
||
export type OverlayPersistedState = {
|
||
url: string;
|
||
opacity: number;
|
||
alwaysOnTop: boolean;
|
||
clickThrough: boolean;
|
||
};
|
||
|
||
export type OverlayState = OverlayPersistedState & {
|
||
isOpen: boolean;
|
||
urlError: string | null;
|
||
hotkeyError: string | null;
|
||
};
|
||
|
||
export type OverlayActions = {
|
||
setUrl: (raw: string) => void;
|
||
setOpacity: (v: number) => void;
|
||
setAlwaysOnTop: (v: boolean) => void;
|
||
setClickThrough: (v: boolean) => void;
|
||
/** Internal: applies a click-through change that originated in Rust.
|
||
* Skips the live-wiring effect to avoid ping-pong with the backend. */
|
||
_setClickThroughFromBackend: (v: boolean) => void;
|
||
setHotkeyError: (msg: string | null) => void;
|
||
setIsOpen: (v: boolean) => void;
|
||
hydrate: (s: OverlayPersistedState) => void;
|
||
};
|
||
|
||
export type ClickThroughToggledPayload = { clickThrough: boolean };
|
||
export type ClickThroughNoOverlayPayload = Record<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**
|
||
|
||
```bash
|
||
git add browserlay/src/types/overlay.ts
|
||
git commit -m "feat: add overlay state types and IPC constants"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: URL normalization + allowlist (with tests)
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/lib/url.ts`
|
||
- Create: `browserlay/src/lib/url.test.ts`
|
||
- Modify: `browserlay/package.json` (add `vitest` dev dep)
|
||
- Modify: `browserlay/vite.config.ts` (add test config)
|
||
|
||
- [ ] **Step 1: Add Vitest**
|
||
|
||
From `browserlay/`:
|
||
|
||
```bash
|
||
npm install -D vitest @types/node
|
||
```
|
||
|
||
- [ ] **Step 2: Add test script to package.json**
|
||
|
||
Open `browserlay/package.json` and inside `"scripts"` add a `"test": "vitest run"` entry. Final scripts block should look like:
|
||
|
||
```json
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "tsc && vite build",
|
||
"preview": "vite preview",
|
||
"tauri": "tauri",
|
||
"test": "vitest run"
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add Vitest config to vite.config.ts**
|
||
|
||
In `browserlay/vite.config.ts`, change the top of the file and add a `test` block. Replace its contents with:
|
||
|
||
```ts
|
||
/// <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:
|
||
|
||
```ts
|
||
import { describe, it, expect } from "vitest";
|
||
import { normalizeUrl, validateUrl } from "./url";
|
||
|
||
describe("normalizeUrl", () => {
|
||
it("prepends https:// to a bare domain", () => {
|
||
expect(normalizeUrl("twitch.tv/foo")).toBe("https://twitch.tv/foo");
|
||
});
|
||
it("preserves existing https://", () => {
|
||
expect(normalizeUrl("https://example.com")).toBe("https://example.com");
|
||
});
|
||
it("preserves existing http://", () => {
|
||
expect(normalizeUrl("http://example.com")).toBe("http://example.com");
|
||
});
|
||
it("trims surrounding whitespace", () => {
|
||
expect(normalizeUrl(" twitch.tv ")).toBe("https://twitch.tv");
|
||
});
|
||
});
|
||
|
||
describe("validateUrl", () => {
|
||
it("accepts a normal https URL", () => {
|
||
expect(validateUrl("https://twitch.tv/foo")).toEqual({ ok: true, url: "https://twitch.tv/foo" });
|
||
});
|
||
it("normalizes a bare domain", () => {
|
||
expect(validateUrl("twitch.tv")).toEqual({ ok: true, url: "https://twitch.tv/" });
|
||
});
|
||
it("rejects empty input", () => {
|
||
expect(validateUrl("")).toEqual({ ok: false, error: "Enter a URL" });
|
||
});
|
||
it("rejects whitespace-only input", () => {
|
||
expect(validateUrl(" ")).toEqual({ ok: false, error: "Enter a URL" });
|
||
});
|
||
it("rejects javascript: scheme", () => {
|
||
expect(validateUrl("javascript:alert(1)")).toEqual({
|
||
ok: false,
|
||
error: "Only http and https URLs are allowed",
|
||
});
|
||
});
|
||
it("rejects file:// scheme", () => {
|
||
expect(validateUrl("file:///c:/secret.html")).toEqual({
|
||
ok: false,
|
||
error: "Only http and https URLs are allowed",
|
||
});
|
||
});
|
||
it("rejects tauri:// scheme", () => {
|
||
expect(validateUrl("tauri://localhost/index.html")).toEqual({
|
||
ok: false,
|
||
error: "Only http and https URLs are allowed",
|
||
});
|
||
});
|
||
it("rejects data: scheme", () => {
|
||
expect(validateUrl("data:text/html,hi")).toEqual({
|
||
ok: false,
|
||
error: "Only http and https URLs are allowed",
|
||
});
|
||
});
|
||
it("rejects unparsable input", () => {
|
||
expect(validateUrl("not a url")).toEqual({
|
||
ok: false,
|
||
error: "That doesn't look like a URL",
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they fail**
|
||
|
||
Run from `browserlay/`: `npm test`
|
||
Expected: FAIL with "Cannot find module './url'" or similar.
|
||
|
||
- [ ] **Step 6: Implement the URL helpers**
|
||
|
||
Create `browserlay/src/lib/url.ts` with:
|
||
|
||
```ts
|
||
export type UrlValidation =
|
||
| { ok: true; url: string }
|
||
| { ok: false; error: string };
|
||
|
||
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
|
||
|
||
export function normalizeUrl(raw: string): string {
|
||
const trimmed = raw.trim();
|
||
if (trimmed === "") return trimmed;
|
||
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return trimmed;
|
||
return `https://${trimmed}`;
|
||
}
|
||
|
||
export function validateUrl(raw: string): UrlValidation {
|
||
const trimmed = raw.trim();
|
||
if (trimmed === "") return { ok: false, error: "Enter a URL" };
|
||
|
||
const candidate = normalizeUrl(trimmed);
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(candidate);
|
||
} catch {
|
||
return { ok: false, error: "That doesn't look like a URL" };
|
||
}
|
||
|
||
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
|
||
return { ok: false, error: "Only http and https URLs are allowed" };
|
||
}
|
||
if (!parsed.hostname) {
|
||
return { ok: false, error: "That doesn't look like a URL" };
|
||
}
|
||
return { ok: true, url: parsed.toString() };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Run tests to verify they pass**
|
||
|
||
Run: `npm test`
|
||
Expected: all 14 tests pass.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add browserlay/package.json browserlay/package-lock.json browserlay/vite.config.ts browserlay/src/lib/url.ts browserlay/src/lib/url.test.ts
|
||
git commit -m "feat: url normalization and http(s) allowlist"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Generic debounce helper (with test)
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/lib/debounce.ts`
|
||
- Create: `browserlay/src/lib/debounce.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `browserlay/src/lib/debounce.test.ts` with:
|
||
|
||
```ts
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||
import { debounce } from "./debounce";
|
||
|
||
describe("debounce", () => {
|
||
beforeEach(() => vi.useFakeTimers());
|
||
afterEach(() => vi.useRealTimers());
|
||
|
||
it("delays a single call by the wait period", () => {
|
||
const fn = vi.fn();
|
||
const d = debounce(fn, 100);
|
||
d("a");
|
||
expect(fn).not.toHaveBeenCalled();
|
||
vi.advanceTimersByTime(100);
|
||
expect(fn).toHaveBeenCalledTimes(1);
|
||
expect(fn).toHaveBeenCalledWith("a");
|
||
});
|
||
|
||
it("collapses rapid calls into a single trailing call", () => {
|
||
const fn = vi.fn();
|
||
const d = debounce(fn, 100);
|
||
d("a"); d("b"); d("c");
|
||
vi.advanceTimersByTime(100);
|
||
expect(fn).toHaveBeenCalledTimes(1);
|
||
expect(fn).toHaveBeenCalledWith("c");
|
||
});
|
||
|
||
it("flush() invokes pending call immediately", () => {
|
||
const fn = vi.fn();
|
||
const d = debounce(fn, 100);
|
||
d("a");
|
||
d.flush();
|
||
expect(fn).toHaveBeenCalledWith("a");
|
||
});
|
||
|
||
it("flush() with no pending call is a no-op", () => {
|
||
const fn = vi.fn();
|
||
const d = debounce(fn, 100);
|
||
d.flush();
|
||
expect(fn).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL with "Cannot find module './debounce'".
|
||
|
||
- [ ] **Step 3: Implement debounce**
|
||
|
||
Create `browserlay/src/lib/debounce.ts` with:
|
||
|
||
```ts
|
||
export type Debounced<Args 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**
|
||
|
||
```bash
|
||
git add browserlay/src/lib/debounce.ts browserlay/src/lib/debounce.test.ts
|
||
git commit -m "feat: debounce helper with flush"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Zustand store
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/lib/store.ts`
|
||
- Create: `browserlay/src/lib/store.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `browserlay/src/lib/store.test.ts` with:
|
||
|
||
```ts
|
||
import { describe, it, expect, beforeEach } from "vitest";
|
||
import { useOverlayStore } from "./store";
|
||
|
||
const initial = useOverlayStore.getState();
|
||
|
||
describe("useOverlayStore", () => {
|
||
beforeEach(() => useOverlayStore.setState(initial, true));
|
||
|
||
it("starts with sensible defaults", () => {
|
||
const s = useOverlayStore.getState();
|
||
expect(s.url).toBe("");
|
||
expect(s.opacity).toBe(1.0);
|
||
expect(s.alwaysOnTop).toBe(true);
|
||
expect(s.clickThrough).toBe(false);
|
||
expect(s.isOpen).toBe(false);
|
||
expect(s.urlError).toBe(null);
|
||
expect(s.hotkeyError).toBe(null);
|
||
});
|
||
|
||
it("setUrl validates and stores both url and urlError", () => {
|
||
useOverlayStore.getState().setUrl("twitch.tv");
|
||
expect(useOverlayStore.getState().url).toBe("twitch.tv");
|
||
expect(useOverlayStore.getState().urlError).toBe(null);
|
||
|
||
useOverlayStore.getState().setUrl("javascript:alert(1)");
|
||
expect(useOverlayStore.getState().urlError).toMatch(/http and https/);
|
||
});
|
||
|
||
it("setOpacity clamps to 0.1..1.0", () => {
|
||
useOverlayStore.getState().setOpacity(0.05);
|
||
expect(useOverlayStore.getState().opacity).toBe(0.1);
|
||
useOverlayStore.getState().setOpacity(2);
|
||
expect(useOverlayStore.getState().opacity).toBe(1.0);
|
||
useOverlayStore.getState().setOpacity(0.5);
|
||
expect(useOverlayStore.getState().opacity).toBe(0.5);
|
||
});
|
||
|
||
it("setAlwaysOnTop and setClickThrough mutate flags", () => {
|
||
useOverlayStore.getState().setAlwaysOnTop(false);
|
||
expect(useOverlayStore.getState().alwaysOnTop).toBe(false);
|
||
useOverlayStore.getState().setClickThrough(true);
|
||
expect(useOverlayStore.getState().clickThrough).toBe(true);
|
||
});
|
||
|
||
it("hydrate replaces persisted fields and clears errors", () => {
|
||
useOverlayStore.getState().setUrl("javascript:alert(1)");
|
||
useOverlayStore.getState().hydrate({
|
||
url: "https://example.com",
|
||
opacity: 0.6,
|
||
alwaysOnTop: false,
|
||
clickThrough: true,
|
||
});
|
||
const s = useOverlayStore.getState();
|
||
expect(s.url).toBe("https://example.com");
|
||
expect(s.opacity).toBe(0.6);
|
||
expect(s.alwaysOnTop).toBe(false);
|
||
expect(s.clickThrough).toBe(true);
|
||
expect(s.urlError).toBe(null);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL ("Cannot find module './store'").
|
||
|
||
- [ ] **Step 3: Implement the store**
|
||
|
||
Create `browserlay/src/lib/store.ts` with:
|
||
|
||
```ts
|
||
import { create } from "zustand";
|
||
import type { OverlayActions, OverlayPersistedState, OverlayState } from "../types/overlay";
|
||
import { validateUrl } from "./url";
|
||
|
||
const clamp = (v: number, min: number, max: number): number =>
|
||
Math.min(max, Math.max(min, v));
|
||
|
||
export const useOverlayStore = create<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**
|
||
|
||
```bash
|
||
git add browserlay/src/lib/store.ts browserlay/src/lib/store.test.ts
|
||
git commit -m "feat: zustand store for overlay state"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Overlay API boundary (`lib/overlay.ts`)
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/lib/overlay.ts`
|
||
|
||
This module is the only place that imports `@tauri-apps/api/webviewWindow`. We won't unit test it — its job is thin Tauri API delegation; the real test is the manual verification checklist (Task 19).
|
||
|
||
- [ ] **Step 1: Implement the overlay boundary**
|
||
|
||
Create `browserlay/src/lib/overlay.ts` with:
|
||
|
||
```ts
|
||
import {
|
||
WebviewWindow,
|
||
getCurrentWebviewWindow,
|
||
} from "@tauri-apps/api/webviewWindow";
|
||
import { LogicalPosition, LogicalSize, currentMonitor } from "@tauri-apps/api/window";
|
||
import { OVERLAY_LABEL } from "../types/overlay";
|
||
|
||
export type OpenOptions = {
|
||
url: string;
|
||
opacity: number;
|
||
alwaysOnTop: boolean;
|
||
clickThrough: boolean;
|
||
};
|
||
|
||
const FIRST_OPEN_W = 800;
|
||
const FIRST_OPEN_H = 600;
|
||
const FIRST_OPEN_MARGIN = 24;
|
||
|
||
async function getOverlay(): Promise<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,
|
||
});
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
const onCreated = w.once("tauri://created", () => {
|
||
onError.then((u) => u());
|
||
resolve();
|
||
});
|
||
const onError = w.once("tauri://error", (e) => {
|
||
onCreated.then((u) => u());
|
||
reject(new Error(`Failed to create overlay window: ${JSON.stringify(e.payload)}`));
|
||
});
|
||
});
|
||
|
||
// Apply runtime-only state that's not part of the constructor.
|
||
await w.setOpacity(opts.opacity);
|
||
await w.setIgnoreCursorEvents(opts.clickThrough);
|
||
}
|
||
|
||
export async function closeOverlay(): Promise<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;
|
||
}
|
||
|
||
/** Used to listen for the overlay's destruction so we can sync isOpen. */
|
||
export async function onOverlayClosed(handler: () => void): Promise<() => void> {
|
||
const w = await getOverlay();
|
||
if (!w) {
|
||
handler();
|
||
return () => undefined;
|
||
}
|
||
const unlisten = await w.onCloseRequested(() => {
|
||
handler();
|
||
});
|
||
return unlisten;
|
||
}
|
||
|
||
/** Convenience for the control window's own handle if needed elsewhere. */
|
||
export function getControlWindow(): WebviewWindow {
|
||
return getCurrentWebviewWindow();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify tsc accepts the file**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/lib/overlay.ts
|
||
git commit -m "feat: overlay window API boundary"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Persistence (load + debounced save)
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/lib/persist.ts`
|
||
|
||
- [ ] **Step 1: Implement persist**
|
||
|
||
Create `browserlay/src/lib/persist.ts` with:
|
||
|
||
```ts
|
||
import { load, type Store } from "@tauri-apps/plugin-store";
|
||
import { STORE_FILE, STORE_KEY, type OverlayPersistedState } from "../types/overlay";
|
||
import { useOverlayStore } from "./store";
|
||
import { debounce } from "./debounce";
|
||
|
||
const DEFAULTS: OverlayPersistedState = {
|
||
url: "",
|
||
opacity: 1.0,
|
||
alwaysOnTop: true,
|
||
clickThrough: false,
|
||
};
|
||
|
||
let storePromise: Promise<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);
|
||
}
|
||
}
|
||
|
||
const writeToDisk = debounce(async (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);
|
||
}
|
||
}, 300);
|
||
|
||
/** Subscribe to store changes and persist the four user-pref fields. */
|
||
export function startPersistSubscription(): () => void {
|
||
return useOverlayStore.subscribe((s) => {
|
||
writeToDisk({
|
||
url: s.url,
|
||
opacity: s.opacity,
|
||
alwaysOnTop: s.alwaysOnTop,
|
||
clickThrough: s.clickThrough,
|
||
});
|
||
});
|
||
}
|
||
|
||
/** Force-write any pending debounced save (call on app exit). */
|
||
export function flushPendingPersist(): void {
|
||
writeToDisk.flush();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify tsc accepts the file**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/lib/persist.ts
|
||
git commit -m "feat: load + debounced persist via tauri-plugin-store"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Live wiring + click-through sync hooks
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/hooks/useLiveOverlayWiring.ts`
|
||
- Create: `browserlay/src/hooks/useClickThroughSync.ts`
|
||
|
||
- [ ] **Step 1: Implement live wiring hook**
|
||
|
||
Create `browserlay/src/hooks/useLiveOverlayWiring.ts` with:
|
||
|
||
```ts
|
||
import { useEffect, useRef } from "react";
|
||
import { useOverlayStore } from "../lib/store";
|
||
import {
|
||
applyAlwaysOnTop,
|
||
applyClickThrough,
|
||
applyOpacity,
|
||
} from "../lib/overlay";
|
||
|
||
/** Pushes Zustand changes to the overlay window in real time. No-op when overlay is closed. */
|
||
export function useLiveOverlayWiring(): void {
|
||
const opacity = useOverlayStore((s) => s.opacity);
|
||
const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
|
||
const clickThrough = useOverlayStore((s) => s.clickThrough);
|
||
const isOpen = useOverlayStore((s) => s.isOpen);
|
||
|
||
// Track whether the *previous* clickThrough value came from the backend so we
|
||
// don't bounce it back. A simple ref counter works because the silent setter
|
||
// is the only path that calls _setClickThroughFromBackend.
|
||
const lastAppliedClickThrough = useRef<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:
|
||
|
||
```ts
|
||
import { useEffect } from "react";
|
||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||
import { useOverlayStore } from "../lib/store";
|
||
import {
|
||
EVT_CLICK_THROUGH_NO_OVERLAY,
|
||
EVT_CLICK_THROUGH_TOGGLED,
|
||
type ClickThroughToggledPayload,
|
||
} from "../types/overlay";
|
||
|
||
/** Subscribes to backend events about click-through state and toast about no-overlay attempts. */
|
||
export function useClickThroughSync(
|
||
onNoOverlay: () => void
|
||
): void {
|
||
useEffect(() => {
|
||
let unlistenToggled: UnlistenFn | undefined;
|
||
let unlistenNoOverlay: UnlistenFn | undefined;
|
||
let cancelled = false;
|
||
|
||
(async () => {
|
||
unlistenToggled = await listen<ClickThroughToggledPayload>(
|
||
EVT_CLICK_THROUGH_TOGGLED,
|
||
(e) => {
|
||
if (cancelled) return;
|
||
useOverlayStore.getState()._setClickThroughFromBackend(
|
||
e.payload.clickThrough
|
||
);
|
||
}
|
||
);
|
||
unlistenNoOverlay = await listen(EVT_CLICK_THROUGH_NO_OVERLAY, () => {
|
||
if (cancelled) return;
|
||
onNoOverlay();
|
||
});
|
||
})().catch((err) => console.warn("event listen failed", err));
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
unlistenToggled?.();
|
||
unlistenNoOverlay?.();
|
||
};
|
||
}, [onNoOverlay]);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify tsc accepts the files**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 0 errors. (`onNoOverlay` callback type is inferred as `() => void`.)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/hooks/useLiveOverlayWiring.ts browserlay/src/hooks/useClickThroughSync.ts
|
||
git commit -m "feat: live overlay wiring and backend click-through sync hooks"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: UI components
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/components/UrlField.tsx`
|
||
- Create: `browserlay/src/components/OpacitySlider.tsx`
|
||
- Create: `browserlay/src/components/ToggleRow.tsx`
|
||
- Create: `browserlay/src/components/OpenOverlayButton.tsx`
|
||
- Create: `browserlay/src/components/StatusBar.tsx`
|
||
- Create: `browserlay/src/components/ControlPanel.tsx`
|
||
|
||
- [ ] **Step 1: UrlField**
|
||
|
||
Create `browserlay/src/components/UrlField.tsx` with:
|
||
|
||
```tsx
|
||
import { Globe } from "lucide-react";
|
||
import { useOverlayStore } from "../lib/store";
|
||
|
||
export function UrlField(): JSX.Element {
|
||
const url = useOverlayStore((s) => s.url);
|
||
const urlError = useOverlayStore((s) => s.urlError);
|
||
const setUrl = useOverlayStore((s) => s.setUrl);
|
||
|
||
return (
|
||
<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:
|
||
|
||
```tsx
|
||
import { useOverlayStore } from "../lib/store";
|
||
|
||
export function OpacitySlider(): JSX.Element {
|
||
const opacity = useOverlayStore((s) => s.opacity);
|
||
const setOpacity = useOverlayStore((s) => s.setOpacity);
|
||
const pct = Math.round(opacity * 100);
|
||
|
||
return (
|
||
<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:
|
||
|
||
```tsx
|
||
import type { ReactNode } from "react";
|
||
|
||
export type ToggleRowProps = {
|
||
label: string;
|
||
description?: string;
|
||
icon?: ReactNode;
|
||
checked: boolean;
|
||
onChange: (v: boolean) => void;
|
||
};
|
||
|
||
export function ToggleRow({ label, description, icon, checked, onChange }: ToggleRowProps): JSX.Element {
|
||
return (
|
||
<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:
|
||
|
||
```tsx
|
||
import { Play, Square } from "lucide-react";
|
||
import { useOverlayStore } from "../lib/store";
|
||
import { closeOverlay, openOverlay } from "../lib/overlay";
|
||
import { validateUrl } from "../lib/url";
|
||
|
||
export function OpenOverlayButton(): JSX.Element {
|
||
const url = useOverlayStore((s) => s.url);
|
||
const urlError = useOverlayStore((s) => s.urlError);
|
||
const isOpen = useOverlayStore((s) => s.isOpen);
|
||
const opacity = useOverlayStore((s) => s.opacity);
|
||
const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
|
||
const clickThrough = useOverlayStore((s) => s.clickThrough);
|
||
const setIsOpen = useOverlayStore((s) => s.setIsOpen);
|
||
|
||
const validation = validateUrl(url);
|
||
const disabled = !isOpen && (!validation.ok);
|
||
|
||
async function handleClick(): Promise<void> {
|
||
if (isOpen) {
|
||
await closeOverlay();
|
||
setIsOpen(false);
|
||
return;
|
||
}
|
||
if (!validation.ok) return;
|
||
await openOverlay({
|
||
url: validation.url,
|
||
opacity,
|
||
alwaysOnTop,
|
||
clickThrough,
|
||
});
|
||
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:
|
||
|
||
```tsx
|
||
import { AlertTriangle, Keyboard } from "lucide-react";
|
||
import { useOverlayStore } from "../lib/store";
|
||
|
||
export type StatusBarProps = { transient: string | null };
|
||
|
||
export function StatusBar({ transient }: StatusBarProps): JSX.Element {
|
||
const hotkeyError = useOverlayStore((s) => s.hotkeyError);
|
||
const isOpen = useOverlayStore((s) => s.isOpen);
|
||
|
||
return (
|
||
<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:
|
||
|
||
```tsx
|
||
import { Layers, MousePointerClick } from "lucide-react";
|
||
import { UrlField } from "./UrlField";
|
||
import { OpacitySlider } from "./OpacitySlider";
|
||
import { ToggleRow } from "./ToggleRow";
|
||
import { OpenOverlayButton } from "./OpenOverlayButton";
|
||
import { useOverlayStore } from "../lib/store";
|
||
|
||
export function ControlPanel(): JSX.Element {
|
||
const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
|
||
const setAlwaysOnTop = useOverlayStore((s) => s.setAlwaysOnTop);
|
||
const clickThrough = useOverlayStore((s) => s.clickThrough);
|
||
const setClickThrough = useOverlayStore((s) => s.setClickThrough);
|
||
|
||
return (
|
||
<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**
|
||
|
||
```bash
|
||
git add browserlay/src/components
|
||
git commit -m "feat: control panel UI components"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Wire App.tsx + main.tsx
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/src/App.tsx`
|
||
- Modify: `browserlay/src/main.tsx`
|
||
|
||
- [ ] **Step 1: Replace App.tsx**
|
||
|
||
Open `browserlay/src/App.tsx` and replace its contents with:
|
||
|
||
```tsx
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { ControlPanel } from "./components/ControlPanel";
|
||
import { StatusBar } from "./components/StatusBar";
|
||
import { useLiveOverlayWiring } from "./hooks/useLiveOverlayWiring";
|
||
import { useClickThroughSync } from "./hooks/useClickThroughSync";
|
||
import { useOverlayStore } from "./lib/store";
|
||
import { isOverlayOpen } from "./lib/overlay";
|
||
|
||
function App(): JSX.Element {
|
||
useLiveOverlayWiring();
|
||
const setIsOpen = useOverlayStore((s) => s.setIsOpen);
|
||
const [transient, setTransient] = useState<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:
|
||
|
||
```tsx
|
||
import React from "react";
|
||
import ReactDOM from "react-dom/client";
|
||
import App from "./App";
|
||
import "./index.css";
|
||
import { hydrateFromDisk, startPersistSubscription, flushPendingPersist } from "./lib/persist";
|
||
import { registerHotkeyErrorListener } from "./lib/hotkey";
|
||
|
||
async function bootstrap(): Promise<void> {
|
||
await hydrateFromDisk();
|
||
startPersistSubscription();
|
||
await registerHotkeyErrorListener();
|
||
|
||
window.addEventListener("beforeunload", () => {
|
||
flushPendingPersist();
|
||
});
|
||
|
||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||
<React.StrictMode>
|
||
<App />
|
||
</React.StrictMode>
|
||
);
|
||
}
|
||
|
||
void bootstrap();
|
||
```
|
||
|
||
- [ ] **Step 3: Stub the hotkey error listener (real impl in Task 16)**
|
||
|
||
Create `browserlay/src/lib/hotkey.ts` with:
|
||
|
||
```ts
|
||
import { listen } from "@tauri-apps/api/event";
|
||
import { useOverlayStore } from "./store";
|
||
|
||
export const EVT_HOTKEY_REGISTRATION = "hotkey-registration";
|
||
|
||
export type HotkeyRegistrationPayload =
|
||
| { ok: true }
|
||
| { ok: false; error: string };
|
||
|
||
/** Listens for backend reports about whether the global shortcut registered successfully. */
|
||
export async function registerHotkeyErrorListener(): Promise<void> {
|
||
await listen<HotkeyRegistrationPayload>(EVT_HOTKEY_REGISTRATION, (e) => {
|
||
if (e.payload.ok) {
|
||
useOverlayStore.getState().setHotkeyError(null);
|
||
} else {
|
||
useOverlayStore.getState().setHotkeyError(e.payload.error);
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify tsc accepts everything**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 5: Run frontend tests**
|
||
|
||
Run: `npm test`
|
||
Expected: all tests pass (URL + debounce + store).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/App.tsx browserlay/src/main.tsx browserlay/src/lib/hotkey.ts
|
||
git commit -m "feat: wire App + main with persist + hooks"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Add Rust plugin crates and tokio
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/src-tauri/Cargo.toml`
|
||
|
||
- [ ] **Step 1: Add plugin dependencies**
|
||
|
||
Open `browserlay/src-tauri/Cargo.toml` and replace its `[dependencies]` block with:
|
||
|
||
```toml
|
||
[dependencies]
|
||
tauri = { version = "2", features = [] }
|
||
tauri-plugin-opener = "2"
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
|
||
tauri-plugin-store = "2"
|
||
tokio = { version = "1", features = ["time", "macros"] }
|
||
|
||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||
tauri-plugin-window-state = "2"
|
||
tauri-plugin-global-shortcut = "2"
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the crate builds**
|
||
|
||
Run from `browserlay/src-tauri/`: `cargo check`
|
||
Expected: compiles (may take a while on first fetch).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src-tauri/Cargo.toml browserlay/src-tauri/Cargo.lock
|
||
git commit -m "chore(rust): add store, window-state, global-shortcut, tokio"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Capability files
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/src-tauri/capabilities/default.json`
|
||
- Create: `browserlay/src-tauri/capabilities/overlay.json`
|
||
|
||
- [ ] **Step 1: Update default capability for the main window**
|
||
|
||
Open `browserlay/src-tauri/capabilities/default.json` and replace its contents with:
|
||
|
||
```json
|
||
{
|
||
"$schema": "../gen/schemas/desktop-schema.json",
|
||
"identifier": "default",
|
||
"description": "Capability for the main control window",
|
||
"windows": ["main"],
|
||
"permissions": [
|
||
"core:default",
|
||
"core:webview:allow-create-webview-window",
|
||
"core:window:allow-set-opacity",
|
||
"core:window:allow-set-always-on-top",
|
||
"core:window:allow-set-ignore-cursor-events",
|
||
"core:window:allow-close",
|
||
"core:window:allow-current-monitor",
|
||
"opener:default",
|
||
"store:default",
|
||
"global-shortcut:allow-register",
|
||
"global-shortcut:allow-unregister",
|
||
"global-shortcut:allow-is-registered"
|
||
]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create the overlay capability**
|
||
|
||
Create `browserlay/src-tauri/capabilities/overlay.json` with:
|
||
|
||
```json
|
||
{
|
||
"$schema": "../gen/schemas/desktop-schema.json",
|
||
"identifier": "overlay",
|
||
"description": "Locked-down capability for the overlay window — pages loaded here cannot call Tauri APIs",
|
||
"windows": ["overlay"],
|
||
"permissions": []
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src-tauri/capabilities/default.json browserlay/src-tauri/capabilities/overlay.json
|
||
git commit -m "feat(security): per-window capabilities, overlay locked down"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Rust commands module + click-through toggle
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src-tauri/src/commands/mod.rs`
|
||
- Create: `browserlay/src-tauri/src/commands/overlay.rs`
|
||
|
||
- [ ] **Step 1: Create the commands module file**
|
||
|
||
Create `browserlay/src-tauri/src/commands/mod.rs` with:
|
||
|
||
```rust
|
||
pub mod overlay;
|
||
```
|
||
|
||
- [ ] **Step 2: Implement overlay commands and the shared toggle helper**
|
||
|
||
Create `browserlay/src-tauri/src/commands/overlay.rs` with:
|
||
|
||
```rust
|
||
use std::time::Duration;
|
||
|
||
use serde::Serialize;
|
||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||
|
||
pub const OVERLAY_LABEL: &str = "overlay";
|
||
pub const EVT_CLICK_THROUGH_TOGGLED: &str = "click-through-toggled";
|
||
pub const EVT_CLICK_THROUGH_NO_OVERLAY: &str = "click-through-no-overlay";
|
||
|
||
#[derive(Clone, Serialize)]
|
||
pub struct ClickThroughToggledPayload {
|
||
#[serde(rename = "clickThrough")]
|
||
pub click_through: bool,
|
||
}
|
||
|
||
/// Applies a specific click-through state to the overlay (sets ignore_cursor_events,
|
||
/// performs the opacity pulse, emits the toggled event). Returns the applied state.
|
||
///
|
||
/// Tauri 2's WebviewWindow doesn't expose getters for `ignore_cursor_events` or
|
||
/// `opacity`, so callers must compute the new state themselves: the JS-side
|
||
/// `toggle_click_through` command receives `current` from the front end and
|
||
/// passes `!current`; the global shortcut handler maintains its own cache via
|
||
/// `AppState.click_through`.
|
||
pub async fn apply_state<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 pulse for visual feedback.
|
||
// We don't have a getter for current opacity in Tauri 2's API, so we
|
||
// restore to a value the JS side passes in via the command (or to 1.0
|
||
// for the global shortcut path, which we accept as a small visual quirk).
|
||
// The JS side will re-apply the user's true target opacity via the
|
||
// live-wiring effect immediately after the toggled event.
|
||
let _ = overlay.set_opacity(0.4);
|
||
tokio::time::sleep(Duration::from_millis(180)).await;
|
||
// Best-effort restore; JS live-wiring will re-apply the real target.
|
||
let _ = overlay.set_opacity(1.0);
|
||
|
||
app.emit(
|
||
EVT_CLICK_THROUGH_TOGGLED,
|
||
ClickThroughToggledPayload {
|
||
click_through: new_state,
|
||
},
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
Ok(Some(new_state))
|
||
}
|
||
|
||
/// Tauri command — invoked from JS. The JS side passes the *current* state so
|
||
/// we can compute the inverse without needing a getter on the Tauri side.
|
||
#[tauri::command]
|
||
pub async fn toggle_click_through<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(())
|
||
}
|
||
```
|
||
|
||
> **Note on the design:** Tauri 2 doesn't expose a getter for `ignore_cursor_events` / `opacity` on `WebviewWindow`. Rather than rely on getters, we keep a tiny Rust-side cache (`AppState.click_through`) that the JS side keeps in sync via `sync_click_through_cache`. The global shortcut handler reads this cache, computes the inverse, calls `apply_state`. The JS-driven toggle path uses `toggle_click_through(current)` and skips the cache.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src-tauri/src/commands
|
||
git commit -m "feat(rust): commands module with click-through toggle helpers"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Rust lib.rs — register plugins, shortcut, commands
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/src-tauri/src/lib.rs`
|
||
|
||
- [ ] **Step 1: Replace lib.rs**
|
||
|
||
Open `browserlay/src-tauri/src/lib.rs` and replace its contents with:
|
||
|
||
```rust
|
||
mod commands;
|
||
|
||
use std::sync::Mutex;
|
||
|
||
use serde::Serialize;
|
||
use tauri::{Emitter, Manager};
|
||
|
||
#[cfg(desktop)]
|
||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
|
||
|
||
pub struct AppState {
|
||
pub click_through: Mutex<bool>,
|
||
}
|
||
|
||
#[derive(Clone, Serialize)]
|
||
struct HotkeyRegistrationPayload<'a> {
|
||
ok: bool,
|
||
error: Option<&'a str>,
|
||
}
|
||
|
||
const EVT_HOTKEY_REGISTRATION: &str = "hotkey-registration";
|
||
|
||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||
pub fn run() {
|
||
tauri::Builder::default()
|
||
.manage(AppState {
|
||
click_through: Mutex::new(false),
|
||
})
|
||
.plugin(tauri_plugin_opener::init())
|
||
.plugin(tauri_plugin_store::Builder::new().build())
|
||
.setup(|app| {
|
||
#[cfg(desktop)]
|
||
{
|
||
app.handle()
|
||
.plugin(tauri_plugin_window_state::Builder::default().build())?;
|
||
|
||
let shortcut = Shortcut::new(
|
||
Some(Modifiers::CONTROL | Modifiers::ALT),
|
||
Code::Space,
|
||
);
|
||
let shortcut_for_handler = shortcut;
|
||
|
||
app.handle().plugin(
|
||
tauri_plugin_global_shortcut::Builder::new()
|
||
.with_handler(move |app, sc, event| {
|
||
if sc != &shortcut_for_handler {
|
||
return;
|
||
}
|
||
if event.state() != ShortcutState::Pressed {
|
||
return;
|
||
}
|
||
let app_handle = app.clone();
|
||
tauri::async_runtime::spawn(async move {
|
||
if let Err(err) =
|
||
commands::overlay::toggle_via_global_shortcut(&app_handle).await
|
||
{
|
||
eprintln!("global shortcut toggle failed: {err}");
|
||
}
|
||
});
|
||
})
|
||
.build(),
|
||
)?;
|
||
|
||
let registration_result = app.global_shortcut().register(shortcut);
|
||
let payload = match ®istration_result {
|
||
Ok(()) => HotkeyRegistrationPayload {
|
||
ok: true,
|
||
error: None,
|
||
},
|
||
Err(e) => HotkeyRegistrationPayload {
|
||
ok: false,
|
||
error: Some(
|
||
"Hotkey unavailable — click-through escape disabled",
|
||
),
|
||
},
|
||
};
|
||
let _ = app.emit(EVT_HOTKEY_REGISTRATION, payload);
|
||
if let Err(e) = registration_result {
|
||
eprintln!("Failed to register Ctrl+Alt+Space: {e}");
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
.invoke_handler(tauri::generate_handler![
|
||
commands::overlay::toggle_click_through,
|
||
commands::overlay::sync_click_through_cache,
|
||
])
|
||
.run(tauri::generate_context!())
|
||
.expect("error while running tauri application");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the crate compiles**
|
||
|
||
Run from `browserlay/src-tauri/`: `cargo check`
|
||
Expected: compiles. Warnings about unused `e` binding in the `Err(e)` arm are expected — silence by changing `Err(e) =>` to `Err(_) =>`.
|
||
|
||
- [ ] **Step 3: Address the `Err(_)` warning**
|
||
|
||
In the `match ®istration_result` block, change `Err(e) => HotkeyRegistrationPayload {` to `Err(_) => HotkeyRegistrationPayload {`. Re-run `cargo check`. Expected: clean.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src-tauri/src/lib.rs
|
||
git commit -m "feat(rust): register plugins, global shortcut, commands"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Sync the Rust click-through cache from JS
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/src/lib/overlay.ts`
|
||
- Modify: `browserlay/src/hooks/useLiveOverlayWiring.ts`
|
||
- Modify: `browserlay/src/components/OpenOverlayButton.tsx`
|
||
|
||
The Rust handler needs to know the JS-side click-through truth so the global shortcut can toggle correctly when the JS side hasn't been the one driving the change. We sync on every JS-driven change.
|
||
|
||
- [ ] **Step 1: Add a sync helper to lib/overlay.ts**
|
||
|
||
Open `browserlay/src/lib/overlay.ts` and append this export at the bottom:
|
||
|
||
```ts
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
|
||
export async function syncClickThroughCache(value: boolean): Promise<void> {
|
||
try {
|
||
await invoke("sync_click_through_cache", { value });
|
||
} catch (err) {
|
||
console.warn("Failed to sync click-through cache to backend", err);
|
||
}
|
||
}
|
||
```
|
||
|
||
> Move the `import { invoke } from "@tauri-apps/api/core";` to the top of the file with the other imports.
|
||
|
||
- [ ] **Step 2: Sync on overlay open**
|
||
|
||
In `browserlay/src/lib/overlay.ts`, in `openOverlay`, after the `await w.setIgnoreCursorEvents(opts.clickThrough);` line, add:
|
||
|
||
```ts
|
||
await syncClickThroughCache(opts.clickThrough);
|
||
```
|
||
|
||
- [ ] **Step 3: Sync on every JS-driven click-through change**
|
||
|
||
Open `browserlay/src/hooks/useLiveOverlayWiring.ts` and replace the `useEffect` that handles click-through with:
|
||
|
||
```ts
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
if (lastAppliedClickThrough.current === clickThrough) return;
|
||
lastAppliedClickThrough.current = clickThrough;
|
||
void applyClickThrough(clickThrough);
|
||
void syncClickThroughCache(clickThrough);
|
||
}, [clickThrough, isOpen]);
|
||
```
|
||
|
||
Add to imports at top: `import { applyAlwaysOnTop, applyClickThrough, applyOpacity, syncClickThroughCache } from "../lib/overlay";`
|
||
|
||
- [ ] **Step 4: Re-run tests + tsc**
|
||
|
||
Run: `npm test && npx tsc --noEmit`
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/lib/overlay.ts browserlay/src/hooks/useLiveOverlayWiring.ts
|
||
git commit -m "feat: sync JS click-through state to rust cache for global shortcut"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: Manual verification of MVP
|
||
|
||
**Files:** none (manual testing)
|
||
|
||
- [ ] **Step 1: Run the app in dev mode**
|
||
|
||
From `browserlay/`: `npm run tauri dev`
|
||
Expected: control window opens with the new dark UI. No overlay window.
|
||
|
||
- [ ] **Step 2: URL validation checks**
|
||
|
||
In the URL field, type each of these and observe the result:
|
||
- `twitch.tv/foo` → no error, Open enabled.
|
||
- `not a url` → "That doesn't look like a URL" shown, Open disabled.
|
||
- `javascript:alert(1)` → "Only http and https URLs are allowed", Open disabled.
|
||
- `tauri://localhost` → same error.
|
||
- `https://example.com` → no error, Open enabled.
|
||
|
||
- [ ] **Step 3: Open overlay**
|
||
|
||
Type `https://example.com`, click "Open overlay". Expected: a frameless transparent window appears top-right of the primary monitor, ~800×600, showing example.com. The status bar pip turns active and the button changes to "Close overlay".
|
||
|
||
- [ ] **Step 4: Live opacity**
|
||
|
||
Drag the opacity slider from 100% down to ~30%. Expected: the overlay fades visibly in real time. Drag back up to 100% — restored.
|
||
|
||
- [ ] **Step 5: Always-on-top toggle**
|
||
|
||
Toggle AOT off. Click another window (e.g. an editor) so it covers where the overlay sits. Expected: the editor covers the overlay. Toggle AOT back on. Expected: the overlay raises above.
|
||
|
||
- [ ] **Step 6: Click-through off (interactive)**
|
||
|
||
Ensure click-through is off. Click on a link or button on the loaded page. Expected: it activates / scrolls / responds.
|
||
|
||
- [ ] **Step 7: Click-through on (pass-through)**
|
||
|
||
Toggle click-through on. Click where the overlay sits. Expected: the click goes through to the app underneath the overlay.
|
||
|
||
- [ ] **Step 8: Global hotkey escape**
|
||
|
||
With click-through still on, click on a different app entirely (so neither browserlay window has focus). Press `Ctrl+Alt+Space`. Expected: the overlay opacity blinks briefly; click-through is now off; the control panel toggle reflects the new state.
|
||
|
||
- [ ] **Step 9: Persistence — close and reopen**
|
||
|
||
Set URL to `https://example.com`, opacity to 50%, AOT off, click-through on. Close the app entirely. Reopen with `npm run tauri dev`. Expected: control panel shows the same URL, opacity, and toggle states.
|
||
|
||
- [ ] **Step 10: Window geometry persistence**
|
||
|
||
Open the overlay. Drag it to a different position and resize it. Close the overlay (via Close button). Reopen the overlay (same URL). Expected: it appears at the same position and size as before.
|
||
|
||
- [ ] **Step 11: Hotkey-conflict surfacing**
|
||
|
||
(Optional, only if you have a way to register `Ctrl+Alt+Space` from another app.) Run that other app first, then start browserlay. Expected: the StatusBar shows "⚠ Hotkey unavailable — click-through escape disabled".
|
||
|
||
- [ ] **Step 12: Closing the overlay via taskbar**
|
||
|
||
Open the overlay. Right-click its taskbar entry → Close window. Expected: control panel still functions; clicking Open again creates a fresh overlay.
|
||
|
||
- [ ] **Step 13: Production build**
|
||
|
||
Run from `browserlay/`: `npm run tauri build`
|
||
Expected: build completes; `src-tauri/target/release/bundle/msi/` (or `nsis/`) contains an installer. Run the installer; the installed app launches and behaves identically.
|
||
|
||
- [ ] **Step 14: Commit any fixes discovered during verification**
|
||
|
||
If steps 1–13 surfaced bugs, fix them with conventional-commit-style commits. If everything passes as-is, no commit needed for this step.
|
||
|
||
---
|
||
|
||
## Task 20: README + version tag
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/README.md`
|
||
|
||
- [ ] **Step 1: Replace README.md**
|
||
|
||
Open `browserlay/README.md` and replace its contents with:
|
||
|
||
```markdown
|
||
# Browserlay
|
||
|
||
A Windows desktop app that creates a translucent, always-on-top browser overlay. Type a URL, drop the opacity, toggle click-through — keep a stream, dashboard, or doc page floating over your work.
|
||
|
||

|
||
|
||
## Features (Phase 1 / MVP)
|
||
|
||
- Frameless, transparent overlay window pinned over other apps.
|
||
- Real-time opacity slider (10%–100%).
|
||
- Always-on-top toggle.
|
||
- Click-through toggle — mouse events pass through to the app underneath.
|
||
- **`Ctrl+Alt+Space`** global hotkey to toggle click-through from anywhere (the escape hatch when you can't click the overlay).
|
||
- Last URL, opacity, and toggle states persist between sessions.
|
||
- Window position and size remembered between sessions.
|
||
|
||
## Dev setup
|
||
|
||
Prereqs: Node 18+, Rust toolchain (`rustup`), Tauri 2 prereqs for Windows ([WebView2 runtime ships with Windows 11](https://tauri.app/start/prerequisites/)).
|
||
|
||
```bash
|
||
cd browserlay
|
||
npm install
|
||
npm run tauri dev
|
||
```
|
||
|
||
Frontend tests:
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
## Build
|
||
|
||
```bash
|
||
npm run tauri build
|
||
```
|
||
|
||
Outputs an MSI/NSIS installer under `src-tauri/target/release/bundle/`.
|
||
|
||
## Architecture
|
||
|
||
Two-window Tauri 2 app. Control window hosts a React+Tailwind+Zustand UI; overlay is a frameless transparent webview spawned at runtime pointing directly at the user-supplied URL. Click-through hotkey runs in Rust via `tauri-plugin-global-shortcut` so it works regardless of focus. Per-window capability files lock the overlay down — pages it loads can't call any Tauri APIs.
|
||
|
||
See [`docs/plans/2026-05-08-browserlay-mvp-design.md`](docs/plans/2026-05-08-browserlay-mvp-design.md) for the full design.
|
||
|
||
## Roadmap (Phase 2)
|
||
|
||
Presets · configurable hotkeys · system tray icon · edge-snap · multi-monitor placement · in-overlay toolbar.
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add browserlay/README.md
|
||
git commit -m "docs: README for Phase 1 MVP"
|
||
```
|
||
|
||
- [ ] **Step 3: Tag the release**
|
||
|
||
```bash
|
||
git tag -a v0.1.0 -m "Phase 1 MVP"
|
||
```
|
||
|
||
- [ ] **Step 4: Confirm with user before pushing**
|
||
|
||
Per the agreed workflow, do NOT push or push tags without explicit user approval. Surface this and wait:
|
||
|
||
> "All Task 19 verification checks passed. v0.1.0 tagged locally. OK to push to origin/main and push the tag?"
|
||
|
||
Once approved, run:
|
||
|
||
```bash
|
||
git push origin main
|
||
git push origin v0.1.0
|
||
```
|
||
|
||
---
|
||
|
||
## Done criteria for Phase 1
|
||
|
||
- All 14 verification steps in Task 19 pass on Windows 11.
|
||
- `npm run tauri build` produces a working installer.
|
||
- `v0.1.0` tag pushed to `origin/main` after user approval.
|
||
- README documents what the app does, dev setup, build commands, and the hotkey.
|
||
|
||
## Out of scope (Phase 2)
|
||
|
||
Tray icon, presets, configurable hotkeys, edge snap, multi-monitor placement UI, in-overlay toolbar, auto-update.
|