Skip to content

Commit 7ef1024

Browse files
Tiwasclaude
andcommitted
fix(dashboard): move settings back button into titlebar; defensive no-drag CSS
In dashboard mode (1200×800 transparent window) the OS hit-tester was claiming clicks inside the settings-header for window dragging, swallowing the back button entirely. Widget mode worked because the small always-on- top window uses different hit-testing rules. - Move the "← Back" affordance into the titlebar actions row (next to ×), which is verified-clickable because it sits inside the explicit no-drag region used by the close button. - Auto-focus the import-paste textarea + add `key` to recover from window minimize/restore focus-stuck cases. - Bump modal z-index to 500, mark all screen containers explicitly as -webkit-app-region: no-drag so the drag region can never silently expand to cover content. Lock titlebar to a fixed 36px height with flex-shrink: 0 so its bounding rect is deterministic. - Replace the unreliable document.body.matches(":hover") in the hotzone auto-hide check with mousemove/mousedown/keydown activity tracking. The previous :hover poll could falsely report "not hovered" after a minimize/restore cycle, hiding the window mid-interaction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent eb0d6e9 commit 7ef1024

4 files changed

Lines changed: 85 additions & 17 deletions

File tree

apps/dashboard/src/App.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,15 @@ function AppInner({
8484
}, [settings.mode]);
8585

