Use shared modals and combine selected-day actions

This commit is contained in:
syntaxbullet
2026-09-04 19:21:24 +02:00
parent 392deb9cbe
commit 892487eb4c
15 changed files with 242 additions and 113 deletions

View File

@@ -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 Signed-in users can create manual, count, or task habits, choose a recurrence and
color, log todays progress, and inspect their real calendars. Completed-versus-due color, log todays 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. 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, its own recurrence. Both habit and task schedules support daily, selected weekdays,
day intervals, and week intervals with an editable start date and weekday. day intervals, and week intervals with an editable start date and weekday.
Archive asks for confirmation in place and preserves earlier history. Archive asks for confirmation in a modal and preserves earlier history.
**Archived habits** opens the archive, with history inspection and restoration **Archived habits** opens the archive modal, with history inspection and restoration
starting today. Task deletion still uses an in-place confirmation. Tasks starting today. Task deletion uses the same modal confirmation. Tasks
remain manageable on days off, while their checkboxes stay disabled. 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 Mutations are saved through the authenticated API; failed
saves retain the recorded values and allow retry. Account and calendar failures 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 have retry states. The dashboard refreshes on window focus and every minute while

View File

@@ -1,4 +1,5 @@
import { ReminderSettings } from "./components/ReminderSettings"; import { ReminderSettings } from "./components/ReminderSettings";
import { Modal } from "./components/design-system/Modal";
import { import {
afterAll, afterAll,
afterEach, afterEach,
@@ -10,7 +11,7 @@ import {
test, test,
} from "bun:test"; } from "bun:test";
import { Window } from "happy-dom"; import { Window } from "happy-dom";
import { act } from "react"; import { act, StrictMode, useState } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router"; import { MemoryRouter, useLocation, useNavigate } from "react-router";
import type { Root } from "react-dom/client"; import type { Root } from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
@@ -117,6 +118,54 @@ const buttonNamed = (name: string) => [...container.querySelectorAll<HTMLButtonE
.find(button => button.textContent === name || button.getAttribute("aria-label") === name)!; .find(button => button.textContent === name || button.getAttribute("aria-label") === name)!;
describe("homepage", () => { describe("homepage", () => {
test("development Strict Mode does not restore background focus while a modal is open", async () => {
await act(async () => root.render(<StrictMode><Modal id="strict-modal" title="Editor" onClose={() => {}} initialFocus="input"><input aria-label="Draft" /></Modal></StrictMode>));
await new Promise(resolve => dom.requestAnimationFrame(() => dom.requestAnimationFrame(resolve)));
expect(document.activeElement).toBe(container.querySelector("input"));
expect(container.querySelector<HTMLDialogElement>("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<HTMLDialogElement>("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 <Modal id="outer" title="Outer" onClose={() => {}}>
{nested && <Modal id="inner" title="Inner" busy={busy} onClose={() => setNested(false)}>
<button onClick={() => setBusy(false)}>Finish save</button>
</Modal>}
</Modal>;
}
await act(async () => root.render(<Harness />));
const inner = container.querySelector<HTMLDialogElement>("#inner")!;
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
expect(inner.open).toBe(true);
expect(inner.querySelector<HTMLButtonElement>('[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<HTMLDialogElement>("#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 () => { test("archived habits expose history and restore to the dashboard", async () => {
const f = connectAccount(); const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z"); 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 f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
await render("/"); await render("/");
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click()); await act(async () => container.querySelector<HTMLButtonElement>('[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()); await act(async () => buttonNamed("Edit selected check-in").click());
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!; const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
expect(past).toBeDefined(); expect(past).toBeDefined();

View File

@@ -12,37 +12,38 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
const [confirmation, setConfirmation] = useState(""); const [confirmation, setConfirmation] = useState("");
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [reminderBusy, setReminderBusy] = useState(false);
const saving = useRef(false); const saving = useRef(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const listId = useId(); const listId = useId();
async function run(action: () => Promise<unknown>) { async function run(action: () => Promise<unknown>) {
if (saving.current) return; if (saving.current || reminderBusy) return;
saving.current = true; setBusy(true); setError(""); saving.current = true; setBusy(true); setError("");
try { await action(); onChanged(); } try { await action(); onChanged(); }
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); } catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
finally { saving.current = false; setBusy(false); } finally { saving.current = false; setBusy(false); }
} }
return <WorkspacePanel id="account-settings" eyebrow="YOUR WORKSPACE" title="Account settings" description="A few preferences to make this space yours." onClose={busy ? undefined : onClose} closeLabel="Close settings"> return <WorkspacePanel id="account-settings" eyebrow="YOUR WORKSPACE" title="Account settings" description="A few preferences to make this space yours." onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
<SettingRow title="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</p>}> <SettingRow title="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</p>}>
<form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}> <form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}>
<Field label="Timezone"> <Field label="Timezone">
{id => <input id={id} list={listId} value={timezone} required maxLength={100} disabled={busy} onChange={event => setTimezone(event.target.value)} />} {id => <input id={id} list={listId} value={timezone} required maxLength={100} disabled={busy || reminderBusy} onChange={event => setTimezone(event.target.value)} />}
</Field> </Field>
<datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist> <datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist>
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy}>{busy && !deleting ? "Saving…" : "Save timezone"}</Button></div> <div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || reminderBusy}>{busy && !deleting ? "Saving…" : "Save timezone"}</Button></div>
</form> </form>
</SettingRow> </SettingRow>
<ReminderSettings timezone={user.timezone} /> <ReminderSettings timezone={user.timezone} onBusyChange={setReminderBusy} disabled={busy} />
<SettingRow title="A copy for you" description={<p>Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.</p>}> <SettingRow title="A copy for you" description={<p>Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.</p>}>
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="minabot-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div> <div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="minabot-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
</SettingRow> </SettingRow>
<SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}> <SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}>
{!deleting ? <div className="ds-action-row"><Button variant="text" disabled={busy} onClick={() => setDeleting(true)} aria-expanded={false}><Trash2 size={16} aria-hidden="true" />Delete account</Button></div> : {!deleting ? <div className="ds-action-row"><Button variant="text" disabled={busy || reminderBusy} onClick={() => setDeleting(true)} aria-expanded={false}><Trash2 size={16} aria-hidden="true" />Delete account</Button></div> :
<form className="ds-form-stack ds-confirmation" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}> <form className="ds-form-stack ds-confirmation" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}>
<p className="type-ui-heading">This cannot be undone.</p> <p className="type-ui-heading">This cannot be undone.</p>
<p className="ds-supporting-copy">Download an export first if you want a copy. Images already posted to Discord remain there; server backups expire under the operators retention policy.</p> <p className="ds-supporting-copy">Download an export first if you want a copy. Images already posted to Discord remain there; server backups expire under the operators retention policy.</p>
<Field label="Type DELETE to confirm">{id => <input id={id} value={confirmation} disabled={busy} autoComplete="off" onChange={event => setConfirmation(event.target.value)} />}</Field> <Field label="Type DELETE to confirm">{id => <input id={id} value={confirmation} disabled={busy || reminderBusy} autoComplete="off" onChange={event => setConfirmation(event.target.value)} />}</Field>
<div className="ds-action-row"><Button type="submit" disabled={busy || confirmation !== "DELETE"}>Permanently delete my account</Button><Button variant="text" disabled={busy} onClick={() => { setDeleting(false); setConfirmation(""); }}>Cancel deletion</Button></div> <div className="ds-action-row"><Button type="submit" disabled={busy || reminderBusy || confirmation !== "DELETE"}>Permanently delete my account</Button><Button variant="text" disabled={busy || reminderBusy} onClick={() => { setDeleting(false); setConfirmation(""); }}>Cancel deletion</Button></div>
</form>} </form>}
</SettingRow> </SettingRow>
<FormMessage error>{error}</FormMessage> <FormMessage error>{error}</FormMessage>

View File

@@ -37,7 +37,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
setError(message); if (message.includes("session has expired")) expired.current(); setError(message); if (message.includes("session has expired")) expired.current();
} finally { saving.current = false; setBusy(false); } } finally { saving.current = false; setBusy(false); }
} }
return <WorkspacePanel id="archived-habits" eyebrow="SAVED FOR LATER" title="Archived habits" description="Earlier progress stays here. Restore a habit whenever youre ready." onClose={busy ? undefined : onClose} closeLabel="Close archive"> return <WorkspacePanel id="archived-habits" eyebrow="SAVED FOR LATER" title="Archived habits" description="Earlier progress stays here. Restore a habit whenever youre ready." onClose={onClose} busy={busy} closeLabel="Close archive">
<FormMessage error>{error}</FormMessage> <FormMessage error>{error}</FormMessage>
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>} {error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
{loading ? <p className="ds-supporting-copy" role="status">Loading archive</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p><p className="ds-supporting-copy">When you put a habit aside, its history will be waiting here.</p></div></div>} {loading ? <p className="ds-supporting-copy" role="status">Loading archive</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p><p className="ds-supporting-copy">When you put a habit aside, its history will be waiting here.</p></div></div>}

View File

@@ -4,8 +4,8 @@ import { habitRequest, formatTrackingDate, type TodayHabit } from "../lib/dashbo
import { Button, Checkbox } from "./design-system/primitives"; import { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field"; import { Field } from "./design-system/Field";
export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: { export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired, onBusyChange }: {
habitId: string; date: string; disabled?: boolean; onSaved: () => void; onExpired?: () => void; habitId: string; date: string; disabled?: boolean; onSaved: () => void; onExpired?: () => void; onBusyChange?: (busy: boolean) => void;
}) { }) {
const [day, setDay] = useState<TodayHabit | null>(null); const [day, setDay] = useState<TodayHabit | null>(null);
const [count, setCount] = useState(""); const [count, setCount] = useState("");
@@ -28,7 +28,7 @@ export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: {
}, [habitId, date, attempt]); }, [habitId, date, attempt]);
async function save(body: { count: number } | { done: boolean }, taskId?: string) { async function save(body: { count: number } | { done: boolean }, taskId?: string) {
if (saving.current || disabled) return; if (saving.current || disabled) return;
saving.current = true; setBusy(true); setError(""); setNotice(""); saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice("");
try { try {
const result = await habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}/${taskId ? `tasks/${taskId}` : "progress"}`, { const result = await habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}/${taskId ? `tasks/${taskId}` : "progress"}`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), 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) { } catch (error) {
const message = error instanceof Error ? error.message : "Could not save. Try again."; const message = error instanceof Error ? error.message : "Could not save. Try again.";
setError(message); if (message.includes("session has expired")) expired.current?.(); 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 <div className="ds-form-stack ds-check-in-controls" aria-label={`Check-in for ${date}`}> return <div className="ds-form-stack ds-check-in-controls" aria-label={`Check-in for ${date}`}>
<header className="ds-check-in-heading"><p className="ds-eyebrow">EDITING CHECK-IN</p><p className="type-ui-heading">{formatTrackingDate(date)}</p></header> <header className="ds-check-in-heading"><p className="ds-eyebrow">EDITING CHECK-IN</p><p className="type-ui-heading">{formatTrackingDate(date)}</p></header>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, type FormEvent } from "react"; import { Modal } from "./design-system/Modal";
import { Button, SectionHeading } from "./design-system/primitives"; import { useRef, useState, type FormEvent } from "react";
import { Button } from "./design-system/primitives";
import { Field } from "./design-system/Field"; import { Field } from "./design-system/Field";
import { ScheduleEditor } from "./design-system/EditingWorkbench"; import { ScheduleEditor } from "./design-system/EditingWorkbench";
import { HabitColorPicker } from "./design-system/HabitColorPicker"; import { HabitColorPicker } from "./design-system/HabitColorPicker";
@@ -30,12 +31,6 @@ export function HabitForm({
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
form.current?.querySelector<HTMLInputElement>("input")?.focus();
return () => previous?.focus();
}, []);
async function submit(event: FormEvent) { async function submit(event: FormEvent) {
event.preventDefault(); event.preventDefault();
if (saving.current) return; if (saving.current) return;
@@ -80,23 +75,7 @@ export function HabitForm({
} }
return ( return (
<section <Modal id="new-habit" eyebrow="A NEW HABIT" title={<>Make it <em>yours.</em></>} description="Choose what counts as complete. Your schedule follows your accounts timezone." onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
className="ds-section ds-split-section"
aria-labelledby="new-habit-title"
id="new-habit"
>
<SectionHeading
number="A NEW HABIT"
id="new-habit-title"
title={
<>
Make it <em>yours.</em>
</>
}
>
Choose what counts as complete. Your schedule follows your accounts
timezone.
</SectionHeading>
<form ref={form} className="ds-edit-content" onSubmit={submit}> <form ref={form} className="ds-edit-content" onSubmit={submit}>
<fieldset className="ds-task-editor-fieldset" disabled={busy}> <fieldset className="ds-task-editor-fieldset" disabled={busy}>
<Field label="Habit name"> <Field label="Habit name">
@@ -223,6 +202,6 @@ export function HabitForm({
</Button> </Button>
</div> </div>
</form> </form>
</section> </Modal>
); );
} }

View File

@@ -1,3 +1,5 @@
import { Modal } from "./design-system/Modal";
import { Pencil } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import type { CalendarResponse } from "../shared/calendar"; import type { CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard"; import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
@@ -40,6 +42,7 @@ export function HabitHistory({
}) { }) {
const [selectedDate, setSelectedDate] = useState<string>(); const [selectedDate, setSelectedDate] = useState<string>();
const [editingDay, setEditingDay] = useState(false); const [editingDay, setEditingDay] = useState(false);
const [savingDay, setSavingDay] = useState(false);
const [calendar, setCalendar] = useState<CalendarResponse | null>(null); const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
@@ -125,6 +128,7 @@ export function HabitHistory({
color={calendar.settings.mainColor} color={calendar.settings.mainColor}
emptyColor={calendar.settings.emptyColor} emptyColor={calendar.settings.emptyColor}
days={days} days={days}
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { setSelectedDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
legend={ legend={
<CalendarLegend <CalendarLegend
label={`${habit.name} progress calendar`} label={`${habit.name} progress calendar`}
@@ -134,16 +138,12 @@ export function HabitHistory({
/> />
} }
/> />
<div className="ds-action-row ds-history-actions"> {editingDay && <Modal id={`check-in-${habit.habitId}`} eyebrow="YOUR HISTORY" title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
<Button variant="text" disabled={disabled} onClick={() => { setSelectedDate(selectedDate ?? date); setEditingDay(!editingDay); }} aria-expanded={editingDay}> <div className="ds-history-editor">
{editingDay ? "Close check-in editor" : "Edit selected check-in"} <div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field><p className="ds-supporting-copy">Update the progress recorded for this day.</p></div>
</Button>
</div>
{editingDay && <div className="ds-history-editor">
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field><p className="ds-supporting-copy">Update the progress recorded for this day.</p></div>
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired} <CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} /> onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</div>} </div></Modal>}
</> </>
); );
if (calendarOnly) return chart; if (calendarOnly) return chart;

View File

@@ -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 { Button } from "./design-system/primitives";
import { Field } from "./design-system/Field"; import { Field } from "./design-system/Field";
export type ItemMode = "edit" | "delete"; 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 }: { export function InlineItemForm({ name, kind, mode, disabled, children, onSave, onDelete, onClose, submitLabel, formLabel }: {
name: string; name: string;
kind: "habit" | "task"; kind: "habit" | "task";
@@ -22,17 +23,12 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
const [draft, setDraft] = useState(name); const [draft, setDraft] = useState(name);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
useEffect(() => { const id = useId();
const previous = document.activeElement as HTMLElement | null; const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete";
form.current?.querySelector<HTMLElement>(mode === "edit" ? "input" : "button")?.focus();
return () => { if (previous?.isConnected) previous.focus(); };
}, [mode]);
return ( return (
<form ref={form} className={`ds-inline-item-form ds-inline-item-form--${kind}`} aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`} <Modal id={`item-${id}`} eyebrow={kind === "habit" ? "YOUR HABIT" : "YOUR TASK"} title={formLabel ?? `${action} ${kind}`} description={name || undefined} onClose={onClose} closeLabel={`Close ${kind} editor`} busy={busy} size="compact" returnFocus={() => [...document.querySelectorAll<HTMLButtonElement>("button[aria-label]")].find(button => button.getAttribute("aria-label") === `${action} ${kind} ${name}`) ?? null} initialFocus={mode === "edit" ? "input" : 'button[type="button"]:not(.ds-panel-close)'}>
onKeyDown={(event) => { <form ref={form} className="ds-modal-form" aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`}
if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); }
}}
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault(); event.preventDefault();
if (saving.current || disabled) return; if (saving.current || disabled) return;
@@ -74,5 +70,6 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
{mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>} {mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
</div> </div>
</form> </form>
</Modal>
); );
} }

View File

@@ -9,7 +9,7 @@ const deliveryLabels: Record<string, string> = {
sent: "Delivered", deferred: "Waiting to retry", pending: "Delivery unconfirmed", uncertain: "Delivery unconfirmed; check Discord. We will not send it again today.", 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", 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<ReminderResponse>(); const [settings, setSettings] = useState<ReminderResponse>();
const [draft, setDraft] = useState(defaultReminder); const [draft, setDraft] = useState(defaultReminder);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -26,26 +26,26 @@ export function ReminderSettings({ timezone }: { timezone: string }) {
}, [attempt]); }, [attempt]);
return <SettingRow title="Daily reminders" description={<><p>A private Discord nudge when you still have habits left today. No habit names are sent.</p><p className="ds-meta-label">Times in {timezone}</p></>}> return <SettingRow title="Daily reminders" description={<><p>A private Discord nudge when you still have habits left today. No habit names are sent.</p><p className="ds-meta-label">Times in {timezone}</p></>}>
{!settings ? error ? <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <p className="ds-supporting-copy" role="status">Loading reminders</p> : <form className="ds-form-stack" onSubmit={async event => { {!settings ? error ? <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <p className="ds-supporting-copy" role="status">Loading reminders</p> : <form className="ds-form-stack" onSubmit={async event => {
event.preventDefault(); if (saving.current) return; event.preventDefault(); if (saving.current || disabled) return;
const form = event.currentTarget; const form = event.currentTarget;
const submitted = { ...draft, ...Object.fromEntries((["time", "quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) }; 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 { try {
await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) }); await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) });
setDraft(submitted); setDraft(submitted);
setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off."); setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off.");
} catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); } } 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 && <p className="ds-supporting-copy">Discord reminders are not configured on this server.</p>} {!settings.available && <p className="ds-supporting-copy">Discord reminders are not configured on this server.</p>}
<fieldset className="ds-inline-item-fields ds-form-stack" disabled={busy}> <fieldset className="ds-inline-item-fields ds-form-stack" disabled={busy || disabled}>
<Checkbox className="ds-preference-toggle" description="At most one message per day. Turn off anytime." label="Enable daily Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} /> <Checkbox className="ds-preference-toggle" description="At most one message per day. Turn off anytime." label="Enable daily Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
<div className="ds-clock-fields"> <div className="ds-clock-fields">
{([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) => {([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) =>
<Field key={key} label={label}>{id => <input id={id} name={key} type="time" required defaultValue={draft[key]} />}</Field>)} <Field key={key} label={label}>{id => <input id={id} name={key} type="time" required defaultValue={draft[key]} />}</Field>)}
</div> </div>
<p className="ds-supporting-copy">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.</p> <p className="ds-supporting-copy">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.</p>
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy}>{busy ? "Saving…" : "Save reminders"}</Button></div> <div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || disabled}>{busy ? "Saving…" : "Save reminders"}</Button></div>
</fieldset> </fieldset>
{settings.lastDelivery && <p className="ds-delivery-note"><span className="ds-eyebrow">LAST REMINDER</span><span>{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</span></p>} {settings.lastDelivery && <p className="ds-delivery-note"><span className="ds-eyebrow">LAST REMINDER</span><span>{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</span></p>}
</form>} </form>}

