diff --git a/README.md b/README.md
index 1e8b544..986961f 100644
--- a/README.md
+++ b/README.md
@@ -60,15 +60,19 @@ right. It stacks on narrow screens and shows an initial if the avatar is unavail
Signed-in users can create manual, count, or task habits, choose a recurrence and
color, log today’s progress, and inspect their real calendars. Completed-versus-due
-counts exclude days off. Inline controls beside habit headings edit the name,
+counts exclude days off. Controls beside habit headings open a modal to edit the name,
tracking method, schedule, color, count target/unit, and unfinished-count carryover.
-Each task can be renamed, rescheduled, or deleted in place; Add task also supports
+Each task can be renamed, rescheduled, or deleted in a modal; Add task also supports
its own recurrence. Both habit and task schedules support daily, selected weekdays,
day intervals, and week intervals with an editable start date and weekday.
-Archive asks for confirmation in place and preserves earlier history.
-**Archived habits** opens the archive, with history inspection and restoration
-starting today. Task deletion still uses an in-place confirmation. Tasks
+Archive asks for confirmation in a modal and preserves earlier history.
+**Archived habits** opens the archive modal, with history inspection and restoration
+starting today. Task deletion uses the same modal confirmation. Tasks
remain manageable on days off, while their checkboxes stay disabled.
+Settings, sharing, new habits, and historical check-ins also open in native dialogs.
+Dialogs keep the background inactive, contain keyboard focus, support Escape and
+backdrop dismissal, and return focus to their opener. Long content scrolls beneath
+a fixed header; closing is disabled during saves.
Mutations are saved through the authenticated API; failed
saves retain the recorded values and allow retry. Account and calendar failures
have retry states. The dashboard refreshes on window focus and every minute while
diff --git a/src/App.test.tsx b/src/App.test.tsx
index f6f88b7..f61b88b 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -1,4 +1,5 @@
import { ReminderSettings } from "./components/ReminderSettings";
+import { Modal } from "./components/design-system/Modal";
import {
afterAll,
afterEach,
@@ -10,7 +11,7 @@ import {
test,
} from "bun:test";
import { Window } from "happy-dom";
-import { act } from "react";
+import { act, StrictMode, useState } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import type { Root } from "react-dom/client";
import { App } from "./App";
@@ -117,6 +118,54 @@ const buttonNamed = (name: string) => [...container.querySelectorAll button.textContent === name || button.getAttribute("aria-label") === name)!;
describe("homepage", () => {
+ test("development Strict Mode does not restore background focus while a modal is open", async () => {
+ await act(async () => root.render( {}} initialFocus="input">));
+ await new Promise(resolve => dom.requestAnimationFrame(() => dom.requestAnimationFrame(resolve)));
+ expect(document.activeElement).toBe(container.querySelector("input"));
+ expect(container.querySelector("dialog")?.open).toBe(true);
+ expect(document.documentElement.style.overflow).toBe("hidden");
+ });
+ test("settings open as a modal and Escape restores the opener and scrolling", async () => {
+ connectAccount();
+ await render("/");
+ const opener = buttonNamed("Settings");
+ opener.focus();
+ await act(async () => opener.click());
+ const dialog = container.querySelector("dialog#account-settings")!;
+ expect(dialog.open).toBe(true);
+ expect(dialog.contains(document.activeElement)).toBe(true);
+ expect(document.documentElement.style.overflow).toBe("hidden");
+ await act(async () => dialog.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
+ await new Promise(resolve => dom.requestAnimationFrame(resolve));
+ expect(container.querySelector("dialog")).toBeNull();
+ expect(document.activeElement).toBe(opener);
+ expect(document.documentElement.style.overflow).toBe("");
+ });
+
+ test("modal dismissal waits for saves and nested dialogs retain the scroll lock", async () => {
+ function Harness() {
+ const [nested, setNested] = useState(true);
+ const [busy, setBusy] = useState(true);
+ return {}}>
+ {nested && setNested(false)}>
+
+ }
+ ;
+ }
+ await act(async () => root.render());
+ const inner = container.querySelector("#inner")!;
+ await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
+ expect(inner.open).toBe(true);
+ expect(inner.querySelector('[aria-label="Close dialog"]')?.disabled).toBe(true);
+ await act(async () => buttonNamed("Finish save").click());
+ await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
+ expect(container.querySelector("#inner")).toBeNull();
+ expect(container.querySelector("#outer")?.open).toBe(true);
+ expect(document.documentElement.style.overflow).toBe("hidden");
+ await act(async () => root.render(null));
+ expect(document.documentElement.style.overflow).toBe("");
+ });
+
test("archived habits expose history and restore to the dashboard", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
@@ -143,6 +192,7 @@ describe("homepage", () => {
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
await render("/");
await act(async () => container.querySelector('[aria-label^="2026-09-03:"]')!.click());
+ expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
await act(async () => buttonNamed("Edit selected check-in").click());
const past = [...container.querySelectorAll('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
expect(past).toBeDefined();
diff --git a/src/components/AccountSettings.tsx b/src/components/AccountSettings.tsx
index 33b58dc..0c3f6c8 100644
--- a/src/components/AccountSettings.tsx
+++ b/src/components/AccountSettings.tsx
@@ -12,37 +12,38 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
const [confirmation, setConfirmation] = useState("");
const [deleting, setDeleting] = useState(false);
const [busy, setBusy] = useState(false);
+ const [reminderBusy, setReminderBusy] = useState(false);
const saving = useRef(false);
const [error, setError] = useState("");
const listId = useId();
async function run(action: () => Promise) {
- if (saving.current) return;
+ if (saving.current || reminderBusy) return;
saving.current = true; setBusy(true); setError("");
try { await action(); onChanged(); }
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
finally { saving.current = false; setBusy(false); }
}
- return
+ return
Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.
}>
-
+
Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.}>
Export my dataJSON file
Permanently remove your account and habit history, and sign out every device.}>
- {!deleting ? :
+ {!deleting ? :
}
{error}
diff --git a/src/components/ArchivedHabits.tsx b/src/components/ArchivedHabits.tsx
index e4b75eb..abacdef 100644
--- a/src/components/ArchivedHabits.tsx
+++ b/src/components/ArchivedHabits.tsx
@@ -37,7 +37,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
setError(message); if (message.includes("session has expired")) expired.current();
} finally { saving.current = false; setBusy(false); }
}
- return
+ return
{error}
{error && }
{loading ? Loading archive…
: !habits.length && No archived habits.
When you put a habit aside, its history will be waiting here.
}
diff --git a/src/components/CheckInEditor.tsx b/src/components/CheckInEditor.tsx
index 62c303e..b07c902 100644
--- a/src/components/CheckInEditor.tsx
+++ b/src/components/CheckInEditor.tsx
@@ -4,8 +4,8 @@ import { habitRequest, formatTrackingDate, type TodayHabit } from "../lib/dashbo
import { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field";
-export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: {
- habitId: string; date: string; disabled?: boolean; onSaved: () => void; onExpired?: () => void;
+export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired, onBusyChange }: {
+ habitId: string; date: string; disabled?: boolean; onSaved: () => void; onExpired?: () => void; onBusyChange?: (busy: boolean) => void;
}) {
const [day, setDay] = useState(null);
const [count, setCount] = useState("");
@@ -28,7 +28,7 @@ export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: {
}, [habitId, date, attempt]);
async function save(body: { count: number } | { done: boolean }, taskId?: string) {
if (saving.current || disabled) return;
- saving.current = true; setBusy(true); setError(""); setNotice("");
+ saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice("");
try {
const result = await habitRequest(`/habits/${habitId}/days/${date}/${taskId ? `tasks/${taskId}` : "progress"}`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
@@ -37,7 +37,7 @@ export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: {
} catch (error) {
const message = error instanceof Error ? error.message : "Could not save. Try again.";
setError(message); if (message.includes("session has expired")) expired.current?.();
- } finally { saving.current = false; setBusy(false); }
+ } finally { saving.current = false; setBusy(false); onBusyChange?.(false); }
}
return
diff --git a/src/components/HabitForm.tsx b/src/components/HabitForm.tsx
index ed1acfd..c5946d6 100644
--- a/src/components/HabitForm.tsx
+++ b/src/components/HabitForm.tsx
@@ -1,5 +1,6 @@
-import { useEffect, useRef, useState, type FormEvent } from "react";
-import { Button, SectionHeading } from "./design-system/primitives";
+import { Modal } from "./design-system/Modal";
+import { useRef, useState, type FormEvent } from "react";
+import { Button } from "./design-system/primitives";
import { Field } from "./design-system/Field";
import { ScheduleEditor } from "./design-system/EditingWorkbench";
import { HabitColorPicker } from "./design-system/HabitColorPicker";
@@ -30,12 +31,6 @@ export function HabitForm({
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
- useEffect(() => {
- const previous = document.activeElement as HTMLElement | null;
- form.current?.querySelector
("input")?.focus();
- return () => previous?.focus();
- }, []);
-
async function submit(event: FormEvent) {
event.preventDefault();
if (saving.current) return;
@@ -80,23 +75,7 @@ export function HabitForm({
}
return (
-
-
- Make it yours.
- >
- }
- >
- Choose what counts as complete. Your schedule follows your account’s
- timezone.
-
+ Make it yours.>} description="Choose what counts as complete. Your schedule follows your account’s timezone." onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
-
+
);
}
diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx
index 05ab10f..bbf2e3b 100644
--- a/src/components/HabitHistory.tsx
+++ b/src/components/HabitHistory.tsx
@@ -1,3 +1,5 @@
+import { Modal } from "./design-system/Modal";
+import { Pencil } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
@@ -40,6 +42,7 @@ export function HabitHistory({
}) {
const [selectedDate, setSelectedDate] = useState();
const [editingDay, setEditingDay] = useState(false);
+ const [savingDay, setSavingDay] = useState(false);
const [calendar, setCalendar] = useState(null);
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
@@ -125,6 +128,7 @@ export function HabitHistory({
color={calendar.settings.mainColor}
emptyColor={calendar.settings.emptyColor}
days={days}
+ selectedDayAction={selected => }
legend={
}
/>
-
-
-
- {editingDay &&
-
+ {editingDay &&
setEditingDay(false)} busy={savingDay}>
+
+
{ setAttempt(value => value + 1); onHistorySaved?.(); }} />
- }
+ onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
+ }
>
);
if (calendarOnly) return chart;
diff --git a/src/components/InlineItemForm.tsx b/src/components/InlineItemForm.tsx
index b6e3690..3f95c31 100644
--- a/src/components/InlineItemForm.tsx
+++ b/src/components/InlineItemForm.tsx
@@ -1,10 +1,11 @@
-import { useEffect, useRef, useState, type ReactNode } from "react";
+import { Modal } from "./design-system/Modal";
+import { useId, useRef, useState, type ReactNode } from "react";
import { Button } from "./design-system/primitives";
import { Field } from "./design-system/Field";
export type ItemMode = "edit" | "delete";
-/** In-place editing and confirmation, shared by habit headings and task rows. */
+/** Shared editing and confirmation dialogs for habit headings and task rows. */
export function InlineItemForm({ name, kind, mode, disabled, children, onSave, onDelete, onClose, submitLabel, formLabel }: {
name: string;
kind: "habit" | "task";
@@ -22,17 +23,12 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
const [draft, setDraft] = useState(name);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
- useEffect(() => {
- const previous = document.activeElement as HTMLElement | null;
- form.current?.querySelector(mode === "edit" ? "input" : "button")?.focus();
- return () => { if (previous?.isConnected) previous.focus(); };
- }, [mode]);
+ const id = useId();
+ const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete";
return (
-