Refactor HabitColorPicker to use HABIT_COLOR_CHOICES, update styles and tests

- Replaced HABIT_PALETTES with HABIT_COLOR_CHOICES in HabitColorPicker component and related tests.
- Adjusted the layout and styles of the color picker for improved usability.
- Updated tests to reflect changes in color selection logic.
- Modified WorkspacePanel and SettingRow components to allow optional eyebrow and description props.
- Enhanced accessibility tests for calendar navigation.
- Updated calendar model to include a 1-month view option.
- Refined styles across various components for consistency and improved responsiveness.
This commit is contained in:
syntaxbullet
2026-09-08 15:42:47 +02:00
parent 4d719afd07
commit 833d5673eb
24 changed files with 175 additions and 215 deletions

View File

@@ -24,9 +24,9 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
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={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
<SettingRow title="Appearance" description={<p>Follow your device or choose a theme for this browser.</p>}><ThemeControl /></SettingRow>
<SettingRow title="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</p>}>
return <WorkspacePanel id="account-settings" title="Account settings" onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
<SettingRow title="Appearance"><ThemeControl /></SettingRow>
<SettingRow title="Daily timezone" description={<p>Sets day boundaries. Earlier deadlines stay unchanged.</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 || reminderBusy} onChange={event => setTimezone(event.target.value)} />}
@@ -36,7 +36,7 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
</form>
</SettingRow>
<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="Export data" description={<p>Download your profile, habits, and history.</p>}>
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="mina-habits-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>}>

View File

@@ -56,7 +56,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
setDeleteError(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 you’re ready." onClose={onClose} busy={busy} closeLabel="Close archive">
return <WorkspacePanel id="archived-habits" title="Archived habits" onClose={onClose} busy={busy} closeLabel="Close archive">
<div ref={archiveFocus} tabIndex={-1} aria-label="Archive status"><FormMessage>{notice}</FormMessage></div>
<FormMessage error>{error}</FormMessage>
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}

View File