View File

@@ -1,5 +1,6 @@
import { Modal } from "./design-system/Modal";
import { useEffect, useRef, useState } from "react"; 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 { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field"; import { Field } from "./design-system/Field";
import { habitRequest, type TodayResponse } from "../lib/dashboard"; 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 [sendError, setSendError] = useState("");
const lock = useRef(false); const lock = useRef(false);
const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null); const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null);
const heading = useRef<HTMLHeadingElement>(null);
const key = JSON.stringify({ ids, from, to, privacy, revision, user }); const key = JSON.stringify({ ids, from, to, privacy, revision, user });
const ready = card?.key === key ? card : null; const ready = card?.key === key ? card : null;
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null; const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
const busy = sending; const busy = sending;
useEffect(() => { heading.current?.focus(); }, []);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
setConnectionError(""); setConnectionError("");
@@ -70,11 +69,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
finally { lock.current = false; setSending(false); } finally { lock.current = false; setSending(false); }
} }
return ( return (
<section id="share-progress" className="ds-share-panel" aria-labelledby="share-progress-title"> <Modal id="share-progress" eyebrow="A LITTLE PROGRESS, WORTH SHARING" title="Your progress, in a picture." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
<header className="ds-share-heading">
<div><p className="ds-eyebrow">A LITTLE PROGRESS, WORTH SHARING</p><h2 id="share-progress-title" ref={heading} tabIndex={-1} className="type-section">Your progress, in a picture.</h2></div>
<Button variant="text" disabled={busy} onClick={onClose} aria-label="Close sharing"><X size={20} aria-hidden="true" /></Button>
</header>
<div className="ds-share-layout"> <div className="ds-share-layout">
<div className="ds-share-controls"> <div className="ds-share-controls">
<fieldset className="ds-share-fieldset" disabled={busy}> <fieldset className="ds-share-fieldset" disabled={busy}>
@@ -103,6 +98,6 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
{sent && <p role="status">{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord </a>}</> : <>Discord didnt confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt wont be resent.</>}</p>} {sent && <p role="status">{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord </a>}</> : <>Discord didnt confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt wont be resent.</>}</p>}
</div> </div>
</div> </div>
</section> </Modal>
); );
} }

