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.

}>
{ event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}> - {id => setTimezone(event.target.value)} />} + {id => setTimezone(event.target.value)} />} {["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => -
+
- + Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.

}>
JSON file
Permanently remove your account and habit history, and sign out every device.

}> - {!deleting ?
: + {!deleting ?
:
{ event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}>

This cannot be undone.

Download an export first if you want a copy. Images already posted to Discord remain there; server backups expire under the operator’s retention policy.

- {id => setConfirmation(event.target.value)} />} -
+ {id => setConfirmation(event.target.value)} />} +
}
{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 &&
} 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

EDITING CHECK-IN

{formatTrackingDate(date)}

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")}>
@@ -223,6 +202,6 @@ export function HabitForm({
- + ); } 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 &&
-
{id => { if (event.target.value) setSelectedDate(event.target.value); }} />}

Update the progress recorded for this day.

+ {editingDay && setEditingDay(false)} busy={savingDay}> +
+
{id => { if (event.target.value) setSelectedDate(event.target.value); }} />}

Update the progress recorded for this day.

{ 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 ( -
{ - if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); } - }} + [...document.querySelectorAll("button[aria-label]")].find(button => button.getAttribute("aria-label") === `${action} ${kind} ${name}`) ?? null} initialFocus={mode === "edit" ? "input" : 'button[type="button"]:not(.ds-panel-close)'}> + { event.preventDefault(); if (saving.current || disabled) return; @@ -74,5 +70,6 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o {mode === "edit" && } +
); } diff --git a/src/components/ReminderSettings.tsx b/src/components/ReminderSettings.tsx index b0657ee..cc22834 100644 --- a/src/components/ReminderSettings.tsx +++ b/src/components/ReminderSettings.tsx @@ -9,7 +9,7 @@ const deliveryLabels: Record = { sent: "Delivered", deferred: "Waiting to retry", pending: "Delivery unconfirmed", uncertain: "Delivery unconfirmed; check Discord. We will not send it again today.", failed: "Discord could not deliver it. Check your DM permissions and that you share a server with the bot.", skipped: "Skipped because your check-ins or settings changed", }; -export function ReminderSettings({ timezone }: { timezone: string }) { +export function ReminderSettings({ timezone, onBusyChange, disabled = false }: { timezone: string; onBusyChange?: (busy: boolean) => void; disabled?: boolean }) { const [settings, setSettings] = useState(); const [draft, setDraft] = useState(defaultReminder); const [error, setError] = useState(""); @@ -26,26 +26,26 @@ export function ReminderSettings({ timezone }: { timezone: string }) { }, [attempt]); return

A private Discord nudge when you still have habits left today. No habit names are sent.

Times in {timezone}

}> {!settings ? error ? :

Loading reminders…

:
{ - event.preventDefault(); if (saving.current) return; + event.preventDefault(); if (saving.current || disabled) return; const form = event.currentTarget; const submitted = { ...draft, ...Object.fromEntries((["time", "quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) }; - saving.current = true; setBusy(true); setError(""); setNotice(""); + saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice(""); try { await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) }); setDraft(submitted); setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off."); } catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); } - finally { saving.current = false; setBusy(false); } + finally { saving.current = false; setBusy(false); onBusyChange?.(false); } }}> {!settings.available &&

Discord reminders are not configured on this server.

} -
+
setDraft(value => ({ ...value, enabled: event.target.checked }))} />
{([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) => {id => })}

During quiet hours we wait until they end. Equal start and end times turn quiet hours off. Missed reminders never carry into the next day.

