diff --git a/README.md b/README.md
index dd56d80..1f06296 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,7 @@ This writes an unpacked extension to `dist/extension/` and a zip to `dist/boost-
1. Open `chrome://extensions` (or `edge://extensions`) and turn on **Developer mode**.
2. Click **Load unpacked** and select `dist/extension`.
-3. Click the toolbar icon to open the timer in a **400 × 600 popup** (a compact layout of the same app). Use the ⤢ button in its top bar, or Alt+Enter, to open the **full page** in a tab; an existing tab is focused instead of opening a second one.
+3. Click the toolbar icon to open the timer in its own **popup window** (a compact layout of the same app) with a taskbar icon; it remembers its size and position. Use the ⤢ button in its top bar, or Alt+Enter, to open the **full page** in a tab; an existing tab is focused instead of opening a second one.
4. Optional: assign a global shortcut for **Open Boost Focus Timer** at `chrome://extensions/shortcuts`.
A session keeps running when the popup closes: reopening it continues the countdown, and the extension shows the "Focus session complete" / "Break is over" notification even while the popup is closed (the end chime only plays if the app is open). Popup and full page share the same data, which lives in the extension's own storage, separate from the web app's. Firefox is not supported yet.
diff --git a/extension/background.js b/extension/background.js
index d532c57..96b39ef 100644
--- a/extension/background.js
+++ b/extension/background.js
@@ -1,7 +1,12 @@
-// Background worker. The toolbar button opens the popup (manifest `default_popup`); this worker
+// Background worker. The toolbar button opens the compact app in a popup window; this worker also
// opens the full-page tab on request and raises the end-of-session notification, because the
-// popup is destroyed whenever it loses focus and cannot wait for a timer itself.
+// popup window may be closed while a timer runs.
const APP_URL = chrome.runtime.getURL("index.html");
+const POPUP_URL = chrome.runtime.getURL("popup.html");
+const POPUP_SIZE = { width: 420, height: 640 };
+const POPUP_EDGE_MARGIN_PX = 16;
+const POPUP_TOOLBAR_OFFSET_PX = 72;
+const POPUP_BOUNDS_KEY = "popupBounds";
const ICON_URL = chrome.runtime.getURL("icons/icon-128.png");
const END_ALARM = "session-end";
const PENDING_KEY = "pendingNotification";
@@ -18,6 +23,38 @@ async function openApp() {
await chrome.windows.update(existing.windowId, { focused: true });
}
+async function popupContext() {
+ const [context] = await chrome.runtime.getContexts({ contextTypes: ["TAB"], documentUrls: [POPUP_URL] });
+ return context ?? null;
+}
+
+/** Top-right of the current browser window, under its toolbar, like the dropdown it replaces. */
+function defaultPopupBounds(browserWindow) {
+ return {
+ left: Math.max(0, browserWindow.left + browserWindow.width - POPUP_SIZE.width - POPUP_EDGE_MARGIN_PX),
+ top: Math.max(0, browserWindow.top + POPUP_TOOLBAR_OFFSET_PX),
+ ...POPUP_SIZE,
+ };
+}
+
+async function openPopup() {
+ const existing = await popupContext();
+ if (existing) {
+ await chrome.windows.update(existing.windowId, { focused: true });
+ return;
+ }
+ const { [POPUP_BOUNDS_KEY]: saved } = await chrome.storage.local.get(POPUP_BOUNDS_KEY);
+ const bounds = saved ?? defaultPopupBounds(await chrome.windows.getLastFocused());
+ await chrome.windows.create({ url: POPUP_URL, type: "popup", focused: true, ...bounds });
+}
+
+async function rememberPopupBounds(browserWindow) {
+ const popup = await popupContext();
+ if (popup?.windowId !== browserWindow.id) return;
+ const { left, top, width, height } = browserWindow;
+ await chrome.storage.local.set({ [POPUP_BOUNDS_KEY]: { left, top, width, height } });
+}
+
async function scheduleEnd({ at, title, body }) {
await chrome.storage.session.set({ [PENDING_KEY]: { title, body } });
await chrome.alarms.create(END_ALARM, { when: at });
@@ -28,6 +65,14 @@ async function cancelEnd() {
await chrome.storage.session.remove(PENDING_KEY);
}
+chrome.action.onClicked.addListener(() => {
+ openPopup().catch((error) => console.error("Failed to open the popup window:", error));
+});
+
+chrome.windows.onBoundsChanged.addListener((browserWindow) => {
+ rememberPopupBounds(browserWindow).catch((error) => console.error("Failed to remember the popup size:", error));
+});
+
chrome.runtime.onMessage.addListener((message) => {
const handlers = { "open-app": openApp, "schedule-end": scheduleEnd, "cancel-end": cancelEnd };
handlers[message?.type]?.(message).catch((error) => console.error(`Failed to handle ${message.type}:`, error));
diff --git a/scripts/build-extension.mjs b/scripts/build-extension.mjs
index f3b500e..ec02451 100644
--- a/scripts/build-extension.mjs
+++ b/scripts/build-extension.mjs
@@ -63,7 +63,7 @@ function externalizeInlineScripts() {
}
}
-/** The toolbar popup is the same page, marked so the app switches to its compact 400x600 layout. */
+/** The popup window is the same page, marked so the app switches to its compact 400x600 layout. */
function writePopupPage() {
const html = readFileSync(join(EXTENSION_DIR, "index.html"), "utf8");
if (!html.includes(" tag");
@@ -82,7 +82,9 @@ function writeManifest() {
// notifications + alarms: the background worker announces a session's end even with the popup closed;
// storage: it keeps that notification's text in chrome.storage.session until the alarm fires.
permissions: ["notifications", "alarms", "storage"],
- action: { default_title: "Boost Focus Timer", default_icon: icons, default_popup: "popup.html" },
+ // No default_popup: the background worker opens popup.html in its own window, which gets a
+ // taskbar entry with the extension icon and survives losing focus.
+ action: { default_title: "Boost Focus Timer", default_icon: icons },
background: { service_worker: "background.js" },
// Unassigned by default; users pick a key at chrome://extensions/shortcuts.
commands: { _execute_action: { description: "Open Boost Focus Timer" } },
diff --git a/src/app/globals.css b/src/app/globals.css
index ac9a6f6..1be73c9 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -6,7 +6,7 @@
* Classes v4 dropped (bg-opacity-*, flex-shrink-*, ...) must be rewritten in markup.
*/
@custom-variant wide (@media (min-width: 820px));
-/* The extension's toolbar popup: popup.html carries data-view="popup" (see scripts/build-extension.mjs). */
+/* The extension's popup window: popup.html carries data-view="popup" (see scripts/build-extension.mjs). */
@custom-variant popup (&:where([data-view="popup"] *));
@theme {
@@ -261,14 +261,10 @@
background-color: #111827;
}
- /*
- * Chrome caps extension popups at 800x600 and sizes them to the document. 400 wide matches the
- * phone layout the app was designed at; 600 is the tallest Chrome allows.
- */
+ /* The popup is a resizable window (see extension/background.js), so it fills whatever size it has. */
html[data-view="popup"],
html[data-view="popup"] body {
- width: 400px;
- height: 600px;
+ height: 100vh;
}
}
diff --git a/src/components/focus/FocusTimer.tsx b/src/components/focus/FocusTimer.tsx
index a21753f..a228831 100644
--- a/src/components/focus/FocusTimer.tsx
+++ b/src/components/focus/FocusTimer.tsx
@@ -209,7 +209,7 @@ export const FocusTimer = forwardRef(function
};
/**
- * Extension only: the popup closes whenever it loses focus, so a countdown that was running when it
+ * Extension only: the popup window can be closed mid-session, so a countdown that was running when it
* closed keeps running (or has finished) instead of coming back paused.
*/
const continueRunningTimer = async (running: RunningTimer, latest: FocusSession): Promise => {
@@ -303,6 +303,19 @@ export const FocusTimer = forwardRef(function
rerollAnimation();
};
+ // An unfinished session has no end time, so its minutes never reach today's total.
+ const confirmReset = async () => {
+ if (session && !session.completed) {
+ const confirmed = await dialog.confirm("Time focused so far won't count toward today.", {
+ title: "Reset this session?",
+ confirmLabel: "Reset",
+ destructive: true,
+ });
+ if (!confirmed) return;
+ }
+ resetTimer();
+ };
+
const completeSession = async () => {
if (!session || session.completed) return;
try {
@@ -541,7 +554,7 @@ export const FocusTimer = forwardRef(function
useHotkey("timer.shuffle", toggleAutoShuffle, isActive && isIdle);
useHotkey("timer.nextAnimation", selectNextAnimation, isActive && isIdle);
const isSessionComplete = !isIdle && !isOnBreak && focus.isCompleted;
- useHotkey("timer.reset", resetTimer, isActive && !isIdle && !isDirectBreak && !isOnBreak);
+ useHotkey("timer.reset", () => void confirmReset(), isActive && !isIdle && !isDirectBreak && !isOnBreak);
useHotkey("timer.extend", () => void extendFocus(), isActive && isSessionComplete);
const toggleCombo = useHotkeyLabel("timer.toggle");
const endCombo = useHotkeyLabel("timer.end");
@@ -744,7 +757,7 @@ export const FocusTimer = forwardRef(function
Start Break
-
+ void confirmReset()} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_BASE} px-4 py-3 bg-gray-500 hover:bg-gray-600`}>
Reset
@@ -766,7 +779,7 @@ export const FocusTimer = forwardRef(function
Done
-
+ void confirmReset()} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_SMALL} bg-gray-500 hover:bg-gray-600`}>
Reset
diff --git a/src/components/hotkeys/HotkeysProvider.tsx b/src/components/hotkeys/HotkeysProvider.tsx
index c18c441..36bb876 100644
--- a/src/components/hotkeys/HotkeysProvider.tsx
+++ b/src/components/hotkeys/HotkeysProvider.tsx
@@ -20,6 +20,7 @@ interface HotkeysContextValue {
rebind(actionId: string, combo: string | null): void;
resetAll(): void;
setCapturing(isCapturing: boolean): void;
+ openPanel(): void;
}
const HotkeysContext = createContext(null);
@@ -90,9 +91,11 @@ export function HotkeysProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener("keydown", onKeyDown);
}, [bindings]);
+ const openPanel = useCallback(() => setIsPanelOpen(true), []);
+
const value = useMemo(
- () => ({ bindings, register, rebind, resetAll, setCapturing }),
- [bindings, register, rebind, resetAll, setCapturing],
+ () => ({ bindings, register, rebind, resetAll, setCapturing, openPanel }),
+ [bindings, register, rebind, resetAll, setCapturing, openPanel],
);
return (
diff --git a/src/components/hotkeys/ShortcutsPanel.tsx b/src/components/hotkeys/ShortcutsPanel.tsx
index cf817fc..8ccc32a 100644
--- a/src/components/hotkeys/ShortcutsPanel.tsx
+++ b/src/components/hotkeys/ShortcutsPanel.tsx
@@ -19,10 +19,11 @@ function isExtensionPage(): boolean {
export function ShortcutsPanel({ actions, onClose }: { actions: readonly HotkeyAction[]; onClose(): void }) {
const { bindings, rebind, resetAll, setCapturing } = useHotkeys();
const [editingId, setEditingId] = useState(null);
- const doneRef = useRef(null);
+ const dialogRef = useRef(null);
+ // Focus lands on the dialog itself so no control shows a ring and Enter cannot close it by accident.
useEffect(() => {
- doneRef.current?.focus();
+ dialogRef.current?.focus();
}, []);
useEffect(() => {
@@ -62,17 +63,18 @@ export function ShortcutsPanel({ actions, onClose }: { actions: readonly HotkeyA
role="presentation"
>