View File

@@ -24,6 +24,7 @@ export function CalendarHeatmap({
onSelectDate, onSelectDate,
historyLabel, historyLabel,
legend, legend,
selectedDayAction,
}: { }: {
days: CalendarDay[]; days: CalendarDay[];
unit: string; unit: string;
@@ -35,6 +36,7 @@ export function CalendarHeatmap({
onSelectDate?: (date: string) => void; onSelectDate?: (date: string) => void;
historyLabel?: string; historyLabel?: string;
legend?: ReactNode; legend?: ReactNode;
selectedDayAction?: (date: string) => ReactNode;
}) { }) {
const [months, setMonths] = useState<MonthView | "custom">(12); const [months, setMonths] = useState<MonthView | "custom">(12);
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null); const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
@@ -281,8 +283,8 @@ export function CalendarHeatmap({
</p> </p>
</details> </details>
</div> </div>
<div id={inspectorId} className="ds-date-inspector" aria-live="polite"> <div id={inspectorId} className="ds-date-inspector">
<div className="ds-date-inspector-heading"> <div className="ds-date-inspector-heading" aria-live="polite">
<CalendarDays size={22} aria-hidden="true" /> <CalendarDays size={22} aria-hidden="true" />
<div> <div>
<span className="ds-eyebrow">SELECTED DAY</span> <span className="ds-eyebrow">SELECTED DAY</span>
@@ -297,6 +299,7 @@ export function CalendarHeatmap({
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span> <span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
</div> </div>
</div> </div>
{selectedDayAction && <div className="ds-action-row ds-date-inspector-actions">{selectedDayAction(selected.date)}</div>}
</div> </div>
</div> </div>
); );

View File

@@ -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<HTMLDialogElement>(null);
const heading = useRef<HTMLHeadingElement>(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<HTMLElement>(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 <dialog ref={dialog} id={id} className={`ds-modal ds-modal--${size}`} aria-labelledby={`${id}-title`}
onCancel={event => { event.preventDefault(); event.stopPropagation(); dismiss(); }}
onKeyDown={event => {
if (event.key !== "Tab") return;
event.stopPropagation();
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('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();
}}>
<header className="ds-modal-heading">
<div>{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}<h2 id={`${id}-title`} ref={heading} tabIndex={-1}>{title}</h2>{description && <p className="ds-panel-description">{description}</p>}</div>
<Button variant="text" className="ds-panel-close" disabled={busy || !onClose} onClick={dismiss} aria-label={closeLabel} title={closeLabel}><X size={20} aria-hidden="true" /></Button>
</header>
<div className="ds-modal-body">{children}</div>
</dialog>;
}

View File

@@ -1,18 +1,11 @@
import { useId, type ReactNode } from "react"; import { useId, type ReactNode } from "react";
import { X } from "lucide-react"; import { Modal } from "./Modal";
import { Button } from "./primitives";
/** Shared flat panel for secondary workspace flows. */ /** Secondary workspace flows share one modal presentation. */
export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel" }: { 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; id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; busy?: boolean;
}) { }) {
return <section id={id} className="ds-workspace-panel" aria-label={title}> return <Modal id={id} eyebrow={eyebrow} title={title} description={description} onClose={onClose} closeLabel={closeLabel} busy={busy}>{children}</Modal>;
<header className="ds-panel-heading">
<div><p className="ds-eyebrow">{eyebrow}</p><h2>{title}</h2>{description && <p className="ds-panel-description">{description}</p>}</div>
{onClose && <Button variant="text" className="ds-panel-close" onClick={onClose} aria-label={closeLabel} title={closeLabel}><X size={20} aria-hidden="true" /></Button>}
</header>
{children}
</section>;
} }
/** Consistent label, explanation and control columns for preference forms. */ /** Consistent label, explanation and control columns for preference forms. */

View File

@@ -172,6 +172,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
const [archive, setArchive] = useState(false); const [archive, setArchive] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [needsRefresh, setNeedsRefresh] = useState(false); const [needsRefresh, setNeedsRefresh] = useState(false);
const focusAddAfterRefresh = useRef(false);
const saving = useRef(false); const saving = useRef(false);
const mounted = useRef(true); const mounted = useRef(true);
const expired = useRef(onExpired); const expired = useRef(onExpired);
@@ -207,7 +208,13 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
if (!controller.signal.aborted) reportError(error); if (!controller.signal.aborted) reportError(error);
}) })
.finally(() => { .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(); return () => controller.abort();
}, [attempt, reportError]); }, [attempt, reportError]);
@@ -361,6 +368,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
<div className="ds-action-row ds-primary-actions"> <div className="ds-action-row ds-primary-actions">
<Button <Button
id="add-habit" id="add-habit"
aria-haspopup="dialog"
disabled={adding || busy || loading || needsRefresh} disabled={adding || busy || loading || needsRefresh}
onClick={() => setAdding(true)} onClick={() => setAdding(true)}
aria-expanded={adding} aria-expanded={adding}
@@ -370,14 +378,14 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
</Button> </Button>
{today.habits.length > 0 && ( {today.habits.length > 0 && (
<> <>
<Button id="open-sharing" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button> <Button id="open-sharing" aria-haspopup="dialog" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
<ButtonLink href="#habits-title">View your habits </ButtonLink> <ButtonLink href="#habits-title">View your habits </ButtonLink>
</> </>
)} )}
</div> </div>
<div className="ds-action-row ds-utility-row"> <div className="ds-action-row ds-utility-row">
<Button variant="text" disabled={busy} aria-expanded={archive} aria-controls={archive ? "archived-habits" : undefined} onClick={() => setArchive(!archive)}><Archive size={16} aria-hidden="true" />Archived habits</Button> <Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={archive} aria-controls={archive ? "archived-habits" : undefined} onClick={() => setArchive(!archive)}><Archive size={16} aria-hidden="true" />Archived habits</Button>
<Button variant="text" disabled={busy} aria-expanded={settings} aria-controls={settings ? "account-settings" : undefined} onClick={() => setSettings(!settings)}><Settings2 size={16} aria-hidden="true" />Settings</Button> <Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={settings} aria-controls={settings ? "account-settings" : undefined} onClick={() => setSettings(!settings)}><Settings2 size={16} aria-hidden="true" />Settings</Button>
</div> </div>
</> </>
)} )}
@@ -410,6 +418,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
onCancel={() => setAdding(false)} onCancel={() => setAdding(false)}
onExpired={() => expired.current()} onExpired={() => expired.current()}
onCreated={(name) => { onCreated={(name) => {
focusAddAfterRefresh.current = true;
setAdding(false); setAdding(false);
setNotice(`${name} created.`); setNotice(`${name} created.`);
setAttempt((value) => value + 1); setAttempt((value) => value + 1);
@@ -536,7 +545,7 @@ function SavedHabit({
/> />
); );
})} })}
{addingTask ? ( {addingTask && (
<InlineTaskEditor <InlineTaskEditor
name="" name=""
schedule={{ type: "daily" }} schedule={{ type: "daily" }}
@@ -549,20 +558,21 @@ function SavedHabit({
onSave={(patch) => onManage(habit, patch, undefined, true)} onSave={(patch) => onManage(habit, patch, undefined, true)}
onDelete={async () => {}} onDelete={async () => {}}
/> />
) : ( )}
<div className="ds-task-add-action"> <div className="ds-task-add-action">
{!habit.requirements.tasks.length && ( {!habit.requirements.tasks.length && (
<p className="ds-footnote">Add a task to start checking in.</p> <p className="ds-footnote">Add a task to start checking in.</p>
)} )}
<Button <Button
variant="text" variant="text"
aria-haspopup="dialog"
aria-expanded={addingTask}
disabled={disabled || habit.requirements.tasks.length >= 100} disabled={disabled || habit.requirements.tasks.length >= 100}
onClick={() => setAddingTask(true)} onClick={() => setAddingTask(true)}
> >
Add task + Add task +
</Button> </Button>
</div> </div>
)}
</> </>
) : undefined ) : undefined
} }

