Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Alt</kbd>+<kbd>Enter</kbd>, 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 <kbd>Alt</kbd>+<kbd>Enter</kbd>, 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.
Expand Down
49 changes: 47 additions & 2 deletions extension/background.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 });
Expand All @@ -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));
Expand Down
6 changes: 4 additions & 2 deletions scripts/build-extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<html")) throw new Error("index.html has no <html> tag");
Expand All @@ -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" } },
Expand Down
10 changes: 3 additions & 7 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}

Expand Down
21 changes: 17 additions & 4 deletions src/components/focus/FocusTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(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<boolean> => {
Expand Down Expand Up @@ -303,6 +303,19 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(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 {
Expand Down Expand Up @@ -541,7 +554,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(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");
Expand Down Expand Up @@ -744,7 +757,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
<Play className="w-5 h-5" />
Start Break
</TimerButton>
<TimerButton onClick={resetTimer} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_BASE} px-4 py-3 bg-gray-500 hover:bg-gray-600`}>
<TimerButton onClick={() => void confirmReset()} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_BASE} px-4 py-3 bg-gray-500 hover:bg-gray-600`}>
<RotateCcw className="w-5 h-5" />
Reset
</TimerButton>
Expand All @@ -766,7 +779,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
<Target className={ICON_SMALL} />
<span>Done</span>
</TimerButton>
<TimerButton onClick={resetTimer} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_SMALL} bg-gray-500 hover:bg-gray-600`}>
<TimerButton onClick={() => void confirmReset()} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_SMALL} bg-gray-500 hover:bg-gray-600`}>
<RotateCcw className={ICON_SMALL} />
Reset
</TimerButton>
Expand Down
7 changes: 5 additions & 2 deletions src/components/hotkeys/HotkeysProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface HotkeysContextValue {
rebind(actionId: string, combo: string | null): void;
resetAll(): void;
setCapturing(isCapturing: boolean): void;
openPanel(): void;
}

const HotkeysContext = createContext<HotkeysContextValue | null>(null);
Expand Down Expand Up @@ -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 (
Expand Down
10 changes: 6 additions & 4 deletions src/components/hotkeys/ShortcutsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const doneRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(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(() => {
Expand Down Expand Up @@ -62,17 +63,18 @@ export function ShortcutsPanel({ actions, onClose }: { actions: readonly HotkeyA
role="presentation"
>
<div
ref={dialogRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
className="w-full max-w-md max-h-[85vh] flex flex-col bg-gray-900 border border-gray-700 rounded-2xl shadow-2xl overflow-hidden motion-safe:animate-[bft-sheet-in_200ms_ease-out]"
className="outline-none w-full max-w-md max-h-[85vh] flex flex-col bg-gray-900 border border-gray-700 rounded-2xl shadow-2xl overflow-hidden motion-safe:animate-[bft-sheet-in_200ms_ease-out]"
>
<div className="relative flex items-center justify-center px-5 pt-5 pb-2">
<h2 id="shortcuts-title" className="text-base font-semibold text-white">
Keyboard Shortcuts
</h2>
<button
ref={doneRef}
onClick={onClose}
className="absolute right-4 rounded-md px-1.5 py-0.5 text-sm font-semibold text-blue-400 hover:text-blue-300 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60"
>
Expand Down
1 change: 1 addition & 0 deletions src/components/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const Globe = createIcon("Globe", [["circle",{cx:"12",cy:"12",r:"10"}],["
export const GripVertical = createIcon("GripVertical", [["circle",{cx:"9",cy:"12",r:"1"}],["circle",{cx:"9",cy:"5",r:"1"}],["circle",{cx:"9",cy:"19",r:"1"}],["circle",{cx:"15",cy:"12",r:"1"}],["circle",{cx:"15",cy:"5",r:"1"}],["circle",{cx:"15",cy:"19",r:"1"}]]);
export const Handshake = createIcon("Handshake", [["path",{d:"m11 17 2 2a1 1 0 1 0 3-3"}],["path",{d:"m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"}],["path",{d:"m21 3 1 11h-2"}],["path",{d:"M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"}],["path",{d:"M3 4h8"}]]);
export const Hash = createIcon("Hash", [["line",{x1:"4",x2:"20",y1:"9",y2:"9"}],["line",{x1:"4",x2:"20",y1:"15",y2:"15"}],["line",{x1:"10",x2:"8",y1:"3",y2:"21"}],["line",{x1:"16",x2:"14",y1:"3",y2:"21"}]]);
export const Keyboard = createIcon("Keyboard", [["rect",{width:"20",height:"16",x:"2",y:"4",rx:"2"}],["path",{d:"M6 8h.001"}],["path",{d:"M10 8h.001"}],["path",{d:"M14 8h.001"}],["path",{d:"M18 8h.001"}],["path",{d:"M8 12h.001"}],["path",{d:"M12 12h.001"}],["path",{d:"M16 12h.001"}],["path",{d:"M7 16h10"}]]);
export const Layers = createIcon("Layers", [["path",{d:"m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"}],["path",{d:"m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"}],["path",{d:"m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"}]]);
export const LayoutGrid = createIcon("LayoutGrid", [["rect",{width:"7",height:"7",x:"3",y:"3",rx:"1"}],["rect",{width:"7",height:"7",x:"14",y:"3",rx:"1"}],["rect",{width:"7",height:"7",x:"14",y:"14",rx:"1"}],["rect",{width:"7",height:"7",x:"3",y:"14",rx:"1"}]]);
export const LineChart = createIcon("LineChart", [["path",{d:"M3 3v18h18"}],["path",{d:"m19 9-5 5-4-4-3 3"}]]);
Expand Down
6 changes: 3 additions & 3 deletions src/components/lib/extension.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
* Browser-extension glue. In the extension the app runs in a toolbar popup that is destroyed
* whenever it loses focus, so a running timer is recorded here and the background worker
* raises the end-of-session notification. Every function is a no-op in the web app.
* Browser-extension glue. In the extension the app runs in a popup window the user may close
* at any time, so a running timer is recorded here and the background worker raises the
* end-of-session notification. Every function is a no-op in the web app.
*/

const RUNNING_TIMER_KEY = "bft:runningTimer";
Expand Down
17 changes: 15 additions & 2 deletions src/components/shell/StartMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import { useEffect, useRef, useState, type ComponentType } from "react";
import { notificationStatus, requestNotificationPermission, type NotificationStatus } from "../focus/notifications";
import { Bell, ChevronDown, CircleUser, Expand, Menu, Settings, Tag, Volume2 } from "../icons";
import { Bell, ChevronDown, CircleUser, Expand, Keyboard, Menu, Settings, Tag, Volume2 } from "../icons";
import { hotkeyHint } from "../focus/GoalForm";
import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider";
import { useHotkey, useHotkeyLabel, useHotkeys } from "../hotkeys/HotkeysProvider";
import type { Tab } from "../types";
import { isPopupView, openFullPage } from "../lib/extension";
import { SoundsPanel } from "./SoundsPanel";
Expand Down Expand Up @@ -80,6 +80,8 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) {
setIsOpen(false);
});
const soundsCombo = useHotkeyLabel("app.sounds");
const { openPanel: openShortcuts } = useHotkeys();
const shortcutsCombo = useHotkeyLabel("app.shortcuts");
const [notificationsSupported] = useState(() => notificationStatus() !== "unsupported");
// Closing the menu keeps its Notifications item from showing a status the hotkey just changed.
useHotkey("app.notifications", () => {
Expand Down Expand Up @@ -160,6 +162,17 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) {
<Volume2 className="w-4 h-4" />
<span>Sounds</span>
</button>
<button
onClick={() => {
openShortcuts();
setIsOpen(false);
}}
title={hotkeyHint("Keyboard Shortcuts", shortcutsCombo)}
className={SUB_ITEM}
>
<Keyboard className="w-4 h-4" />
<span>Keyboard Shortcuts</span>
</button>
<NotificationsItem />
</div>
) : null}
Expand Down
Loading