-
+
{settings.lastDelivery &&

LAST REMINDER{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}

} } diff --git a/src/components/ShareProgress.tsx b/src/components/ShareProgress.tsx index 385c854..5e32df1 100644 --- a/src/components/ShareProgress.tsx +++ b/src/components/ShareProgress.tsx @@ -1,5 +1,6 @@ +import { Modal } from "./design-system/Modal"; import { useEffect, useRef, useState } from "react"; -import { Download, Send, X } from "lucide-react"; +import { Download, Send } from "lucide-react"; import { Button, Checkbox } from "./design-system/primitives"; import { Field } from "./design-system/Field"; import { habitRequest, type TodayResponse } from "../lib/dashboard"; @@ -25,12 +26,10 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public const [sendError, setSendError] = useState(""); const lock = useRef(false); const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null); - const heading = useRef(null); const key = JSON.stringify({ ids, from, to, privacy, revision, user }); const ready = card?.key === key ? card : null; const sent = ready && delivery?.id === ready.deliveryId ? delivery : null; const busy = sending; - useEffect(() => { heading.current?.focus(); }, []); useEffect(() => { const controller = new AbortController(); setConnectionError(""); @@ -70,11 +69,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public finally { lock.current = false; setSending(false); } } return ( -
-
-

A LITTLE PROGRESS, WORTH SHARING

Your progress, in a picture.

- -
+ document.getElementById("open-sharing")}>
@@ -103,6 +98,6 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public {sent &&

{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && View in Discord ↗} : <>Discord didn’t confirm delivery. Check {connection?.channelUrl ? your channel : "your channel"} before creating another card; this attempt won’t be resent.}

}
-
+ ); } diff --git a/src/components/design-system/CalendarHeatmap.tsx b/src/components/design-system/CalendarHeatmap.tsx index aa6a39d..173176c 100644 --- a/src/components/design-system/CalendarHeatmap.tsx +++ b/src/components/design-system/CalendarHeatmap.tsx @@ -24,6 +24,7 @@ export function CalendarHeatmap({ onSelectDate, historyLabel, legend, + selectedDayAction, }: { days: CalendarDay[]; unit: string; @@ -35,6 +36,7 @@ export function CalendarHeatmap({ onSelectDate?: (date: string) => void; historyLabel?: string; legend?: ReactNode; + selectedDayAction?: (date: string) => ReactNode; }) { const [months, setMonths] = useState(12); const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null); @@ -281,8 +283,8 @@ export function CalendarHeatmap({

-
-
+
+
+ {selectedDayAction &&
{selectedDayAction(selected.date)}
}
); diff --git a/src/components/design-system/Modal.tsx b/src/components/design-system/Modal.tsx new file mode 100644 index 0000000..617a7fb --- /dev/null +++ b/src/components/design-system/Modal.tsx @@ -0,0 +1,80 @@ +import { useLayoutEffect, useRef, type ReactNode } from "react"; +import { X } from "lucide-react"; +import { Button } from "./primitives"; + +let openModals = 0; +let previousOverflow = ""; + +/** Native top-layer dialog supplies focus containment and an inert background. */ +export function Modal({ id, eyebrow, title, description, children, onClose, closeLabel = "Close dialog", busy = false, size = "wide", initialFocus, returnFocus }: { + id: string; eyebrow?: string; title: ReactNode; description?: ReactNode; children: ReactNode; + onClose?: () => void; closeLabel?: string; busy?: boolean; size?: "wide" | "compact"; initialFocus?: string; returnFocus?: () => HTMLElement | null; +}) { + const dialog = useRef(null); + const heading = useRef(null); + const pointerOnBackdrop = useRef(false); + const mounted = useRef(false); + useLayoutEffect(() => { + mounted.current = true; + const element = dialog.current!; + const opener = returnFocus?.() ?? document.activeElement as HTMLElement | null; + if (!openModals++) { + previousOverflow = document.documentElement.style.overflow; + document.documentElement.style.overflow = "hidden"; + } + element.showModal(); + const target = initialFocus ? element.querySelector(initialFocus) : heading.current; + target?.focus({ preventScroll: true }); + return () => { + mounted.current = false; + element.close(); + if (!--openModals) document.documentElement.style.overflow = previousOverflow; + // A successful save may keep its opener disabled until the dashboard + // refresh finishes. Restore focus when it becomes available again. + window.requestAnimationFrame(() => window.requestAnimationFrame(() => { + // Ignore React Strict Mode's setup/cleanup rehearsal. + if (mounted.current || !opener?.isConnected) return; + if (!opener.matches(":disabled")) { opener.focus({ preventScroll: true }); return; } + const observer = new window.MutationObserver(() => { + if (opener.matches(":disabled")) return; + observer.disconnect(); + window.clearTimeout(timeout); + if (opener.isConnected && document.activeElement === document.body) opener.focus({ preventScroll: true }); + }); + observer.observe(opener, { attributes: true, attributeFilter: ["disabled"] }); + const timeout = window.setTimeout(() => observer.disconnect(), 5000); + })); + }; + }, []); + + const dismiss = () => { if (!busy) onClose?.(); }; + return { event.preventDefault(); event.stopPropagation(); dismiss(); }} + onKeyDown={event => { + if (event.key !== "Tab") return; + event.stopPropagation(); + const controls = [...event.currentTarget.querySelectorAll('button, a[href], input, select, textarea, [tabindex]')] + .filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0); + const first = controls[0]; + const last = controls.at(-1); + if (!first || !last) { event.preventDefault(); heading.current?.focus(); return; } + const active = document.activeElement; + if (event.shiftKey && (active === first || !controls.includes(active as HTMLElement))) { + event.preventDefault(); last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); first.focus(); + } + }} + onPointerDown={event => { pointerOnBackdrop.current = event.target === event.currentTarget; }} + onClick={event => { + if (!pointerOnBackdrop.current || event.target !== event.currentTarget) return; + const bounds = event.currentTarget.getBoundingClientRect(); + if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dismiss(); + }}> +
+
{eyebrow &&

{eyebrow}

}

{title}

{description &&

{description}

}
+ +
+
{children}
+
; +} diff --git a/src/components/design-system/WorkspacePanel.tsx b/src/components/design-system/WorkspacePanel.tsx index ab066c9..f13af9f 100644 --- a/src/components/design-system/WorkspacePanel.tsx +++ b/src/components/design-system/WorkspacePanel.tsx @@ -1,18 +1,11 @@ import { useId, type ReactNode } from "react"; -import { X } from "lucide-react"; -import { Button } from "./primitives"; +import { Modal } from "./Modal"; -/** Shared flat panel for secondary workspace flows. */ -export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel" }: { - id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; +/** Secondary workspace flows share one modal presentation. */ +export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel", busy }: { + id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; busy?: boolean; }) { - return
-
-

{eyebrow}

{title}

{description &&

{description}

}
- {onClose && } -
- {children} -
; + return {children}; } /** Consistent label, explanation and control columns for preference forms. */ diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 6c55f86..6f44c8c 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -172,6 +172,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi const [archive, setArchive] = useState(false); const [busy, setBusy] = useState(false); const [needsRefresh, setNeedsRefresh] = useState(false); + const focusAddAfterRefresh = useRef(false); const saving = useRef(false); const mounted = useRef(true); const expired = useRef(onExpired); @@ -207,7 +208,13 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi if (!controller.signal.aborted) reportError(error); }) .finally(() => { - if (!controller.signal.aborted) setLoading(false); + if (!controller.signal.aborted) { + setLoading(false); + if (focusAddAfterRefresh.current) { + focusAddAfterRefresh.current = false; + window.requestAnimationFrame(() => document.getElementById("add-habit")?.focus({ preventScroll: true })); + } + } }); return () => controller.abort(); }, [attempt, reportError]); @@ -361,6 +368,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
{today.habits.length > 0 && ( <> - + View your habits ↓ )}
- - + +
)} @@ -410,6 +418,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi onCancel={() => setAdding(false)} onExpired={() => expired.current()} onCreated={(name) => { + focusAddAfterRefresh.current = true; setAdding(false); setNotice(`${name} created.`); setAttempt((value) => value + 1); @@ -536,7 +545,7 @@ function SavedHabit({ /> ); })} - {addingTask ? ( + {addingTask && ( onManage(habit, patch, undefined, true)} onDelete={async () => {}} /> - ) : ( + )}
{!habit.requirements.tasks.length && (

Add a task to start checking in.

)}
- )} ) : undefined } diff --git a/styles/design-system.css b/styles/design-system.css index 2c47742..8dcb947 100644 --- a/styles/design-system.css +++ b/styles/design-system.css @@ -993,6 +993,12 @@ @apply type-small; } .ds-date-inspector-heading { display: flex; align-items: center; gap: 14px; min-width: 0; } +.ds-date-inspector-actions { flex-shrink: 0; margin-left: auto; } +.ds-date-inspector:has(.ds-date-inspector-actions) { display: flex; align-items: center; justify-content: space-between; gap: 24px; } +@media (max-width: 640px) { + .ds-date-inspector:has(.ds-date-inspector-actions) { align-items: stretch; flex-direction: column; gap: 18px; } + .ds-date-inspector-actions { margin-left: 36px; } +} .ds-date-inspector-heading > svg { flex-shrink: 0; color: var(--ds-secondary); } .ds-date-inspector-heading time { display: block; margin-top: 3px; @apply type-ui-heading; } .ds-date-inspector-status { display: block; margin-top: 4px; color: var(--ds-secondary); overflow-wrap: anywhere; } @@ -1405,10 +1411,6 @@ .ds-editable-task-row > .ds-checkbox { flex: 1; min-height: 76px; border-top: 0; } .ds-checkbox-copy { display: grid; gap: 4px; min-width: 0; } .ds-checkbox-description { color: var(--ds-secondary); @apply type-small; } -.ds-share-panel { margin-top: 32px; padding: 32px; border: 1px solid var(--ds-rule); } -.ds-share-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 32px; } -.ds-share-heading h2 { margin-top: 8px; } -.ds-share-heading > .ds-button { flex: 0 0 auto; padding: 12px; } .ds-share-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 36px; align-items: start; } .ds-share-controls { display: grid; gap: 24px; min-width: 0; } .ds-share-fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 10px; min-width: 0; } @@ -1426,7 +1428,6 @@ .ds-share-actions .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; } @media (max-width: 900px) { .ds-share-layout { grid-template-columns: minmax(0, 1fr); gap: 28px; } - .ds-share-panel { padding: 20px; } .ds-share-preview { padding: 12px; } } .ds-task-add-action { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; } @@ -1444,10 +1445,6 @@ .ds-utility-row { border-top: 1px solid var(--ds-rule); padding-top: 12px; margin-top: 20px; gap: 4px; } .ds-utility-row .ds-button { color: var(--ds-secondary); @apply type-small; } .ds-utility-row .ds-button[aria-expanded="true"] { color: var(--ds-ink); background: var(--ds-paper); } -.ds-workspace-panel { border: 1px solid var(--ds-rule); padding: 32px; margin: 32px 0 40px; min-width: 0; } -.ds-panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 32px; } -.ds-panel-heading > div { min-width: 0; } -.ds-panel-heading h2 { margin-top: 10px; @apply type-section; overflow-wrap: anywhere; } .ds-panel-description { margin-top: 12px !important; color: var(--ds-secondary); max-width: 52ch; } .ds-panel-close { flex: 0 0 44px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--ds-rule); } .ds-setting-row { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr); gap: 48px; padding: 28px 0; border-top: 1px solid var(--ds-rule); } @@ -1492,9 +1489,6 @@ .ds-record-heading { align-items: flex-start; flex-direction: column; gap: 16px; } } @media (max-width: 640px) { - .ds-workspace-panel { padding: 20px; margin: 24px 0 32px; } - .ds-panel-heading { gap: 16px; margin-bottom: 24px; } - .ds-panel-heading h2 { @apply type-title; } .ds-panel-description { @apply type-small; } .ds-setting-row { grid-template-columns: minmax(0, 1fr); gap: 20px; padding: 24px 0; } .ds-setting-heading > div { max-width: none; } @@ -1505,3 +1499,26 @@ .ds-record-heading > .ds-action-row { flex-shrink: 1; gap: 8px; } } @media (prefers-reduced-motion: reduce) { .ds-disclosure-icon { transition: none; } } + +/* Shared top-layer modals keep temporary flows out of the dashboard layout. */ +.ds-modal { width: min(1040px, calc(100vw - 64px)); max-width: none; max-height: calc(100dvh - 64px); margin: auto; padding: 0; border: 1px solid var(--ds-rule); background: var(--ds-paper); color: var(--ds-ink); overflow: hidden; box-shadow: 0 24px 80px #00000026; } +.ds-modal[open] { display: flex; flex-direction: column; } +.ds-modal--compact { width: min(660px, calc(100vw - 64px)); } +.ds-modal::backdrop { background: #11111166; backdrop-filter: blur(3px); } +.ds-modal-heading { display: flex; flex: 0 0 auto; justify-content: space-between; align-items: flex-start; gap: 24px; padding: 28px 32px 24px; border-bottom: 1px solid var(--ds-rule); } +.ds-modal-heading > div { min-width: 0; } +.ds-modal-heading h2 { margin-top: 8px; @apply type-section; overflow-wrap: anywhere; } +.ds-modal-heading h2:focus { outline: none; } +.ds-modal-heading .ds-panel-description { @apply type-small; } +.ds-modal-body { overflow-y: auto; overscroll-behavior: contain; min-height: 0; padding: 28px 32px 32px; scrollbar-gutter: stable; } +.ds-modal-body > .ds-setting-row:first-child { border-top: 0; padding-top: 0; } +.ds-modal-body > .ds-record-list > .ds-record:first-child { border-top: 0; } +.ds-modal-body > .ds-record-list > .ds-record:first-child > .ds-record-heading { padding-top: 0; } +.ds-modal-form { min-width: 0; } +.ds-modal-body > .ds-history-editor { border: 0; background: transparent; padding: 0; margin: 0; } +@media (max-width: 640px) { + .ds-modal, .ds-modal--compact { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); } + .ds-modal-heading { padding: 20px; gap: 16px; } + .ds-modal-heading h2 { @apply type-title; } + .ds-modal-body { padding: 20px; } +}