View File

@@ -993,6 +993,12 @@
@apply type-small; @apply type-small;
} }
.ds-date-inspector-heading { display: flex; align-items: center; gap: 14px; min-width: 0; } .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 > 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-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; } .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-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-copy { display: grid; gap: 4px; min-width: 0; }
.ds-checkbox-description { color: var(--ds-secondary); @apply type-small; } .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-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-controls { display: grid; gap: 24px; min-width: 0; }
.ds-share-fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 10px; 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; } .ds-share-actions .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
@media (max-width: 900px) { @media (max-width: 900px) {
.ds-share-layout { grid-template-columns: minmax(0, 1fr); gap: 28px; } .ds-share-layout { grid-template-columns: minmax(0, 1fr); gap: 28px; }
.ds-share-panel { padding: 20px; }
.ds-share-preview { padding: 12px; } .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; } .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 { 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 { color: var(--ds-secondary); @apply type-small; }
.ds-utility-row .ds-button[aria-expanded="true"] { color: var(--ds-ink); background: var(--ds-paper); } .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-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-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); } .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; } .ds-record-heading { align-items: flex-start; flex-direction: column; gap: 16px; }
} }
@media (max-width: 640px) { @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-panel-description { @apply type-small; }
.ds-setting-row { grid-template-columns: minmax(0, 1fr); gap: 20px; padding: 24px 0; } .ds-setting-row { grid-template-columns: minmax(0, 1fr); gap: 20px; padding: 24px 0; }
.ds-setting-heading > div { max-width: none; } .ds-setting-heading > div { max-width: none; }
@@ -1505,3 +1499,26 @@
.ds-record-heading > .ds-action-row { flex-shrink: 1; gap: 8px; } .ds-record-heading > .ds-action-row { flex-shrink: 1; gap: 8px; }
} }
@media (prefers-reduced-motion: reduce) { .ds-disclosure-icon { transition: none; } } @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; }
}