@@ -3,7 +3,7 @@ import { Window } from "happy-dom";
import { act, useState } from "react";
import type { Root } from "react-dom/client";
import { HabitForm } from "./HabitForm";
import { HabitColorPicker } from "./design-system/HabitColorPicker";
import { HABIT_COLOR_CHOICES, HabitColorPicker } from "./design-system/HabitColorPicker";
const dom = new Window({ url: "http://localhost:3000/design-system" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
@@ -46,33 +46,33 @@ function button(text: string) {
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!;
}
test("habit starters remain editable and custom creation resets the draft", async () => {
test("blank habit creation exposes tracking-specific fields without templates", async () => {
await act(async () => root.render(<HabitForm date="2026-09-05" onCancel={() => {}} onCreated={() => {}} onExpired={() => {}} />));
await act(async () => button("Reading").click());
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("Read");
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("count");
expect(container.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe("10");
await act(async () => button("Evening routine").click());
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("tasks");
expect([...container.querySelectorAll("input")].map(input => input.value)).toContain("Prepare for tomorrow");
await act(async () => button("Start from scratch").click());
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("");
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("manual");
expect(container.querySelector('[aria-label="Start with an idea"]')).toBeNull();
const method = container.querySelector<HTMLSelectElement>("select")!;
expect(method.value).toBe("manual");
await act(async () => { method.value = "count"; method.dispatchEvent(new Event("change", { bubbles: true })); });
expect(container.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe("10");
await act(async () => { method.value = "tasks"; method.dispatchEvent(new Event("change", { bubbles: true })); });
expect(container.querySelector('input[type="number"]')).toBeNull();
await act(async () => button("Add task +").click());
expect([...container.querySelectorAll("label")].filter(label => /^Task \d+$/.test(label.textContent ?? ""))).toHaveLength(2);
});
test("compact palette discloses full controls and preserves custom color", async () => {
test("continuous colors disclose custom controls and preserve the selected color", async () => {
function Picker() {
const [color, setColor] = useState("#123456");
return <HabitColorPicker value={color} onChange={setColor} compact />;
return <HabitColorPicker value={color} onChange={setColor} />;
}
await act(async () => root.render(<Picker />));
expect(container.querySelector('[aria-label="Suggested colors"]')?.querySelectorAll("button").length).toBe(8);
const disclosure = button("More colors");
expect(container.querySelector('[aria-label="Habit colors"]')?.querySelectorAll('button[aria-label^="Color "]').length).toBe(HABIT_COLOR_CHOICES.length);
const disclosure = button("Custom");
const details = document.getElementById(disclosure.getAttribute("aria-controls")!)!;
expect(details.hidden).toBe(true);
await act(async () => disclosure.click());
expect(details.hidden).toBe(false);
expect(container.querySelector<HTMLInputElement>('input[type="text"]')?.value).toBe("#123456");
await act(async () => button("Fewer colors").click());
await act(async () => button("Custom").click());
expect(container.querySelector('[aria-label="Progress shades using #123456"]')).not.toBeNull();
});

View File

@@ -7,12 +7,6 @@ import { HabitColorPicker } from "./design-system/HabitColorPicker";
import { habitInput, type Schedule } from "../habits/contracts";
import { habitRequest } from "../lib/dashboard";
const STARTERS = [
{ label: "Reading", name: "Read", method: "count", target: 10, unit: "pages", tasks: [""], color: "#426582" },
{ label: "Get outside", name: "Get outside", method: "manual", target: 1, unit: "times", tasks: [""], color: "#58765b" },
{ label: "Evening routine", name: "Wind down", method: "tasks", target: 1, unit: "times", tasks: ["Put things away", "Prepare for tomorrow"], color: "#79618d" },
] as const;
/** Account behavior composed from the design system's existing form components. */
export function HabitForm({
date,
@@ -33,22 +27,10 @@ export function HabitForm({
const [unit, setUnit] = useState("pages");
const [tasks, setTasks] = useState([""]);
const [schedule, setSchedule] = useState<Schedule>({ type: "daily" });
const [color, setColor] = useState("#58765b");
const [color, setColor] = useState("#3e805d");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
function applyStarter(starter: (typeof STARTERS)[number] | null) {
setName(starter?.name ?? "");
setMethod(starter?.method ?? "manual");
setTarget(starter?.target ?? 10);
setUnit(starter?.unit ?? "pages");
setTasks(starter ? [...starter.tasks] : [""]);
setColor(starter?.color ?? "#58765b");
setSchedule({ type: "daily" });
setError("");
form.current?.querySelector<HTMLInputElement>("input")?.focus();
}
async function submit(event: FormEvent) {
event.preventDefault();
if (saving.current) return;
@@ -93,17 +75,9 @@ export function HabitForm({
}
return (
<Modal id="new-habit" eyebrow="A NEW HABIT" title={<>Make it <em>yours.</em></>} description="Schedules follow your account’s timezone." onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
<Modal id="new-habit" title="Create habit" 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}>
<div className="ds-habit-starters" role="group" aria-label="Start with an idea">
<div className="ds-habit-starter-options">
{STARTERS.map((starter) => (
<Button key={starter.label} variant="secondary" onClick={() => applyStarter(starter)}>{starter.label}</Button>
))}
<Button variant="text" onClick={() => applyStarter(null)}>Start from scratch</Button>
</div>
</div>
<fieldset className="ds-task-editor-fieldset ds-habit-form-grid" disabled={busy}>
<Field label="Habit name">
{(id) => (
<input
@@ -116,7 +90,7 @@ export function HabitForm({
/>
)}
</Field>
<Field label="How will you track it?">
<Field label="Tracking">
{(id) => (
<select
id={id}
@@ -131,9 +105,10 @@ export function HabitForm({
</select>
)}
</Field>
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
{method === "count" && (
<div className="ds-form-grid">
<Field label="Target per scheduled day">
<Field label="Daily target">
{(id) => (
<input
id={id}
@@ -202,17 +177,9 @@ export function HabitForm({
>
Add task +
</Button>
<p className="ds-form-feedback">
Tasks repeat on each scheduled day.
</p>
</div>
)}
<ScheduleEditor
value={schedule}
onChange={setSchedule}
anchorDate={date}
/>
<HabitColorPicker value={color} onChange={setColor} mode="create" compact />
<HabitColorPicker value={color} onChange={setColor} mode="create" />
</fieldset>
{error && (
<p className="ds-form-feedback" role="alert">

View File

@@ -139,7 +139,7 @@ export function HabitHistory({
/>
}
/>
{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}>
{editingDay && <Modal id={`check-in-${habit.habitId}`} 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></div>
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}

View File

@@ -30,13 +30,14 @@ export function InlineHabitEditor({ config, color: initialColor, date, mode, dis
if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => issue.message).join(" "));
await onSave(parsed.data);
}}>
<Field label="How will you track it?">
<Field label="Tracking">
{(id) => <select id={id} value={method} onChange={(event) => setMethod(event.target.value as HabitConfig["method"])}>
<option value="manual">A simple check-in</option>
<option value="count">A count target</option>
<option value="tasks">A list of tasks</option>
</select>}
</Field>
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
{method !== config.method && <p className="ds-footnote">Changing the tracking method starts today’s progress over. Earlier history is kept.{method === "tasks" ? " After saving, add tasks below." : config.method === "tasks" ? " Existing tasks will leave this habit." : ""}</p>}
{method === "count" && (<>
<div className="ds-form-grid">
@@ -44,11 +45,9 @@ export function InlineHabitEditor({ config, color: initialColor, date, mode, dis
<Field label="Unit">{(id) => <input id={id} required maxLength={80} value={unit} onChange={(event) => setUnit(event.target.value)} />}</Field>
</div>
<Checkbox label="Carry unfinished counts to the next scheduled day" checked={carryPartialProgress} onChange={(event) => setCarryPartialProgress(event.target.checked)} />
<p className="ds-footnote">Completed counts reset. Explicitly recorded counts are kept.</p>
</>)}
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
<HabitColorPicker value={color} onChange={setColor} mode="edit" />
<p className="ds-footnote">Changes start today. Earlier targets and schedules stay as they were.</p>
<p className="ds-footnote">Applies from today. Earlier history is kept.</p>
</InlineItemForm>
);
}

View File

@@ -27,7 +27,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete";
return (
<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)'}>
<Modal id={`item-${id}`} title={formLabel ?? `${action} ${kind}`} 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();
@@ -48,7 +48,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
}
}}>
{mode === "edit" ? (
<fieldset className="ds-inline-item-fields" disabled={busy || disabled}>
<fieldset className={`ds-inline-item-fields${kind === "habit" ? " ds-habit-form-grid" : ""}`} disabled={busy || disabled}>
<Field label={kind === "habit" ? "Habit name" : "Task name"}>
{(id) => <input id={id} value={draft} required maxLength={200} onChange={(event) => setDraft(event.target.value)} />}
</Field>
@@ -57,7 +57,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
) : (
<p className="ds-inline-delete-copy">
{kind === "habit" ? "Archive" : "Delete"} <strong>{name}</strong>? {kind === "habit"
? "This removes the habit from your dashboard starting today. Earlier history is kept. You can restore it from Archived habits."
? "History is kept. Restore it anytime from Archived habits."
: "This removes the task from today and future check-ins. Earlier history is kept."}
</p>
)}

View File

@@ -26,8 +26,8 @@ export function InlineTaskEditor({ name, schedule: savedSchedule, habitSchedule,
await onSave(parsed.data);
}}>
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
<p className="ds-footnote">This task is due when both its recurrence and the habit’s schedule match. Habit: {scheduleLabel(habitSchedule)}.</p>
{!creating && <p className="ds-footnote">Changes start today. Earlier check-ins keep their original schedule.</p>}
<p className="ds-footnote">Runs when both schedules match. Habit: {scheduleLabel(habitSchedule)}.</p>
{!creating && <p className="ds-footnote">Applies from today. Earlier check-ins stay unchanged.</p>}
</InlineItemForm>
);
}

View File

@@ -42,11 +42,11 @@ export function LiveDiscordShare({ revision }: { revision: number }) {
const ready = preview?.theme === theme && preview.revision === revision ? preview : null;
const active = state?.status === 'active' || state?.status === 'sending';
return <section className="ds-live-discord" aria-label="Live Discord image">
<div className="ds-live-discord-heading"><div><h3>All your habits. One shared image.</h3><p>1920 × 1080 · 16:9 · Last 30 days</p></div><ThemeControl disabled={busy} /></div>
<div className="ds-live-discord-heading"><div><h3>All habits</h3><p>Last 30 days</p></div><ThemeControl disabled={busy} /></div>
<div className="ds-live-discord-preview" aria-busy={!ready && !previewError}>
{previewError ? <p role="alert">{previewError}</p> : ready ? <img src={ready.url} alt="16:9 Discord card containing all active habit names and their daily progress for the last 30 days." /> : <p role="status">Creating your image…</p>}
</div>
<p className="ds-live-discord-description">The bot posts this image in {connection?.connected ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a> : 'the configured Discord channel'}, then replaces the attachment on that same message after changes settle for two seconds. Updates continue with this page closed.</p>
<p className="ds-live-discord-description">Automatically updates in {connection?.connected ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a> : 'the configured Discord channel'}. Updates continue with this page closed.</p>
<p className="ds-live-discord-privacy">Shares your display name, avatar, all active habit names, and progress—including new habits. Task details stay private. Stopping updates leaves the last image in Discord.</p>
<div className="ds-live-discord-footer">
<div aria-live="polite">{state?.messageUrl && <a href={state.messageUrl} target="_blank" rel="noreferrer">View message in Discord ↗</a>}

View File

@@ -25,7 +25,7 @@ export function ReminderSettings({ timezone, onBusyChange, disabled = false }: {
return () => controller.abort();
}, [attempt]);
const changeEntry = (index: number, entry: ReminderEntry) => setDraft(value => ({ ...value, reminders: value.reminders.map((previous, position) => position === index ? entry : previous) }));
return <SettingRow title="Reminders" description={<><p>A private Discord nudge when you still have habits left today. Named reminders include their name in the message. No habit names are sent.</p><p className="ds-meta-label">Times in {timezone}</p></>}>
return <SettingRow title="Reminders" description={<><p>Private Discord reminders for unfinished habits. Reminder names are included; habit names stay private.</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 || disabled) return;
const form = event.currentTarget;
@@ -40,7 +40,7 @@ export function ReminderSettings({ timezone, onBusyChange, disabled = 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 || disabled}>
<Checkbox className="ds-preference-toggle" description="Choose when to receive a nudge. Turn off anytime." label="Enable Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
<Checkbox className="ds-preference-toggle" label="Enable Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
{draft.reminders.map((entry, index) => <div key={index} className="ds-form-stack">
<Field label={`Reminder ${index + 1} name (optional)`}>{id => <input id={id} value={entry.name ?? ""} placeholder="e.g. Morning check-in" maxLength={80} onChange={event => changeEntry(index, { ...entry, name: event.target.value })} />}</Field>
<div className="ds-clock-fields">

View File

@@ -76,9 +76,9 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
finally { lock.current = false; setSending(false); }
}
return (
<Modal id="share-progress" eyebrow="SHARE A LITTLE PROGRESS" title="Made for your people." description="Choose what to include. See exactly what you’ll share." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
<Modal id="share-progress" title="Share progress" onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
<div className="ds-sharing-mode" role="group" aria-label="Sharing format">
<Button variant={mode === "live" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "live"} onClick={() => setMode("live")}>Live image · 16:9</Button>
<Button variant={mode === "live" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "live"} onClick={() => setMode("live")}>Live image</Button>
<Button variant={mode === "snapshot" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "snapshot"} onClick={() => setMode("snapshot")}>Snapshot</Button>
</div>
{mode === "live" ? <LiveDiscordShare revision={revision} /> : <>
@@ -91,7 +91,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
<div className={`ds-share-preview${thumbnail ? " ds-share-preview--thumbnail" : ""}`} aria-busy={!ready && !error}>
{error ? <div className="ds-share-placeholder" role="alert"><ImageIcon size={28} aria-hidden="true" /><p>{error}</p>{ids.length > 0 && <Button variant="secondary" onClick={() => setRenderAttempt(n => n + 1)}>Retry preview</Button>}</div> : ready ? <img src={ready.url} alt={ready.alt} /> : <div className="ds-share-placeholder" role="status"><ImageIcon size={28} aria-hidden="true" /><p>Creating your card…</p></div>}
</div>
<p className="ds-share-caption">{thumbnail ? "A small-size preview, like an image in chat." : "A closer look at your card."} Same image in Discord and your download.</p>
<p className="ds-share-caption">Your preview matches the shared image.</p>
{ready?.avatarMissing && <p role="status" className="ds-share-caption">Your avatar couldn’t load. A neutral profile icon is used.</p>}
</section>
<div className="ds-share-controls">
@@ -104,7 +104,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
<fieldset className="ds-share-fieldset" disabled={busy}>
<legend>Habits <span className="ds-share-count">{ids.length} / 6</span></legend>
<div className="ds-share-habits">{today.habits.map(habit => <Checkbox key={habit.habitId} label={habit.name ?? "Habit"} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}</div>
<p className="ds-share-hint">Choose up to six. Task names stay private.</p>
<p className="ds-share-hint">Task names stay private.</p>
</fieldset>
<details className="ds-share-privacy"><summary><ShieldCheck size={17} aria-hidden="true" /><span>Privacy & details</span><ChevronDown size={16} aria-hidden="true" /></summary>
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="ds-visually-hidden">Show on the card</legend>
@@ -118,7 +118,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
</div>
<footer className="ds-share-footer">
<div className="ds-share-footer-copy" aria-live="polite">
{sendError ? <p role="alert" className="ds-share-error">{sendError}</p> : sent ? <p>{sent.status === "sent" ? <>Shared successfully. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Delivery unconfirmed. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"}; this attempt won’t be resent.</>}</p> : <p>{sending ? "Sending your card to Discord…" : error ? "Check your card settings." : ready ? "Ready when you are." : "Your card is taking shape."}<span>{sending ? "This may take a moment." : "Only the details in your preview are shared."}</span></p>}
{sendError ? <p role="alert" className="ds-share-error">{sendError}</p> : sent ? <p>{sent.status === "sent" ? <>Shared successfully. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Delivery unconfirmed. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"}; this attempt won’t be resent.</>}</p> : <p>{sending ? "Sending your card to Discord…" : error ? "Check your card settings." : ready ? "Ready to share." : "Preparing preview…"}<span>{sending ? "This may take a moment." : "Only the details in your preview are shared."}</span></p>}
</div>
<div className="ds-share-actions">
{ready ? <a className="ds-button ds-button--secondary" href={ready.url} download={`mina-habits-progress-${from}-${to}.png`}><Download size={17} aria-hidden="true" />Download</a> : <Button variant="secondary" disabled><Download size={17} aria-hidden="true" />Download</Button>}

View File

@@ -37,9 +37,9 @@ export function ThemeControl({ disabled = false }: { disabled?: boolean }) {
{ value: "light", label: "Light", Icon: Sun },
{ value: "dark", label: "Dark", Icon: Moon },
] as const).map(({ value, label, Icon }) => (
<label className="ds-theme-option" key={value}>
<label className="ds-theme-option" key={value} title={`${label} theme`}>
<input type="radio" name={`theme-${id}`} value={value} checked={preference === value} disabled={disabled} onChange={() => setPreference(value)} />
<span><Icon size={16} strokeWidth={1.75} aria-hidden="true" />{label}</span>
<span><Icon size={16} strokeWidth={1.75} aria-hidden="true" /><span className="ds-visually-hidden">{label}</span></span>
</label>
))}
</div>

View File

@@ -110,13 +110,14 @@ export function CalendarHeatmap({
year: "numeric",
timeZone: "UTC",
})}{" "}
–{" "}
{months !== 1 && <>–{" "}
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
month: "short",
day: months === "custom" ? "numeric" : undefined,
year: "numeric",
timeZone: "UTC",
})}
</>}
</span>
<div
className="ds-month-views"
@@ -127,7 +128,7 @@ export function CalendarHeatmap({
<button
key={value}
type="button"
aria-label={`${value} months`}
aria-label={`${value} ${value === 1 ? "month" : "months"}`}
aria-pressed={months === value}
onClick={() => setMonths(value)}
>

View File

@@ -63,8 +63,8 @@ export function HabitChart({
<div className="ds-goal-body">
<div className="ds-goal-completion">
{trackingMethod === "count" && <div className="ds-goal-count">
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
{children}
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
</div>}
{trackingMethod === "manual" && <div className="ds-goal-check-in">{children}<label htmlFor={checkInId}>{complete ? "Done for today" : "Mark as done"}</label></div>}
{trackingMethod === "tasks" && <>

View File

@@ -1,17 +1,14 @@
import { expect, test } from "bun:test";
import {
HABIT_PALETTES,
HABIT_COLOR_CHOICES,
isHabitColor,
previewHabitColors,
} from "./HabitColorPicker";
import { HABIT_COLORS } from "./calendar-model";
test("eight suggested palettes contain thirty-two unique, valid habit colors", () => {
const colors = HABIT_PALETTES.flatMap((palette) => [...palette.colors]);
expect(HABIT_PALETTES.length).toBe(8);
expect(HABIT_PALETTES.every((palette) => palette.colors.length === 4)).toBe(true);
expect(new Set(colors).size).toBe(32);
expect(colors.every(isHabitColor)).toBe(true);
test("the continuous color selection contains distinct, valid colors", () => {
expect(new Set(HABIT_COLOR_CHOICES).size).toBe(HABIT_COLOR_CHOICES.length);
expect(HABIT_COLOR_CHOICES.every(isHabitColor)).toBe(true);
});
test("custom colors accept six hex digits and reject incomplete or malformed colors", () => {

View File

@@ -24,15 +24,11 @@ export function previewHabitColors(
return colors;
}
export const HABIT_PALETTES = [
{ name: "Earth", colors: ["#58765b", "#977344", "#a3604d", "#76734e"] },
{ name: "Coast", colors: ["#426582", "#427b80", "#657e9c", "#667d70"] },
{ name: "Dusk", colors: ["#79618d", "#9c647c", "#736a9c", "#686878"] },
{ name: "Forest", colors: ["#386641", "#52734d", "#6b705c", "#3d6b62"] },
{ name: "Citrus", colors: ["#b45309", "#a16207", "#9a5b35", "#7a801c"] },
{ name: "Blossom", colors: ["#b05276", "#a34e62", "#a65f5a", "#8e5572"] },
{ name: "Jewel", colors: ["#6d28a8", "#1d678d", "#087f73", "#a12e54"] },
{ name: "Slate", colors: ["#475569", "#52616b", "#65605b", "#404047"] },
export const HABIT_COLOR_CHOICES = [
"#b44f57", "#b86643", "#a7792c", "#858337",
"#5c813f", "#3e805d", "#337e79", "#387b98",
"#466db0", "#6263ad", "#885ba6", "#a35488",
"#956976", "#89705a", "#667b82", "#606571",
] as const;
export function isHabitColor(value: string) {
@@ -42,65 +38,33 @@ export function isHabitColor(value: string) {
export function HabitColorPicker({
value,
onChange,
compact = false,
}: {
value: string;
onChange: (color: string) => void;
mode?: "demo" | "create" | "edit";
compact?: boolean;
}) {
const { theme } = useTheme();
const id = useId();
const [expanded, setExpanded] = useState(false);
const showDetails = !compact || expanded;
const valid = isHabitColor(value);
const showCustom = expanded || !valid;
return (
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend>
<p id={`${id}-hint`} className={showDetails ? "ds-footnote" : "ds-visually-hidden"}>
<p id={`${id}-hint`} className="ds-visually-hidden">
Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.
</p>
{compact && (
<>
<div className="ds-color-swatches ds-color-swatches--compact" role="group" aria-label="Suggested colors">
{HABIT_PALETTES.map((palette) => (
<button key={palette.name} type="button" aria-label={`${palette.name} ${palette.colors[0]}`} aria-pressed={value.toLowerCase() === palette.colors[0]} title={palette.name} onClick={() => onChange(palette.colors[0])}>
<span style={{ backgroundColor: habitProgressColor(palette.colors[0], 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>{value.toLowerCase() === palette.colors[0] ? "✓" : ""}</span>
</button>
))}
</div>
<button type="button" className="ds-button ds-button--text" aria-expanded={expanded} aria-controls={`${id}-details`} onClick={() => setExpanded(!expanded)}>{expanded ? "Fewer colors" : "More colors"}</button>
</>
)}
<div id={`${id}-details`} hidden={!showDetails}>
<div className="ds-color-palettes">
{HABIT_PALETTES.map((palette) => (
<div
key={palette.name}
className="ds-color-palette"
role="group"
aria-label={`${palette.name} palette`}
>
<span>{palette.name}</span>
<div className="ds-color-swatches">
{palette.colors.map((color) => (
<button
key={color}
type="button"
aria-label={`${palette.name} ${color}`}
aria-pressed={value.toLowerCase() === color}
title={color}
onClick={() => onChange(color)}
>
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
{value.toLowerCase() === color ? "✓" : ""}
</span>
</button>
))}
</div>
</div>
<div className="ds-color-swatches" role="group" aria-label="Habit colors">
{HABIT_COLOR_CHOICES.map((color) => (
<button key={color} type="button" aria-label={`Color ${color}`} aria-pressed={value.toLowerCase() === color} title={color} onClick={() => onChange(color)}>
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
{value.toLowerCase() === color ? "✓" : ""}
</span>
</button>
))}
<button type="button" className="ds-color-custom-toggle" aria-pressed={valid && !HABIT_COLOR_CHOICES.some(color => color === value.toLowerCase())} aria-expanded={showCustom} aria-controls={`${id}-custom`} onClick={() => setExpanded(!showCustom)}>Custom</button>
</div>
<div id={`${id}-custom`} hidden={!showCustom}>
<div className="ds-custom-color">
<Field label="Custom color" id={`${id}-native`}>
{(inputId) => (
@@ -137,7 +101,6 @@ export function HabitColorPicker({
className="ds-color-live-preview"
aria-label="Live calendar color preview"
>
<span className="ds-muted">Calendar preview</span>
<div
className="ds-color-preview-shades"
role="img"
@@ -152,7 +115,6 @@ export function HabitColorPicker({
/>
))}
</div>
<span className="ds-muted">0 → complete</span>
</div>
)}
{!valid && (

View File

@@ -3,13 +3,13 @@ import { Modal } from "./Modal";
/** 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;
id: string; eyebrow?: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; busy?: boolean;
}) {
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. */
export function SettingRow({ title, description, children }: { title: string; description: ReactNode; children: ReactNode }) {
export function SettingRow({ title, description, children }: { title: string; description?: ReactNode; children: ReactNode }) {
const id = useId();
return <section className="ds-setting-row" aria-labelledby={id}>
<header className="ds-setting-heading"><h3 id={id}>{title}</h3><div>{description}</div></header>

View File

@@ -190,13 +190,13 @@ test("invalid custom colors associate both guidance and corrective feedback", as
test("unified calendar retains every date and navigates across months in every range", async () => {
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" />));
for (const months of [3, 4, 6, 12]) {
for (const months of [1, 3, 4, 6, 12]) {
await act(async () => button(`${months}m`).click());
expect(container.querySelectorAll(".ds-calendar-grid")).toHaveLength(1);
expect(container.querySelectorAll(".ds-calendar-weekdays")).toHaveLength(1);
expect(container.querySelectorAll(".ds-calendar-month-label")).toHaveLength(months);
const dates = [...container.querySelectorAll<HTMLButtonElement>(".ds-day")];
expect(dates).toHaveLength({ 3: 92, 4: 122, 6: 183, 12: 365 }[months]!);
expect(dates).toHaveLength({ 1: 30, 3: 92, 4: 122, 6: 183, 12: 365 }[months]!);
expect(new Set(dates.map(day => day.getAttribute("aria-label"))).size).toBe(dates.length);
expect(dates.filter(day => day.tabIndex === 0)).toHaveLength(1);
const firstDate = new Date(`${dates[0]!.getAttribute("aria-label")!.slice(0, 10)}T00:00:00Z`);

View File

@@ -104,11 +104,14 @@ test("month presets include exact whole months and cross year and leap-year boun
expect(
MONTH_VIEWS.map((months) => monthWindow("2026-09-04", months)),
).toEqual([
{ from: "2026-09-01", to: "2026-09-30" },
{ from: "2026-07-01", to: "2026-09-30" },
{ from: "2026-06-01", to: "2026-09-30" },
{ from: "2026-04-01", to: "2026-09-30" },
{ from: "2025-10-01", to: "2026-09-30" },
]);
expect(monthWindow("2024-02-29", 1)).toEqual({ from: "2024-02-01", to: "2024-02-29" });
expect(monthWindow("2026-01-04", 1)).toEqual({ from: "2026-01-01", to: "2026-01-31" });
expect(monthWindow("2024-02-29", 3)).toEqual({
from: "2023-12-01",
to: "2024-02-29",
@@ -121,7 +124,7 @@ test("switching month views preserves dated values, complete coverage and future
MONTH_VIEWS.map(
(months) => visibleCalendarDays(days, months, "2026-09-04").length,
),
).toEqual([92, 122, 183, 365]);
).toEqual([30, 92, 122, 183, 365]);
for (const months of MONTH_VIEWS) {
const visible = visibleCalendarDays(days, months, "2026-09-04");
expect(visible.find((day) => day.date === "2026-09-04")).toEqual(

View File

@@ -20,7 +20,7 @@ export type CalendarDay = {
state: "due" | "not-due" | "future";
};
export const MONTH_VIEWS = [3, 4, 6, 12] as const;
export const MONTH_VIEWS = [1, 3, 4, 6, 12] as const;
export type MonthView = (typeof MONTH_VIEWS)[number];
/** Whole calendar months, including the month containing the anchor date. */

View File

@@ -117,6 +117,7 @@ export function Counter({
>
−
</Button>
<div className="ds-counter-value">
<input
className="ds-counter-input"
type="text"
@@ -133,7 +134,8 @@ export function Counter({
if (event.key === "Escape") { event.preventDefault(); updateDraft(String(value)); }
}}
/>
<span className="ds-muted"> / {target}</span>
<span className="ds-counter-target ds-muted"> / {target}</span>
</div>
<Button
variant="text"
aria-label={`Increase ${label}`}

View File

@@ -1,10 +1,10 @@
import { expect, test } from "bun:test";
import { habitProgressColor, themedHabitShade } from "./habit-palette";
import { HABIT_PALETTES } from "../components/design-system/HabitColorPicker";
import { HABIT_COLOR_CHOICES } from "../components/design-system/HabitColorPicker";
import { shade } from "../habits/calendar";
const luminance = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i+2),16)/255).map(v => v <= .04045 ? v/12.92 : ((v+.055)/1.055)**2.4).reduce((n,v,i) => n+v*[.2126,.7152,.0722][i]!,0);
test("all suggested and extreme custom colors have eight distinct ordered steps in both themes", () => {
for (const base of [...HABIT_PALETTES.flatMap(p=>[...p.colors]), '#ffffff','#000000','#ffff00','#0000ff','#ff0000','#00ff00']) {
for (const base of [...HABIT_COLOR_CHOICES, '#ffffff','#000000','#ffff00','#0000ff','#ff0000','#00ff00']) {
for (const theme of ['light','dark'] as const) {
const colors = Array.from({length:8},(_,i)=>habitProgressColor(base,i/7,theme));
expect(new Set(colors).size).toBe(8);

View File

@@ -1298,22 +1298,22 @@
.ds-legend-scale { flex-wrap: wrap; }
/* Editing patterns use the same open layout, rules, and neutral controls. */
.ds-color-picker { border: 0; border-top: 1px solid var(--ds-rule); padding: 20px 0 0; margin: 26px 0; min-width: 0; }
.ds-color-picker legend { padding: 0 12px 0 0; @apply type-ui-heading; }
.ds-color-picker { border: 0; padding: 0; margin: 0; min-width: 0; }
.ds-color-picker legend { padding: 0; margin-bottom: 8px; @apply type-ui-heading; }
.ds-color-picker > .ds-footnote { margin-top: 0 !important; }
.ds-color-palettes { display: flex; flex-wrap: wrap; gap: 20px 28px; margin: 20px 0; }
.ds-color-palette > span { @apply type-small; color: var(--ds-secondary); }
.ds-color-swatches { display: flex; gap: 4px; margin-top: 8px; }
.ds-color-swatches button { display: grid; place-items: center; width: 44px; min-width: 44px; min-height: 44px; padding: 4px; border: 1px solid transparent; background: transparent; }
.ds-color-swatches { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.ds-color-swatches .ds-color-custom-toggle { width: auto; padding-inline: 8px; color: var(--ds-ink); @apply type-small; }
.ds-color-custom-toggle[aria-expanded="true"] { background: var(--ds-surface); }
.ds-color-swatches button { display: grid; place-items: center; width: 32px; min-width: 32px; min-height: 32px; padding: 3px; border: 1px solid transparent; background: transparent; }
.ds-color-swatches button[aria-pressed="true"] { border-color: var(--ds-ink); }
.ds-color-swatches button > span { display: grid; place-content: center; height: 28px; width: 28px; color: var(--ds-paper); @apply type-body; }
.ds-custom-color { display: flex; flex-wrap: wrap; gap: 16px; align-items: start; }
.ds-color-swatches button > span { display: grid; place-content: center; height: 24px; width: 24px; color: var(--ds-paper); @apply type-body; }
.ds-custom-color { display: flex; flex-wrap: wrap; gap: 12px; align-items: start; margin-top: 12px; }
.ds-custom-color .ds-field { margin-bottom: 0; }
.ds-custom-color .ds-field:last-child { width: 180px; }
.ds-custom-color input[type="color"] { width: 80px; height: 44px; padding: 5px; cursor: pointer; }
.ds-color-live-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 22px; @apply type-small; }
.ds-color-live-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; @apply type-small; }
.ds-color-preview-shades { display: flex; gap: 4px; }
.ds-color-preview-shades > span { width: 18px; height: 18px; border: 1px solid var(--ds-rule); }
.ds-color-preview-shades > span { width: 12px; height: 12px; border: 1px solid var(--ds-rule); }
.ds-edit-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
@@ -1503,18 +1503,18 @@
.ds-utility-row .ds-button[aria-expanded="true"] { color: var(--ds-ink); background: var(--ds-paper); }
.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); }
.ds-setting-row { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr); gap: 24px; padding: 20px 0; border-top: 1px solid var(--ds-rule); }
.ds-setting-row:last-of-type { padding-bottom: 0; }
.ds-setting-heading h3 { @apply type-ui-heading; }
.ds-setting-heading > div { display: grid; gap: 12px; margin-top: 10px; max-width: 32ch; color: var(--ds-secondary); @apply type-small; }
.ds-setting-controls { display: grid; gap: 16px; min-width: 0; align-content: start; }
.ds-form-stack { display: grid; gap: 20px; min-width: 0; align-content: start; }
.ds-form-stack { display: grid; gap: 12px; min-width: 0; align-content: start; }
.ds-form-stack .ds-field { margin-bottom: 0; }
.ds-supporting-copy, .ds-meta-label { color: var(--ds-secondary); @apply type-small; overflow-wrap: anywhere; }
.ds-meta-label { font-variant-numeric: tabular-nums; }
.ds-clock-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.ds-clock-fields input { font-variant-numeric: tabular-nums; }
.ds-preference-toggle { padding: 16px; background: var(--ds-surface); border: 1px solid var(--ds-rule); }
.ds-preference-toggle { padding: 12px; background: var(--ds-surface); border: 1px solid var(--ds-rule); }
.ds-preference-toggle .ds-checkbox-description { margin-top: 4px; }
.ds-delivery-note { display: grid; gap: 6px; padding: 14px 16px; border-left: 2px solid var(--ds-control-border); background: var(--ds-surface); @apply type-small; }
.ds-confirmation { padding: 24px; background: var(--ds-surface); border-left: 2px solid var(--ds-ink); }
@@ -1546,7 +1546,7 @@
}
@media (max-width: 640px) {
.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: 12px; padding: 16px 0; }
.ds-setting-heading > div { max-width: none; }
.ds-confirmation, .ds-empty-panel { padding: 18px; }
.ds-history-editor { grid-template-columns: minmax(0, 1fr); padding: 20px; gap: 24px; }
@@ -1561,12 +1561,12 @@
.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 { display: flex; flex: 0 0 auto; justify-content: space-between; align-items: flex-start; gap: 16px; padding: 20px 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 { margin-top: 0; @apply type-title; 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 { overflow-y: auto; overscroll-behavior: contain; min-height: 0; padding: 20px 24px; scrollbar-gutter: auto; }
.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; }
@@ -1574,9 +1574,9 @@
.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 { padding: 16px; gap: 12px; }
.ds-modal-heading h2 { @apply type-title; }
.ds-modal-body { padding: 20px; }
.ds-modal-body { padding: 16px; }
}
/* Compact workspace variants share the same spacing and control language. */
@@ -1599,7 +1599,7 @@
.ds-habits-section .ds-section-top { margin-bottom: 24px; align-items: center; flex-wrap: wrap; }
.ds-habits-section .ds-section-top h2 { @apply type-title; margin-top: 0; }
.ds-habit-filters { display: inline-flex; width: fit-content; gap: 4px; padding: 4px; background: var(--ds-surface); border: 1px solid var(--ds-rule); border-radius: 6px; }
.ds-habit-filters .ds-button { @apply type-small; color: var(--ds-secondary); border: 1px solid transparent; background: transparent; border-radius: 3px; }
.ds-habit-filters .ds-button { @apply type-small; color: var(--ds-secondary); border: 1px solid transparent; background: transparent; border-radius: 0; }
.ds-habit-filters .ds-button[aria-pressed="true"] { color: var(--ds-ink); background: var(--ds-paper); border-color: var(--ds-rule); box-shadow: 0 1px 2px #00000008; }
.ds-habit-list { grid-template-columns: minmax(0, 1fr); gap: 0; }
.ds-habit-list > .ds-habit-chart { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 16px; padding: 24px 0; border-top: 1px solid var(--ds-rule); }
@@ -1614,17 +1614,13 @@
.ds-habit-history-toggle { display: inline-flex; align-items: center; gap: 8px; @apply type-small; color: var(--ds-secondary); }
.ds-habit-history-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
.ds-habit-history-panel { margin-top: 16px; }
.ds-counter { flex-wrap: wrap; max-width: 100%; gap: 4px; padding: 4px; border: 1px solid var(--ds-rule); border-radius: 6px; background: var(--ds-paper); }
.ds-counter-input { box-sizing: border-box; width: 7ch; min-width: 56px; min-height: 44px; padding: 8px; border: 1px solid transparent; background: var(--ds-surface); color: var(--ds-ink); text-align: center; font: inherit; font-variant-numeric: tabular-nums; border-radius: 3px; }
.ds-counter { flex-wrap: wrap; max-width: 100%; gap: 8px; padding: 0; border: 0; background: transparent; }
.ds-counter-value { display: flex; align-items: center; min-width: 0; }
.ds-counter-input { box-sizing: border-box; width: 7ch; min-width: 56px; min-height: 44px; padding: 8px; border: 1px solid transparent; background: var(--ds-surface); color: var(--ds-ink); text-align: center; font: inherit; font-variant-numeric: tabular-nums; border-radius: 0; }
.ds-counter-input:hover:not(:disabled) { border-color: var(--ds-control-border); }
.ds-counter .ds-muted { padding-right: 8px; font-variant-numeric: tabular-nums; }
.ds-counter .ds-button.ds-counter-save { width: auto; padding: 8px 12px; @apply type-small; font-weight: 500; }
.ds-counter-error { flex-basis: 100%; padding: 4px 8px; @apply type-small; }
.ds-habit-starters { margin-bottom: 16px; padding: 16px; background: var(--ds-surface); border-radius: 4px; }
.ds-habit-starter-options { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.ds-habit-starter-options .ds-button { @apply type-small; background: var(--ds-paper); }
.ds-habit-starter-options .ds-button--text { background: transparent; }
.ds-color-swatches--compact { flex-wrap: wrap; }
@media (max-width: 700px) {
.ds-welcome-panel--compact { padding: 20px; margin-top: 20px; }
.ds-welcome-top { align-items: flex-start; flex-wrap: wrap; gap: 16px; }
@@ -1656,38 +1652,43 @@
.ds-goal-status { display: inline-flex; align-items: center; gap: 8px; @apply type-small; }
.ds-goal-body { display: grid; grid-template-columns: minmax(0, 1fr) 304px; gap: 32px; align-items: start; }
.ds-goal-graph { grid-column: 1; grid-row: 1; min-width: 0; }
.ds-goal-completion { grid-column: 2; grid-row: 1; min-width: 0; padding: 24px; background: var(--ds-surface); border-top: 3px solid var(--habit-color); }
.ds-goal-count { display: grid; gap: 12px; }
.ds-goal-target { @apply type-body; font-weight: 500; overflow-wrap: anywhere; }
.ds-goal-count .ds-counter { width: fit-content; max-width: 100%; border-radius: 0; gap: 4px; padding: 0; justify-content: flex-start; }
.ds-goal-count .ds-counter-input { @apply type-body; font-family: var(--ds-sans); font-weight: 500; width: 5ch; min-width: 44px; height: 44px; min-height: 44px; padding: 8px 4px; background: var(--ds-paper); border-radius: 0; }
.ds-goal-count .ds-counter .ds-button { @apply type-lead; height: 44px; }
.ds-goal-count .ds-counter .ds-counter-save { @apply type-small; flex-basis: 100%; border-width: 1px 0 0; }
.ds-goal-count .ds-counter > .ds-muted { padding: 0 12px 0 4px; white-space: nowrap; }
.ds-goal-check-in { display: grid; grid-template-columns: 20px minmax(0, 1fr); align-items: center; gap: 0 12px; }
.ds-goal-completion { grid-column: 2; grid-row: 1; min-width: 0; padding: 16px; background: var(--ds-surface); }
.ds-goal-count { display: grid; justify-items: center; gap: 4px; }
.ds-goal-target { @apply type-small; color: var(--ds-secondary); text-align: center; overflow-wrap: anywhere; }
.ds-goal-count .ds-counter { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; width: min(100%, 220px); gap: 8px; align-items: center; }
.ds-goal-count .ds-counter-value { display: grid; justify-items: center; gap: 2px; }
.ds-goal-count .ds-counter-input { @apply type-control; font-family: var(--ds-sans); font-weight: 500; width: 100%; min-width: 0; height: 36px; min-height: 36px; padding: 2px 0; background: transparent; border-radius: 0; }
.ds-goal-count .ds-counter .ds-button { @apply type-lead; width: 32px; min-width: 32px; height: 32px; min-height: 32px; background: var(--ds-paper); color: var(--ds-ink); border: 0; }
.ds-goal-count .ds-counter .ds-button:hover:not(:disabled) { background: var(--ds-ink); color: var(--ds-paper); }
.ds-goal-count .ds-counter .ds-counter-save { grid-column: 1 / -1; width: 100%; @apply type-small; background: var(--ds-ink); color: var(--ds-paper); }
.ds-goal-count .ds-counter-target { padding: 0; white-space: nowrap; @apply type-small; }
.ds-goal-count .ds-counter-error { grid-column: 1 / -1; padding: 0; }
.ds-goal-check-in { display: grid; grid-template-columns: 32px minmax(0, 1fr); align-items: center; gap: 8px; min-height: 44px; }
.ds-goal-check-in > .ds-checkbox { justify-content: center; }
.ds-goal-check-in > label[for] { @apply type-body; font-weight: 500; cursor: pointer; }
.ds-goal-completion .ds-checkbox input { width: 20px; height: 20px; min-width: 20px; min-height: 20px; flex: 0 0 20px; aspect-ratio: 1; }
.ds-goal-task-progress { display: grid; gap: 12px; margin-bottom: 16px; @apply type-body; font-weight: 500; }
.ds-goal-task-progress progress { appearance: none; width: 100%; height: 4px; border: 0; background: var(--ds-rule); accent-color: var(--habit-color); }
.ds-goal-check-in > label[for] { @apply type-body; font-weight: 500; cursor: pointer; padding-block: 8px; }
.ds-goal-completion .ds-checkbox input { width: 22px; height: 22px; min-width: 22px; min-height: 22px; flex: 0 0 22px; border: 2px solid var(--ds-control-border); aspect-ratio: 1; }
.ds-goal-check-in .ds-checkbox input { width: 24px; height: 24px; flex-basis: 24px; }
.ds-goal-completion .ds-checkbox input:checked::after { width: 10px; height: 5px; border-width: 0 0 2px 2px; }
.ds-goal-check-in .ds-checkbox input:checked::after { width: 11px; height: 6px; }
.ds-goal-task-progress { display: grid; gap: 10px; margin-bottom: 8px; @apply type-small; font-weight: 500; }
.ds-goal-task-progress progress { appearance: none; width: 100%; height: 4px; border: 0; background: var(--ds-rule); accent-color: var(--ds-ink); }
.ds-goal-task-progress progress::-webkit-progress-bar { background: var(--ds-rule); }
.ds-goal-task-progress progress::-webkit-progress-value { background: var(--habit-color); }
.ds-goal-task-progress progress::-moz-progress-bar { background: var(--habit-color); }
.ds-goal-task-progress progress::-webkit-progress-value { background: var(--ds-ink); }
.ds-goal-task-progress progress::-moz-progress-bar { background: var(--ds-ink); }
.ds-routine-items { list-style: none; padding: 0; margin: 0; }
.ds-routine-items .ds-editable-task-row { gap: 8px; align-items: flex-start; padding-block: 0; }
.ds-routine-items .ds-item-actions { padding-top: 2px; }
.ds-routine-items .ds-editable-task-row > .ds-checkbox { min-height: 48px; min-width: 0; align-items: flex-start; gap: 12px; padding-block: 12px; @apply type-body; }
.ds-routine-items .ds-checkbox input { margin-top: 2px; }
.ds-routine-items .ds-checkbox-copy { gap: 4px; overflow-wrap: anywhere; }
.ds-goal-completion input:checked { background: var(--habit-color); border-color: var(--habit-color); }
.ds-goal-completion input:checked { background: var(--ds-ink); border-color: var(--ds-ink); }
.ds-routine-items .ds-editable-task-row:has(input:checked) .ds-checkbox-label { color: var(--ds-secondary); text-decoration: line-through; }
.ds-goal-tasks .ds-task-add-action { padding: 8px 0 0; border-top: 1px solid var(--ds-rule); }
.ds-goal-tasks .ds-task-add-action .ds-button { justify-content: flex-start; gap: 12px; padding: 0; border: 0; min-height: 44px; @apply type-body; }
.ds-goal-tasks .ds-task-add-action svg { width: 20px; height: 20px; flex: 0 0 20px; }
.ds-habit-starter-options { margin-top: 0; }
@media (max-width: 1000px) {
.ds-goal-body { grid-template-columns: minmax(0, 1fr) 280px; gap: 24px; }
.ds-goal-completion { padding: 20px; }
.ds-goal-completion { padding: 16px; }
}
@media (max-width: 760px) {
.ds-habit-list > .ds-goal { padding: 24px 0; }
@@ -1695,7 +1696,7 @@
.ds-goal-heading-actions { justify-content: space-between; width: 100%; }
.ds-goal-body { display: flex; flex-direction: column; gap: 24px; }
.ds-goal-completion, .ds-goal-graph { width: 100%; }
.ds-goal-count .ds-counter { justify-content: flex-start; width: fit-content; }
.ds-goal-count { width: min(100%, 240px); margin-inline: auto; }
}
@media (forced-colors: active) {
.ds-goal-completion { border: 1px solid CanvasText; }
@@ -1718,14 +1719,42 @@
}
.ds-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 20px; flex-wrap: wrap; }
/* Native radios keep arrow-key selection while presenting one compact control. */
.ds-theme-control { display: inline-flex; flex: 0 0 auto; width: fit-content; padding: 4px; gap: 2px; border: 1px solid var(--ds-rule); border-radius: 12px; background: var(--ds-surface); @apply type-small; }
.ds-theme-control { display: inline-flex; flex: 0 0 auto; width: fit-content; padding: 0; gap: 2px; border: 0; border-radius: 0; background: transparent; @apply type-small; }
.ds-theme-option { position: relative; cursor: pointer; }
.ds-theme-option input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.ds-theme-option > span { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 36px; padding: 0 11px; border: 1px solid transparent; border-radius: 8px; color: var(--ds-secondary); white-space: nowrap; transition: background-color 120ms ease, color 120ms ease; }
.ds-theme-option > span { display: flex; align-items: center; justify-content: center; gap: 0; width: 36px; min-height: 36px; padding: 0; border: 0; border-radius: 0; color: var(--ds-secondary); white-space: nowrap; transition: background-color 120ms ease, color 120ms ease; }
.ds-theme-option svg { flex: 0 0 auto; }
.ds-theme-option:hover > span { color: var(--ds-ink); background: var(--ds-selection); }
.ds-theme-option input:checked + span { color: var(--ds-ink); background: var(--ds-paper); border-color: var(--ds-rule); box-shadow: 0 1px 3px rgb(0 0 0 / 8%); }
.ds-theme-option input:checked + span { color: var(--ds-paper); background: var(--ds-ink); box-shadow: none; }
.ds-theme-option input:focus-visible + span { outline: 2px solid var(--ds-ink); outline-offset: 2px; }
.ds-theme-option input:disabled + span { opacity: .45; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) { .ds-theme-option > span { transition: none; } }
@media (max-width: 600px) { .ds-header { flex-wrap: wrap; } .ds-header-actions { gap: 12px; } }
/* Compact shared forms retain clear labels, usable controls, and responsive columns. */
.ds-habit-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.ds-habit-form-grid > * { grid-column: 1 / -1; min-width: 0; }
.ds-habit-form-grid > .ds-field:nth-child(2), .ds-habit-form-grid > .ds-schedule-editor { grid-column: auto; }
.ds-habit-form-grid .ds-field { margin-bottom: 0; gap: 6px; }
.ds-habit-form-grid .ds-form-grid { gap: 12px; }
.ds-habit-form-grid .ds-form-section { display: grid; gap: 12px; margin: 0; padding-top: 16px; }
.ds-habit-form-grid .ds-schedule-editor { display: grid; align-content: start; gap: 12px; }
.ds-habit-form-grid:has(.ds-schedule-editor > :nth-child(2)) > .ds-schedule-editor { grid-column: 1 / -1; }
.ds-habit-form-grid .ds-weekday-field { margin: 0; }
.ds-habit-form-grid .ds-footnote { margin: 0; @apply type-small; }
.ds-habit-form-grid > .ds-checkbox { @apply type-small; }
.ds-modal-body .ds-form-actions, .ds-modal-body .ds-inline-form-actions { gap: 12px; margin-top: 16px; }
.ds-modal-body .ds-input-row { min-width: 0; }
.ds-modal-body .ds-form-message:empty, .ds-setting-heading > div:empty { display: none; }
.ds-calendar[data-months="1"] .ds-calendar-timeline { max-width: calc(46px + var(--calendar-weeks) * 32px + (var(--calendar-weeks) - 1) * var(--calendar-gap)); }
@media (max-width: 480px) {
.ds-habit-form-grid { grid-template-columns: minmax(0, 1fr); gap: 12px; }
.ds-habit-form-grid .ds-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ds-weekdays { gap: 2px; }
.ds-weekdays button { min-width: 32px; flex: 1; padding-inline: 2px; }
}
@media (pointer: coarse) { .ds-theme-option > span { width: 44px; min-height: 44px; } }
.ds-inline-item-fields:not(.ds-habit-form-grid) { display: grid; gap: 12px; }
.ds-inline-item-fields:not(.ds-habit-form-grid) .ds-field { margin: 0; }
.ds-inline-item-fields:not(.ds-habit-form-grid) .ds-schedule-editor { display: grid; gap: 12px; }

View File

@@ -1,14 +1,14 @@
.ds-live-discord { display: grid; gap: 18px; }
.ds-live-discord-heading, .ds-live-discord-footer { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; }
.ds-live-discord-heading h3 { font-size: 20px; margin: 0 0 6px; }
.ds-live-discord-heading p, .ds-live-discord-footer p { margin: 4px 0; font-size: 13px; }
.ds-live-discord-preview { aspect-ratio: 16 / 9; overflow: hidden; border-radius: 12px; border: 1px solid var(--ds-border, #858a8066); display: grid; place-items: center; }
@reference "./globals.css";
.ds-live-discord { display: grid; gap: 12px; padding: 0 24px 20px; }
.ds-live-discord-heading, .ds-live-discord-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.ds-live-discord-heading h3 { @apply type-ui-heading; margin: 0; }
.ds-live-discord-heading p, .ds-live-discord-footer p { margin: 4px 0; @apply type-small; }
.ds-live-discord-preview { aspect-ratio: 16 / 9; overflow: hidden; border-radius: 0; border: 1px solid var(--ds-rule); display: grid; place-items: center; }
.ds-live-discord-preview img { width: 100%; height: 100%; display: block; object-fit: contain; }
.ds-live-discord-description { font-size: 14px; margin: 0; }
.ds-live-discord-privacy { font-size: 12px; opacity: .75; margin: 0; }
.ds-live-discord-footer { border-top: 1px solid #858a8033; padding-top: 18px; }
.ds-live-discord-description { @apply type-small; margin: 0; }
.ds-live-discord-privacy { @apply type-small; color: var(--ds-secondary); margin: 0; }
.ds-live-discord-footer { border-top: 1px solid var(--ds-rule); padding-top: 12px; }
.ds-live-discord-footer .ds-share-actions { flex-wrap: wrap; }
.ds-sharing-mode { display: flex; gap: 8px; margin-bottom: 20px; }
.ds-live-discord { padding: 0 32px 24px; }
.ds-sharing-mode { padding: 0 32px; margin-top: 20px; }
.ds-sharing-mode { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 24px; margin-block: 16px; }
@media (max-width: 600px) { .ds-live-discord { padding: 0 16px 16px; } .ds-sharing-mode { padding: 0 16px; } }