8686
// Listen for hotzone triggers from Rust. If the widget was already visible
87-
// we just focus it. If it was hidden, we show it and start a recurring
88-
// auto-hide check: after `hotzoneAutoHideSec` seconds, if the cursor is
89-
// currently over the widget we restart the check (5s re-checks); if not,
90-
// we hide. So the widget stays as long as you keep your cursor on it.
87+
// we just focus it. If it was hidden, we show it and start an auto-hide
88+
// check that polls for user activity (mousemove/mousedown/keydown anywhere
89+
// in the window) rather than relying on the :hover pseudo-class, which is
90+
// unreliable in WebView2 across focus / minimize cycles.
9191
useEffect(() => {
9292
const initialSec = settings.hotzoneAutoHideSec;
9393
const recheckSec = 5;
9494
let activeTimer: number | null = null;
95+
let lastActivity = Date.now();
9596

9697
function clearTimer() {
9798
if (activeTimer !== null) {
@@ -100,18 +101,26 @@ function AppInner({
100101
}
101102
}
102103

104+
function markActivity() {
105+
lastActivity = Date.now();
106+
}
107+
103108
function scheduleCheck(delayMs: number, win: ReturnType<typeof getCurrentWindow>) {
104109
activeTimer = window.setTimeout(() => {
105110
activeTimer = null;
106-
const hovered = document.body.matches(":hover");
107-
if (hovered) {
111+
const idleMs = Date.now() - lastActivity;
112+
if (idleMs < delayMs) {
108113
scheduleCheck(recheckSec * 1000, win);
109114
} else {
110115
win.hide().catch(() => {});
111116
}
112117
}, delayMs);
113118
}
114119

120+
document.addEventListener("mousemove", markActivity);
121+
document.addEventListener("mousedown", markActivity);
122+
document.addEventListener("keydown", markActivity);
123+
115124
const promise = listen<number>("hotzone-trigger", async () => {
116125
try {
117126
const win = getCurrentWindow();
@@ -120,6 +129,7 @@ function AppInner({
120129
await win.setFocus();
121130
if (wasVisible) return;
122131
clearTimer();
132+
markActivity();
123133
scheduleCheck(initialSec * 1000, win);
124134
} catch (e) {
125135
console.warn("[dashboard] show on hotzone failed:", e);
@@ -128,6 +138,9 @@ function AppInner({
128138
return () => {
129139
promise.then((unlisten) => unlisten()).catch(() => {});
130140
clearTimer();
141+
document.removeEventListener("mousemove", markActivity);
142+
document.removeEventListener("mousedown", markActivity);
143+
document.removeEventListener("keydown", markActivity);
131144
};
132145
}, [settings.hotzoneAutoHideSec]);
133146

@@ -263,6 +276,15 @@ function AppInner({
263276
<div className="titlebar">
264277
<span>Smart (Components) Toolkit Widget</span>
265278
<div className="actions">
279+
{screen.kind === "settings" && (
280+
<button
281+
className="icon-btn"
282+
onClick={backToDashboard}
283+
title={t.settings_back}
284+
>
285+
{t.settings_back}
286+
</button>
287+
)}
266288
<button
267289
className="icon-btn close-btn"
268290
onClick={() => getCurrentWindow().close()}

apps/dashboard/src/components/Floorplan.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from "react";
1+
import { useEffect, useRef, useState, type ChangeEvent } from "react";
22
import { openUrl } from "@tauri-apps/plugin-opener";
33
import {
44
EMPTY_FLOORPLAN,
@@ -110,8 +110,19 @@ function ImportFloorplanModal({
110110
const [tab, setTab] = useState<"file" | "paste">("file");
111111
const [pasted, setPasted] = useState("");
112112
const [error, setError] = useState<string | null>(null);
113+
const textareaRef = useRef<HTMLTextAreaElement>(null);
113114

114-
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
115+
// Auto-focus the textarea every time the paste tab becomes active. This
116+
// also recovers focus after the window is minimized and restored, which
117+
// sometimes leaves the previous element in a focus-stuck state in WebView2.
118+
useEffect(() => {
119+
if (tab === "paste") {
120+
const id = window.setTimeout(() => textareaRef.current?.focus(), 30);
121+
return () => window.clearTimeout(id);
122+
}
123+
}, [tab]);
124+
125+
async function handleFile(e: ChangeEvent<HTMLInputElement>) {
115126
setError(null);
116127
const file = e.target.files?.[0];
117128
if (!file) return;
@@ -160,14 +171,22 @@ function ImportFloorplanModal({
160171
</div>
161172

162173
{tab === "file" ? (
163-
<input type="file" accept=".svg,image/svg+xml" onChange={handleFile} />
174+
<input
175+
key="file-input"
176+
type="file"
177+
accept=".svg,image/svg+xml"
178+
onChange={handleFile}
179+
/>
164180
) : (
165181
<>
166182
<textarea
183+
key="paste-textarea"
184+
ref={textareaRef}
167185
rows={8}
168186
placeholder="<svg …>…</svg>"
169187
value={pasted}
170188
onChange={(e) => setPasted(e.target.value)}
189+
onClick={(e) => e.stopPropagation()}
171190
style={{
172191
width: "100%",
173192
fontFamily: "monospace",
@@ -178,6 +197,7 @@ function ImportFloorplanModal({
178197
borderRadius: 6,
179198
padding: 8,
180199
resize: "vertical",
200+
pointerEvents: "auto",
181201
}}
182202
/>
183203
<div className="modal-actions">

apps/dashboard/src/components/Settings.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ export function Settings({
2828
settings,
2929
homeys,
3030
onChange,
31-
onBack,
3231
onResetCredentials,
3332
onSignOut,
3433
}: {
@@ -90,9 +89,6 @@ export function Settings({
9089
return (
9190
<div className="screen settings-screen">
9291
<div className="settings-header">
93-
<button className="icon-btn" onClick={onBack} title={t.settings_back}>
94-
95-
</button>
9692
<h3>{t.settings_title}</h3>
9793
</div>
9894

apps/dashboard/src/styles.css

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ body,
4646
font-weight: 600;
4747
font-size: 12px;
4848
color: var(--fg-muted);
49+
flex-shrink: 0;
50+
height: 36px;
51+
box-sizing: border-box;
4952
}
5053

5154
.titlebar .actions {
@@ -54,6 +57,19 @@ body,
5457
gap: 6px;
5558
}
5659

60+
/* Defensive: explicitly mark all non-titlebar content as no-drag so the OS
61+
hit-tester never claims clicks for window dragging in larger windows. */
62+
.screen,
63+
.screen-centered,
64+
.tabs,
65+
.settings-screen,
66+
.settings-header,
67+
.modal-backdrop,
68+
.modal,
69+
.floorplan-stage {
70+
-webkit-app-region: no-drag;
71+
}
72+
5773
.icon-btn {
5874
background: transparent;
5975
border: none;
@@ -249,7 +265,8 @@ button.primary:hover {
249265
display: flex;
250266
align-items: center;
251267
justify-content: center;
252-
z-index: 300;
268+
z-index: 500;
269+
pointer-events: auto;
253270
}
254271
.modal {
255272
background: rgba(30, 32, 38, 0.98);
@@ -260,6 +277,9 @@ button.primary:hover {
260277
display: flex;
261278
flex-direction: column;
262279
gap: 10px;
280+
position: relative;
281+
z-index: 501;
282+
pointer-events: auto;
263283
}
264284
.modal-title {
265285
font-weight: 600;
@@ -365,10 +385,15 @@ button.primary:hover {
365385
gap: 6px;
366386
padding: 8px 10px;
367387
border-bottom: 1px solid var(--border);
368-
position: sticky;
369-
top: 0;
370388
background: var(--bg);
371-
z-index: 10;
389+
position: relative;
390+
z-index: 100;
391+
isolation: isolate;
392+
}
393+
.settings-header .icon-btn {
394+
position: relative;
395+
z-index: 1;
396+
-webkit-app-region: no-drag;
372397
}
373398
.settings-header h3 {
374399
margin: 0;
@@ -427,6 +452,11 @@ button.primary:hover {
427452
justify-content: center;
428453
padding: 16px;
429454
overflow: hidden;
455+
position: relative;
456+
z-index: 0;
457+
}
458+
.floorplan-svg svg {
459+
pointer-events: none;
430460
}
431461
.floorplan-svg {
432462
width: 100%;

0 commit comments

Comments
 (0)