docs: address review of MVP plan

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>
This commit is contained in:
Michael Chihlas
2026-05-08 17:25:32 -04:00
parent 4a3d380b54
commit 6e62673f7e

View File

@@ -809,11 +809,12 @@ This module is the only place that imports `@tauri-apps/api/webviewWindow`. We w
Create `browserlay/src/lib/overlay.ts` with: Create `browserlay/src/lib/overlay.ts` with:
```ts ```ts
import { invoke } from "@tauri-apps/api/core";
import { import {
WebviewWindow, WebviewWindow,
getCurrentWebviewWindow, getCurrentWebviewWindow,
} from "@tauri-apps/api/webviewWindow"; } from "@tauri-apps/api/webviewWindow";
import { LogicalPosition, LogicalSize, currentMonitor } from "@tauri-apps/api/window"; import { currentMonitor } from "@tauri-apps/api/window";
import { OVERLAY_LABEL } from "../types/overlay"; import { OVERLAY_LABEL } from "../types/overlay";
export type OpenOptions = { export type OpenOptions = {
@@ -821,6 +822,10 @@ export type OpenOptions = {
opacity: number; opacity: number;
alwaysOnTop: boolean; alwaysOnTop: boolean;
clickThrough: 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_W = 800;
@@ -868,20 +873,47 @@ export async function openOverlay(opts: OpenOptions): Promise<void> {
skipTaskbar: false, 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) => { await new Promise<void>((resolve, reject) => {
const onCreated = w.once("tauri://created", () => { let settled = false;
onError.then((u) => u()); let unlistenCreated: (() => void) | null = null;
let unlistenError: (() => void) | null = null;
void w.once("tauri://created", () => {
if (settled) return;
settled = true;
unlistenError?.();
resolve(); resolve();
}).then((u) => {
unlistenCreated = u;
if (settled) u();
}); });
const onError = w.once("tauri://error", (e) => {
onCreated.then((u) => 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)}`)); 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. // Apply runtime-only state that's not part of the constructor.
await w.setOpacity(opts.opacity); await w.setOpacity(opts.opacity);
await w.setIgnoreCursorEvents(opts.clickThrough); 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> { export async function closeOverlay(): Promise<void> {
@@ -913,23 +945,18 @@ export async function isOverlayOpen(): Promise<boolean> {
return (await getOverlay()) !== null; 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. */ /** Convenience for the control window's own handle if needed elsewhere. */
export function getControlWindow(): WebviewWindow { export function getControlWindow(): WebviewWindow {
return getCurrentWebviewWindow(); 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** - [ ] **Step 2: Verify tsc accepts the file**
@@ -1000,7 +1027,12 @@ export async function hydrateFromDisk(): Promise<void> {
} }
} }
const writeToDisk = debounce(async (state: OverlayPersistedState): Promise<void> => { // 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 { try {
const s = await getStore(); const s = await getStore();
await s.set(STORE_KEY, state); await s.set(STORE_KEY, state);
@@ -1008,23 +1040,40 @@ const writeToDisk = debounce(async (state: OverlayPersistedState): Promise<void>
} catch (err) { } catch (err) {
console.warn("Failed to persist state", err); console.warn("Failed to persist state", err);
} }
}
const writeDebounced = debounce((state: OverlayPersistedState): void => {
latestPending = null;
void writeNow(state);
}, 300); }, 300);
/** Subscribe to store changes and persist the four user-pref fields. */ /** Subscribe to store changes and persist the four user-pref fields. */
export function startPersistSubscription(): () => void { export function startPersistSubscription(): () => void {
return useOverlayStore.subscribe((s) => { return useOverlayStore.subscribe((s) => {
writeToDisk({ const snapshot: OverlayPersistedState = {
url: s.url, url: s.url,
opacity: s.opacity, opacity: s.opacity,
alwaysOnTop: s.alwaysOnTop, alwaysOnTop: s.alwaysOnTop,
clickThrough: s.clickThrough, clickThrough: s.clickThrough,
}); };
latestPending = snapshot;
writeDebounced(snapshot);
}); });
} }
/** Force-write any pending debounced save (call on app exit). */ /**
export function flushPendingPersist(): void { * Best-effort synchronous flush for beforeunload. The actual disk write is
writeToDisk.flush(); * 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);
} }
``` ```
@@ -1102,6 +1151,7 @@ Create `browserlay/src/hooks/useClickThroughSync.ts` with:
import { useEffect } from "react"; import { useEffect } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useOverlayStore } from "../lib/store"; import { useOverlayStore } from "../lib/store";
import { applyOpacity } from "../lib/overlay";
import { import {
EVT_CLICK_THROUGH_NO_OVERLAY, EVT_CLICK_THROUGH_NO_OVERLAY,
EVT_CLICK_THROUGH_TOGGLED, EVT_CLICK_THROUGH_TOGGLED,
@@ -1125,6 +1175,12 @@ export function useClickThroughSync(
useOverlayStore.getState()._setClickThroughFromBackend( useOverlayStore.getState()._setClickThroughFromBackend(
e.payload.clickThrough 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, () => { unlistenNoOverlay = await listen(EVT_CLICK_THROUGH_NO_OVERLAY, () => {
@@ -1311,7 +1367,6 @@ import { validateUrl } from "../lib/url";
export function OpenOverlayButton(): JSX.Element { export function OpenOverlayButton(): JSX.Element {
const url = useOverlayStore((s) => s.url); const url = useOverlayStore((s) => s.url);
const urlError = useOverlayStore((s) => s.urlError);
const isOpen = useOverlayStore((s) => s.isOpen); const isOpen = useOverlayStore((s) => s.isOpen);
const opacity = useOverlayStore((s) => s.opacity); const opacity = useOverlayStore((s) => s.opacity);
const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop); const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
@@ -1319,11 +1374,13 @@ export function OpenOverlayButton(): JSX.Element {
const setIsOpen = useOverlayStore((s) => s.setIsOpen); const setIsOpen = useOverlayStore((s) => s.setIsOpen);
const validation = validateUrl(url); const validation = validateUrl(url);
const disabled = !isOpen && (!validation.ok); const disabled = !isOpen && !validation.ok;
async function handleClick(): Promise<void> { async function handleClick(): Promise<void> {
if (isOpen) { if (isOpen) {
await closeOverlay(); 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); setIsOpen(false);
return; return;
} }
@@ -1333,6 +1390,9 @@ export function OpenOverlayButton(): JSX.Element {
opacity, opacity,
alwaysOnTop, alwaysOnTop,
clickThrough, clickThrough,
onDestroyed: () => {
useOverlayStore.getState().setIsOpen(false);
},
}); });
setIsOpen(true); setIsOpen(true);
} }
@@ -1516,16 +1576,22 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
import { hydrateFromDisk, startPersistSubscription, flushPendingPersist } from "./lib/persist"; import { hydrateFromDisk, startPersistSubscription, flushPendingPersistSync } from "./lib/persist";
import { registerHotkeyErrorListener } from "./lib/hotkey"; import { fetchHotkeyStatus } from "./lib/hotkey";
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
await hydrateFromDisk(); await hydrateFromDisk();
startPersistSubscription(); startPersistSubscription();
await registerHotkeyErrorListener(); // 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", () => { window.addEventListener("beforeunload", () => {
flushPendingPersist(); flushPendingPersistSync();
}); });
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
@@ -1538,29 +1604,45 @@ async function bootstrap(): Promise<void> {
void bootstrap(); void bootstrap();
``` ```
- [ ] **Step 3: Stub the hotkey error listener (real impl in Task 16)** - [ ] **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: Create `browserlay/src/lib/hotkey.ts` with:
```ts ```ts
import { listen } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core";
import { useOverlayStore } from "./store"; import { useOverlayStore } from "./store";
export const EVT_HOTKEY_REGISTRATION = "hotkey-registration"; type HotkeyStatus =
| { kind: "pending" }
| { kind: "ok" }
| { kind: "failed"; error: string };
export type HotkeyRegistrationPayload = /** Polls the backend until hotkey registration is decided, then surfaces any error
| { ok: true } * via the store. Bounded retry: setup-time decision is essentially synchronous
| { ok: false; error: string }; * on the Rust side, so a missed first call is at worst a few-ms wait. */
export async function fetchHotkeyStatus(): Promise<void> {
/** Listens for backend reports about whether the global shortcut registered successfully. */ const maxAttempts = 20; // ~1 second total worst case
export async function registerHotkeyErrorListener(): Promise<void> { for (let i = 0; i < maxAttempts; i++) {
await listen<HotkeyRegistrationPayload>(EVT_HOTKEY_REGISTRATION, (e) => { let status: HotkeyStatus;
if (e.payload.ok) { try {
useOverlayStore.getState().setHotkeyError(null); status = await invoke<HotkeyStatus>("get_hotkey_status");
} else { } catch (err) {
useOverlayStore.getState().setHotkeyError(e.payload.error); 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");
} }
``` ```
@@ -1732,16 +1814,13 @@ pub async fn apply_state<R: Runtime>(
.set_ignore_cursor_events(new_state) .set_ignore_cursor_events(new_state)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Opacity pulse for visual feedback. // Opacity dip half of the visual pulse. The JS click-through-toggled
// We don't have a getter for current opacity in Tauri 2's API, so we // handler is responsible for restoring opacity to the user's actual
// restore to a value the JS side passes in via the command (or to 1.0 // target — Rust deliberately does NOT call set_opacity again, so there
// for the global shortcut path, which we accept as a small visual quirk). // is no possibility of the global-shortcut path "snapping back" to a
// The JS side will re-apply the user's true target opacity via the // wrong value (we don't have a current-opacity getter).
// live-wiring effect immediately after the toggled event.
let _ = overlay.set_opacity(0.4); let _ = overlay.set_opacity(0.4);
tokio::time::sleep(Duration::from_millis(180)).await; 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( app.emit(
EVT_CLICK_THROUGH_TOGGLED, EVT_CLICK_THROUGH_TOGGLED,
@@ -1792,6 +1871,16 @@ pub fn sync_click_through_cache(
*guard = value; *guard = value;
Ok(()) 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` / `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. > **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.
@@ -1820,28 +1909,34 @@ mod commands;
use std::sync::Mutex; use std::sync::Mutex;
use serde::Serialize; use serde::Serialize;
use tauri::{Emitter, Manager}; use tauri::Manager;
#[cfg(desktop)] #[cfg(desktop)]
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
pub struct AppState { pub struct AppState {
pub click_through: Mutex<bool>, 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)] #[derive(Clone, Serialize, Default)]
struct HotkeyRegistrationPayload<'a> { #[serde(tag = "kind", rename_all = "lowercase")]
ok: bool, pub enum HotkeyStatus {
error: Option<&'a str>, #[default]
Pending,
Ok,
Failed { error: String },
} }
const EVT_HOTKEY_REGISTRATION: &str = "hotkey-registration";
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.manage(AppState { .manage(AppState {
click_through: Mutex::new(false), click_through: Mutex::new(false),
hotkey_status: Mutex::new(HotkeyStatus::Pending),
}) })
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
@@ -1879,21 +1974,18 @@ pub fn run() {
)?; )?;
let registration_result = app.global_shortcut().register(shortcut); let registration_result = app.global_shortcut().register(shortcut);
let payload = match &registration_result { let status = match &registration_result {
Ok(()) => HotkeyRegistrationPayload { Ok(()) => HotkeyStatus::Ok,
ok: true, Err(e) => {
error: None, eprintln!("Failed to register Ctrl+Alt+Space: {e}");
}, HotkeyStatus::Failed {
Err(e) => HotkeyRegistrationPayload { error: "Hotkey unavailable — click-through escape disabled".to_string(),
ok: false, }
error: Some( }
"Hotkey unavailable — click-through escape disabled",
),
},
}; };
let _ = app.emit(EVT_HOTKEY_REGISTRATION, payload); {
if let Err(e) = registration_result { let state = app.state::<AppState>();
eprintln!("Failed to register Ctrl+Alt+Space: {e}"); *state.hotkey_status.lock().expect("poisoned") = status;
} }
} }
Ok(()) Ok(())
@@ -1901,6 +1993,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::overlay::toggle_click_through, commands::overlay::toggle_click_through,
commands::overlay::sync_click_through_cache, commands::overlay::sync_click_through_cache,
commands::overlay::get_hotkey_status,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
@@ -1910,13 +2003,9 @@ pub fn run() {
- [ ] **Step 2: Verify the crate compiles** - [ ] **Step 2: Verify the crate compiles**
Run from `browserlay/src-tauri/`: `cargo check` 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(_) =>`. Expected: compiles cleanly.
- [ ] **Step 3: Address the `Err(_)` warning** - [ ] **Step 3: Commit**
In the `match &registration_result` block, change `Err(e) => HotkeyRegistrationPayload {` to `Err(_) => HotkeyRegistrationPayload {`. Re-run `cargo check`. Expected: clean.
- [ ] **Step 4: Commit**
```bash ```bash
git add browserlay/src-tauri/src/lib.rs git add browserlay/src-tauri/src/lib.rs
@@ -1925,67 +2014,66 @@ git commit -m "feat(rust): register plugins, global shortcut, commands"
--- ---
## Task 18: Sync the Rust click-through cache from JS ## Task 18: Sync click-through cache on every JS-driven change
**Files:** **Files:**
- Modify: `browserlay/src/lib/overlay.ts`
- Modify: `browserlay/src/hooks/useLiveOverlayWiring.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. `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: Add a sync helper to lib/overlay.ts** - [ ] **Step 1: Update useLiveOverlayWiring to sync on every JS-driven change**
Open `browserlay/src/lib/overlay.ts` and append this export at the bottom: Open `browserlay/src/hooks/useLiveOverlayWiring.ts` and replace its contents with:
```ts ```ts
import { invoke } from "@tauri-apps/api/core"; import { useEffect, useRef } from "react";
import { useOverlayStore } from "../lib/store";
import {
applyAlwaysOnTop,
applyClickThrough,
applyOpacity,
syncClickThroughCache,
} from "../lib/overlay";
export async function syncClickThroughCache(value: boolean): Promise<void> { /** Pushes Zustand changes to the overlay window in real time. No-op when overlay is closed. */
try { export function useLiveOverlayWiring(): void {
await invoke("sync_click_through_cache", { value }); const opacity = useOverlayStore((s) => s.opacity);
} catch (err) { const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
console.warn("Failed to sync click-through cache to backend", err); 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]);
} }
``` ```
> Move the `import { invoke } from "@tauri-apps/api/core";` to the top of the file with the other imports. - [ ] **Step 2: Re-run tests + tsc**
- [ ] **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` Run: `npm test && npx tsc --noEmit`
Expected: all pass. Expected: all pass.
- [ ] **Step 6: Commit** - [ ] **Step 3: Commit**
```bash ```bash
git add browserlay/src/lib/overlay.ts browserlay/src/hooks/useLiveOverlayWiring.ts git add browserlay/src/hooks/useLiveOverlayWiring.ts
git commit -m "feat: sync JS click-through state to rust cache for global shortcut" git commit -m "feat: keep rust click-through cache synced with UI toggle"
``` ```
--- ---
@@ -2073,8 +2161,6 @@ Open `browserlay/README.md` and replace its contents with:
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. A Windows desktop app that creates a translucent, always-on-top browser overlay. Type a URL, drop the opacity, toggle click-through — keep a stream, dashboard, or doc page floating over your work.
![Phase 1 MVP](docs/screenshot.png)
## Features (Phase 1 / MVP) ## Features (Phase 1 / MVP)
- Frameless, transparent overlay window pinned over other apps. - Frameless, transparent overlay window pinned over other apps.