feat: add theme provider and control components for light/dark mode

- Implemented ThemeProvider to manage theme state and preferences.
- Added ThemeControl component for users to switch between system, light, and dark themes.
- Integrated localStorage for theme preference persistence.
- Updated document styles based on theme changes.

test: add tests for permanent deletion of habits and related records

- Created tests to ensure permanent deletion of habits removes all dependent records.
- Verified that deletion requires ownership, authentication, and checks for archived habits.
- Added tests to confirm rollback behavior on deletion failures.

test: add tests for habit palette and progress card functionality

- Implemented tests for habit color progression and theme-based color shading.
- Added tests for progress card calculations and calendar display logic.

feat: define theme types and preferences

- Introduced Theme and ThemePreference types for better type safety.
- Created utility functions for resolving theme based on user preference and system settings.
- Defined theme palettes for light and dark modes to ensure adequate contrast.
This commit is contained in:
syntaxbullet
2026-09-05 10:07:28 +02:00
parent 9e9871c08a
commit 00729096bf
47 changed files with 780 additions and 337 deletions

View File

@@ -1,12 +1,13 @@
import { useTheme } from "../ThemeProvider";
import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette";
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { CalendarLegend } from "./CalendarLegend";
import { Field } from "./Field";
import { Button } from "./primitives";
import { CalendarDays, Keyboard } from "lucide-react";
import { CalendarDays } from "lucide-react";
import {
describeDay,
calendarTimeline,
progressShade,
MONTH_VIEWS,
visibleCalendarDays,
type CalendarDay,
@@ -18,8 +19,8 @@ export function CalendarHeatmap({
unit,
label,
color = "#111111",
emptyColor = "#eeeeee",
compact = false,
initialMonths = 12,
selectedDate: controlledDate,
onSelectDate,
historyLabel,
@@ -32,13 +33,15 @@ export function CalendarHeatmap({
color?: string;
emptyColor?: string;
compact?: boolean;
initialMonths?: MonthView;
selectedDate?: string;
onSelectDate?: (date: string) => void;
historyLabel?: string;
legend?: ReactNode;
selectedDayAction?: (date: string) => ReactNode;
}) {
const [months, setMonths] = useState<MonthView | "custom">(12);
const { theme } = useTheme();
const [months, setMonths] = useState<MonthView | "custom">(initialMonths);
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
const [rangeError, setRangeError] = useState("");
const latestDate =
@@ -236,8 +239,8 @@ export function CalendarHeatmap({
day.state === "not-due"
? "transparent"
: day.state === "future"
? emptyColor
: progressShade(day, color),
? "var(--ds-empty)"
: day.value === 0 ? "var(--ds-empty)" : day.color ? themedHabitShade(day.color, color, theme) : habitProgressColor(color, (Math.max(1, Math.min(8, Math.round(day.value / day.target * 8))) - 1) / 7, theme),
}}
aria-label={`${day.date}: ${describeDay(day, unit)}`}
title={`${day.date}: ${describeDay(day, unit)}`}
@@ -274,27 +277,14 @@ export function CalendarHeatmap({
</div>
</div>
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
<div className="ds-calendar-caption">
<span>
{months === "custom" ? `${days.length} ${days.length === 1 ? "day" : "days"}` : `${months} months`}{" "}
<span className="ds-muted">
/{" "}
{historyLabel ??
(compact ? "Demo history" : "Illustrative history")}
</span>
</span>
<details className="ds-calendar-help">
<summary><Keyboard size={16} aria-hidden="true" /> Keyboard shortcuts</summary>
<p id={instructionsId}>
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
</p>
</details>
</div>
<p id={instructionsId} className="ds-visually-hidden">
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
</p>
{!historyLabel && <p className="ds-calendar-demo-label ds-muted">Demo history</p>}
<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>
<time dateTime={selected.date}>
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
month: "long",

View File

@@ -1,3 +1,5 @@
import { useTheme } from "../ThemeProvider";
import { themedHabitShade } from "../../lib/habit-palette";
import { legendShades, type CalendarDay } from "./calendar-model";
export function CalendarLegend({
@@ -11,7 +13,8 @@ export function CalendarLegend({
label: string;
shades?: string[];
}) {
const shades = suppliedShades ?? legendShades(days, color);
const { theme } = useTheme();
const shades = suppliedShades ?? legendShades(days, color, theme);
return (
<div
className="ds-calendar-legend"
@@ -31,11 +34,11 @@ export function CalendarLegend({
: "Empty"
}
>
{shades.map((shade) => (
{shades.map((shade, index) => (
<span
key={shade}
className="ds-legend-swatch"
style={{ backgroundColor: shade }}
style={{ backgroundColor: index === 0 ? "var(--ds-empty)" : suppliedShades ? themedHabitShade(shade, color, theme) : shade }}
/>
))}
</span>

View File

@@ -1,9 +1,9 @@
import { useId, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, ListChecks } from "lucide-react";
import { Check, Circle, Hash, ChevronDown, ListChecks } from "lucide-react";
import { CalendarHeatmap } from "./CalendarHeatmap";
import { demoCalendar } from "./calendar-model";
/** Completion controls define each goal surface; history stays a separate full-width disclosure. */
/** A shared habit identity, visible history, and goal-specific completion surface. */
export function HabitChart({
name,
method,
@@ -45,30 +45,35 @@ export function HabitChart({
}) {
const headingId = useId();
const Heading = `h${headingLevel}` as const;
const complete = due && target > 0 && value >= target;
const TypeIcon = trackingMethod === "count" ? Hash : trackingMethod === "tasks" ? ListChecks : Check;
if (trackingMethod) return (
<section className={`ds-habit-chart ds-goal ds-goal--${trackingMethod}`} id={id} aria-labelledby={headingId} style={{ "--habit-color": color, "--habit-tint": /^#[0-9a-f]{6}$/i.test(color) ? `${color}0d` : "var(--ds-surface)" } as CSSProperties}>
<div className="ds-goal-body">
<div className="ds-goal-overview">
<header className="ds-goal-heading">
<div className="ds-goal-title">
{trackingMethod === "manual" && children}
<Heading id={headingId} className="type-title">
{trackingMethod === "manual" ? <label htmlFor={checkInId}>{name}</label> : name}
</Heading>
</div>
{headingActions}
</header>
{(!due || schedule !== "Every day") && <p className="ds-goal-schedule">{due ? schedule : `Not scheduled today · ${schedule}`}</p>}
{trackingMethod === "tasks" && due && target > 0 && <div className="ds-goal-task-progress">
<progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={target} />
<span role="status">{value} / {target}</span>
</div>}
<section className={`ds-habit-chart ds-goal ds-goal--${trackingMethod}`} id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
<header className="ds-goal-heading">
<div className="ds-goal-identity">
<div className="ds-goal-meta"><span className="ds-goal-swatch" aria-hidden="true" /><TypeIcon size={16} aria-hidden="true" /><span>{method}</span>{schedule !== "Every day" && <span className="ds-goal-schedule">{schedule}</span>}</div>
<Heading id={headingId} className="type-title">{name}</Heading>
</div>
{trackingMethod === "count" && <div className="ds-goal-count">{children}<span className="ds-goal-unit">{unit}</span></div>}
{editor}
{trackingMethod === "tasks" && <div className="ds-goal-tasks">{tasks}</div>}
<div className="ds-goal-heading-actions">
<span className="ds-goal-status" role="status">{complete ? <Check size={16} aria-hidden="true" /> : <Circle size={14} aria-hidden="true" />}{!due ? "Day off" : complete ? "Complete" : value > 0 ? "In progress" : "Not started"}</span>
{headingActions}
</div>
</header>
{editor}
<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}
</div>}
{trackingMethod === "manual" && <div className="ds-goal-check-in">{children}<label htmlFor={checkInId}>{complete ? "Done for today" : "Mark as done"}</label></div>}
{trackingMethod === "tasks" && <>
<div className="ds-goal-task-progress"><span>{value} of {target} tasks done</span><progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={Math.max(1, target)} /></div>
<div className="ds-goal-tasks" role="group" aria-label={`${name}: ${tasksLabel}`}>{tasks}</div>
</>}
</div>
<div className="ds-goal-graph">{calendar ?? <CalendarHeatmap days={demoCalendar(value, target)} label={`${name} progress calendar`} unit={unit} color={color} compact />}</div>
</div>
{calendar}
</section>
);

View File

@@ -1,5 +1,7 @@
import { useTheme } from "../ThemeProvider";
import { habitProgressColor } from "../../lib/habit-palette";
import { useId, useState } from "react";
import { HABIT_COLORS, progressShade } from "./calendar-model";
import { HABIT_COLORS } from "./calendar-model";
import { Field } from "./Field";
export type HabitColors = {
@@ -47,6 +49,7 @@ export function HabitColorPicker({
mode?: "demo" | "create" | "edit";
compact?: boolean;
}) {
const { theme } = useTheme();
const id = useId();
const [expanded, setExpanded] = useState(false);
const showDetails = !compact || expanded;
@@ -55,14 +58,14 @@ export function HabitColorPicker({
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend>
<p id={`${id}-hint`} className={showDetails ? "ds-footnote" : "ds-visually-hidden"}>
Custom colors use six-digit hex values, such as #426582.
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: palette.colors[0] }}>{value.toLowerCase() === palette.colors[0] ? "✓" : ""}</span>
<span style={{ backgroundColor: habitProgressColor(palette.colors[0], 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>{value.toLowerCase() === palette.colors[0] ? "✓" : ""}</span>
</button>
))}
</div>
@@ -89,7 +92,7 @@ export function HabitColorPicker({
title={color}
onClick={() => onChange(color)}
>
<span style={{ backgroundColor: color }}>
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
{value.toLowerCase() === color ? "✓" : ""}
</span>
</button>
@@ -144,10 +147,7 @@ export function HabitColorPicker({
<span
key={count}
style={{
backgroundColor: progressShade(
{ date: "", value: count, target: 8, state: "due" },
value,
),
backgroundColor: count === 0 ? "var(--ds-empty)" : habitProgressColor(value, (count - 1) / 7, theme),
}}
/>
))}

View File

@@ -53,7 +53,7 @@ export function Modal({ id, eyebrow, title, description, children, onClose, clos
onKeyDown={event => {
if (event.key !== "Tab") return;
event.stopPropagation();
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, [tabindex]')]
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, summary, [tabindex]')]
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
const first = controls[0];
const last = controls.at(-1);

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { Link } from "react-router";
import { ThemeControl } from "../ThemeProvider";
import { Button } from "./primitives";
/** The existing design-system page shell, shared without page-specific styling. */
@@ -28,14 +29,14 @@ export function PageLayout({
</a>
<header className="ds-header">
<Link className="ds-wordmark" to={wordmarkTo}>
minabot<span aria-hidden="true">.</span>
Mina Habits<span aria-hidden="true">.</span>
</Link>
{header}
<div className="ds-header-actions">{header}<ThemeControl /></div>
</header>
<main id={mainId} className="ds-main" tabIndex={-1}>
{children}
<footer className="ds-footer">
<span className="ds-wordmark">minabot.</span>
<span className="ds-wordmark">Mina Habits.</span>
<span>A little, every day.</span>
<Button
variant="text"

View File

@@ -46,7 +46,6 @@ export function WelcomePanel({
<div className="ds-welcome-identity">
{avatar}
<div>
<p className="ds-eyebrow">YOUR DAILY CHECK-IN</p>
<h1 id="dashboard-title" className="type-title">Welcome back, <em>{name}.</em></h1>
</div>
</div>

View File

@@ -231,7 +231,7 @@ test("custom timeframe applies inclusive dates, supports single days and survive
expect(container.querySelectorAll(".ds-day")).toHaveLength(2);
expect(container.querySelectorAll(".ds-calendar-month-label")).toHaveLength(1);
expect(container.querySelector(".ds-calendar-range-label")?.textContent).toContain("Aug 31, 2026");
expect(container.querySelector(".ds-calendar-caption")?.textContent).toStartWith("2 days");
expect(container.querySelector(".ds-calendar-range-label")?.textContent).toContain("Sep 1, 2026");
expect(container.querySelectorAll('.ds-day[tabindex="0"]')).toHaveLength(1);
await act(async () => button("3m").click());
expect(container.querySelector("form")).toBeNull();
@@ -242,7 +242,7 @@ test("custom timeframe applies inclusive dates, supports single days and survive
setDates("2026-09-04", "2026-09-04");
await act(async () => button("Apply range").click());
expect(container.querySelectorAll(".ds-day")).toHaveLength(1);
expect(container.querySelector(".ds-calendar-caption")?.textContent).toStartWith("1 day ");
expect(container.querySelector(".ds-calendar-range-label")?.textContent).toContain("Sep 4, 2026");
expect(container.querySelector(".ds-date-inspector")?.textContent).toContain("September 4, 2026");
});

View File

@@ -1,3 +1,5 @@
import { habitProgressColor } from "../../lib/habit-palette";
import type { Theme } from "../../lib/theme";
export const PROGRESS_SHADES = [
"#eeeeee",
"#d8d8d8",
@@ -103,7 +105,7 @@ export function describeDay(day: CalendarDay, unit: string) {
}
/** Only show shades that the chart's completion targets can produce. */
export function legendShades(days: CalendarDay[], color = "#111111") {
export function legendShades(days: CalendarDay[], color = "#111111", theme?: Theme) {
const levels = new Set<number>();
for (const target of new Set(days.map((day) => day.target))) {
if (!Number.isFinite(target) || target <= 0) continue;
@@ -118,7 +120,7 @@ export function legendShades(days: CalendarDay[], color = "#111111") {
...[...levels]
.sort((a, b) => a - b)
.map((value) =>
progressShade({ date: "", value, target: 8, state: "due" }, color),
theme ? habitProgressColor(color, (value - 1) / 7, theme) : progressShade({ date: "", value, target: 8, state: "due" }, color),
),
];
}

View File

@@ -39,14 +39,14 @@ export function SectionHeading({
children,
id,
}: {
number: string;
number?: string;
title: ReactNode;
children?: ReactNode;
id?: string;
}) {
return (
<header className="ds-section-heading">
<span className="ds-eyebrow">{number}</span>
{number && <span className="ds-eyebrow">{number}</span>}
<h2 id={id} tabIndex={-1}>{title}</h2>
{children && <p>{children}</p>}
</header>