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
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.
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

View File

@@ -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<HTMLButtonE
.find(button => 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(<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 () => {
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<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());
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
expect(past).toBeDefined();

View File

@@ -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<unknown>) {
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 <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>}>
<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">
{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>
<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>
</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>}>
<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 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 }) })); }}>
<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>
<Field label="Type DELETE to confirm">{id => <input id={id} value={confirmation} disabled={busy} 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>
<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 || reminderBusy || confirmation !== "DELETE"}>Permanently delete my account</Button><Button variant="text" disabled={busy || reminderBusy} onClick={() => { setDeleting(false); setConfirmation(""); }}>Cancel deletion</Button></div>
</form>}
</SettingRow>
<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();
} 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>
{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>}

View File

@@ -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<TodayHabit | null>(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<TodayHabit>(`/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 <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>

View File

@@ -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<HTMLInputElement>("input")?.focus();
return () => previous?.focus();
}, []);
async function submit(event: FormEvent) {
event.preventDefault();
if (saving.current) return;
@@ -80,23 +75,7 @@ export function HabitForm({
}
return (
<section
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>
<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")}>
<form ref={form} className="ds-edit-content" onSubmit={submit}>
<fieldset className="ds-task-editor-fieldset" disabled={busy}>
<Field label="Habit name">
@@ -223,6 +202,6 @@ export function HabitForm({
</Button>
</div>
</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 type { CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
@@ -40,6 +42,7 @@ export function HabitHistory({
}) {
const [selectedDate, setSelectedDate] = useState<string>();
const [editingDay, setEditingDay] = useState(false);
const [savingDay, setSavingDay] = useState(false);
const [calendar, setCalendar] = useState<CalendarResponse | null>(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 => <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={
<CalendarLegend
label={`${habit.name} progress calendar`}
@@ -134,16 +138,12 @@ export function HabitHistory({
/>
}
/>
<div className="ds-action-row ds-history-actions">
<Button variant="text" disabled={disabled} onClick={() => { setSelectedDate(selectedDate ?? date); setEditingDay(!editingDay); }} aria-expanded={editingDay}>
{editingDay ? "Close check-in editor" : "Edit selected check-in"}
</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>
{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}>
<div className="ds-history-editor">
<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>
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</div>}
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</div></Modal>}
</>
);
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 { 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<HTMLElement>(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 (
<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}`}
onKeyDown={(event) => {
if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); }
}}
<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)'}>
<form ref={form} className="ds-modal-form" aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`}
onSubmit={async (event) => {
event.preventDefault();
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>}
</div>
</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.",
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 [draft, setDraft] = useState(defaultReminder);
const [error, setError] = useState("");
@@ -26,26 +26,26 @@ export function ReminderSettings({ timezone }: { timezone: string }) {
}, [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></>}>
{!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 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 && <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 }))} />
<div className="ds-clock-fields">
{([['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>)}
</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>
<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>
{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>}

View File

@@ -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<HTMLHeadingElement>(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 (
<section id="share-progress" className="ds-share-panel" aria-labelledby="share-progress-title">
<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>
<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")}>
<div className="ds-share-layout">
<div className="ds-share-controls">
<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>}
</div>
</div>
</section>
</Modal>
);
}

View File

@@ -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<MonthView | "custom">(12);
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
@@ -281,8 +283,8 @@ export function CalendarHeatmap({
</p>
</details>
</div>
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
<div className="ds-date-inspector-heading">
<div id={inspectorId} className="ds-date-inspector">
<div className="ds-date-inspector-heading" aria-live="polite">
<CalendarDays size={22} aria-hidden="true" />
<div>
<span className="ds-eyebrow">SELECTED DAY</span>
@@ -297,6 +299,7 @@ export function CalendarHeatmap({
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
</div>
</div>
{selectedDayAction && <div className="ds-action-row ds-date-inspector-actions">{selectedDayAction(selected.date)}</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 { 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 <section id={id} className="ds-workspace-panel" aria-label={title}>
<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>;
return <Modal id={id} eyebrow={eyebrow} title={title} description={description} onClose={onClose} closeLabel={closeLabel} busy={busy}>{children}</Modal>;
}
/** 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 [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
<div className="ds-action-row ds-primary-actions">
<Button
id="add-habit"
aria-haspopup="dialog"
disabled={adding || busy || loading || needsRefresh}
onClick={() => setAdding(true)}
aria-expanded={adding}
@@ -370,14 +378,14 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
</Button>
{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>
</>
)}
</div>
<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-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={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={settings} aria-controls={settings ? "account-settings" : undefined} onClick={() => setSettings(!settings)}><Settings2 size={16} aria-hidden="true" />Settings</Button>
</div>
</>
)}
@@ -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 && (
<InlineTaskEditor
name=""
schedule={{ type: "daily" }}
@@ -549,20 +558,21 @@ function SavedHabit({
onSave={(patch) => onManage(habit, patch, undefined, true)}
onDelete={async () => {}}
/>
) : (
)}
<div className="ds-task-add-action">
{!habit.requirements.tasks.length && (
<p className="ds-footnote">Add a task to start checking in.</p>
)}
<Button
variant="text"
aria-haspopup="dialog"
aria-expanded={addingTask}
disabled={disabled || habit.requirements.tasks.length >= 100}
onClick={() => setAddingTask(true)}
>
Add task +
</Button>
</div>
)}
</>
) : undefined
}

View File

@@ -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; }
}