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

@@ -0,0 +1,30 @@
// Disposable visual fixtures: no user data and no outbound Discord calls.
import { ThemeProvider, ThemeControl, useTheme } from "../src/components/ThemeProvider";
import { createRoot } from "react-dom/client";
import { useEffect, useState } from "react";
import { addDays } from "../src/habits/calendar";
import { renderProgressCard } from "../src/lib/progress-card";
import type { ShareData } from "../src/sharing/contracts";
import "../styles/globals.css";
const names = ["A few pages, every day", "An afternoon walk", "A very long habit name that should truncate gracefully without hitting the count", "Drink enough water", "练习中文 · 日本語", "Make time to unwind"];
const colors = ["#90647e", "#397f76", "#ba793c", "#4b70b4", "#834f9e", "#bd5863"];
function Preview({ count, empty = false, privateCard = false }: { count: number; empty?: boolean; privateCard?: boolean }) {
const { theme } = useTheme();
const [url, setUrl] = useState("");
useEffect(() => {
let disposed = false, objectUrl = "";
const total = count === 6 ? 365 : 30;
const from = addDays("2026-09-04", 1 - total);
const data: ShareData = { from, to: "2026-09-04", timezone: "America/Argentina/Buenos_Aires", habits: names.slice(0, count).map((name, i) => {
const days = Array.from({length: total}, (_, n) => { const due = !empty && (n + i) % 7 !== 0; const ratio = due ? (n % 4 === 0 ? 0 : n % 3 === 0 ? .5 : 1) : null; return { date: addDays(from, n), due, complete: ratio === 1, ratio, color: ratio === 1 ? colors[i]! : ratio ? "#c7b0bf" : "#e4e7e0" }; });
return { name, color: colors[i]!, completed: days.filter(d => d.complete).length, scheduled: days.filter(d => d.due).length, days, legend: { empty: "#e4e7e0", partial: ["#c7b0bf"], complete: colors[i]! } };
}) };
void renderProgressCard(data, { id: "qa", discordId: "qa", username: "alice", displayName: "Alexandra with a very long display name", avatarUrl: null, timezone: data.timezone }, { name: !privateCard, avatar: !privateCard, habitNames: !privateCard, timezone: privateCard }, theme).then(result => {
if (disposed) return; objectUrl = URL.createObjectURL(result.blob); setUrl(objectUrl);
});
return () => { disposed = true; if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [theme]);
return <section style={{ background: "#313338", padding: 20, borderRadius: 8, width: 360 }}><h2 style={{ color: "white", marginBottom: 16 }}>{count} {count === 1 ? "habit" : "habits"}{empty ? " · no schedule" : ""}{privateCard ? " · private" : ""}</h2>{url && <img alt="QA progress card at 320 pixels" src={url} style={{ width: 320, display: "block" }} />}</section>;
}
createRoot(document.getElementById("root")!).render(<ThemeProvider><main className="ds-root" style={{ padding: 24 }}><ThemeControl /><h1>Card readability at 320px</h1><div style={{ display: "flex", flexWrap: "wrap", alignItems: "start", gap: 24, marginTop: 24 }}><Preview count={1}/><Preview count={6}/><Preview count={6} privateCard/><Preview count={1} empty/></div><h2 style={{ margin: "24px 0" }}>390 × 844 mobile composer</h2><iframe title="Mobile dashboard" src="http://127.0.0.1:3107/__preview/login" style={{ width: 390, height: 844, border: "1px solid #aaa" }}/></main></ThemeProvider>);