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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user