diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx
new file mode 100644
index 0000000..220551c
--- /dev/null
+++ b/src/components/Settings.tsx
@@ -0,0 +1,380 @@
+/**
+ * Settings -- User preferences and configuration panel.
+ *
+ * Features:
+ * - Theme toggle (dark/light)
+ * - Notification preferences
+ * - Sound effects toggle
+ * - Floating widget toggle
+ * - Hotkey configuration
+ * - Reconciliation interval
+ * - Default defer duration
+ * - Keyboard shortcuts display
+ * - Data directory info
+ */
+
+import { useCallback, useState } from "react";
+import type { DeferDuration } from "@/lib/types";
+
+/** Settings data structure matching ~/.local/share/mc/settings.json */
+export interface SettingsData {
+ schemaVersion: number;
+ hotkeys: {
+ toggle: string;
+ capture: string;
+ };
+ lorePath: string | null;
+ reconciliationHours: number;
+ floatingWidget: boolean;
+ defaultDefer: DeferDuration;
+ sounds: boolean;
+ theme: "dark" | "light";
+ notifications: boolean;
+}
+
+export interface SettingsProps {
+ settings: SettingsData;
+ onSave: (settings: SettingsData) => void;
+ dataDir?: string;
+}
+
+/** Keyboard shortcuts to display (not configurable, just informational) */
+const KEYBOARD_SHORTCUTS = [
+ { action: "Start task", shortcut: "S" },
+ { action: "Skip task", shortcut: "K" },
+ { action: "Defer 1 hour", shortcut: "D" },
+ { action: "Defer tomorrow", shortcut: "T" },
+ { action: "Command palette", shortcut: "Cmd+K" },
+ { action: "Quick capture", shortcut: "Cmd+Shift+C" },
+] as const;
+
+/** Validate hotkey format (e.g., Meta+Shift+M) */
+function isValidHotkey(value: string): boolean {
+ // Accept common modifier patterns
+ const pattern = /^(Meta|Ctrl|Alt|Shift)(\+(Meta|Ctrl|Alt|Shift))*\+[A-Z]$/;
+ return pattern.test(value);
+}
+
+/** Toggle switch component */
+function Toggle({
+ label,
+ checked,
+ onChange,
+}: {
+ label: string;
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+}): React.ReactElement {
+ const id = label.toLowerCase().replace(/\s+/g, "-");
+
+ return (
+
+ );
+}
+
+/** Section wrapper component */
+function Section({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}): React.ReactElement {
+ return (
+
+ );
+}
+
+export function Settings({
+ settings,
+ onSave,
+ dataDir,
+}: SettingsProps): React.ReactElement {
+ // Local state for form validation
+ const [hotkeyErrors, setHotkeyErrors] = useState<{
+ toggle?: string;
+ capture?: string;
+ }>({});
+
+ // Local state for controlled hotkey inputs
+ const [hotkeyValues, setHotkeyValues] = useState({
+ toggle: settings.hotkeys.toggle,
+ capture: settings.hotkeys.capture,
+ });
+
+ // Local state for reconciliation interval
+ const [reconciliationValue, setReconciliationValue] = useState(
+ String(settings.reconciliationHours)
+ );
+
+ // Handle toggle changes
+ const handleToggle = useCallback(
+ (field: keyof SettingsData, value: boolean) => {
+ onSave({ ...settings, [field]: value });
+ },
+ [settings, onSave]
+ );
+
+ // Handle theme toggle
+ const handleThemeToggle = useCallback(
+ (isDark: boolean) => {
+ onSave({ ...settings, theme: isDark ? "dark" : "light" });
+ },
+ [settings, onSave]
+ );
+
+ // Handle hotkey input change with live validation
+ const handleHotkeyInput = useCallback(
+ (field: "toggle" | "capture", value: string) => {
+ setHotkeyValues((prev) => ({ ...prev, [field]: value }));
+
+ if (value && !isValidHotkey(value)) {
+ setHotkeyErrors((prev) => ({
+ ...prev,
+ [field]: "Invalid hotkey format",
+ }));
+ } else {
+ setHotkeyErrors((prev) => {
+ const updated = { ...prev };
+ delete updated[field];
+ return updated;
+ });
+ }
+ },
+ []
+ );
+
+ // Handle hotkey blur to save valid value
+ const handleHotkeyBlur = useCallback(
+ (field: "toggle" | "capture") => {
+ const value = hotkeyValues[field];
+ if (value && isValidHotkey(value)) {
+ onSave({
+ ...settings,
+ hotkeys: { ...settings.hotkeys, [field]: value },
+ });
+ }
+ },
+ [settings, onSave, hotkeyValues]
+ );
+
+ // Handle reconciliation input change
+ const handleReconciliationChange = useCallback((value: string) => {
+ setReconciliationValue(value);
+ }, []);
+
+ // Handle reconciliation blur (save valid value)
+ const handleReconciliationBlur = useCallback(() => {
+ const num = parseInt(reconciliationValue, 10);
+ if (!Number.isNaN(num) && num > 0) {
+ onSave({ ...settings, reconciliationHours: num });
+ }
+ }, [settings, onSave, reconciliationValue]);
+
+ // Handle select change
+ const handleSelectChange = useCallback(
+ (field: keyof SettingsData, value: string) => {
+ onSave({ ...settings, [field]: value });
+ },
+ [settings, onSave]
+ );
+
+ return (
+
+
Settings
+
+ {/* Appearance */}
+
+
+ {/* Behavior */}
+
+
+ {/* Hotkeys */}
+
+
+
+
+
handleHotkeyInput("toggle", e.target.value)}
+ onBlur={() => handleHotkeyBlur("toggle")}
+ className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-100 outline-none focus:border-blue-500"
+ />
+ {hotkeyErrors.toggle && (
+
{hotkeyErrors.toggle}
+ )}
+
+
+
+
+
handleHotkeyInput("capture", e.target.value)}
+ onBlur={() => handleHotkeyBlur("capture")}
+ className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-100 outline-none focus:border-blue-500"
+ />
+ {hotkeyErrors.capture && (
+
{hotkeyErrors.capture}
+ )}
+
+
+
+
+ {/* Keyboard Shortcuts (read-only) */}
+
+
+ {KEYBOARD_SHORTCUTS.map(({ action, shortcut }) => (
+
+ {action}
+
+ {shortcut}
+
+
+ ))}
+
+
+
+ {/* Data */}
+
+ {/* Lore path */}
+
+
+
+ onSave({
+ ...settings,
+ lorePath: e.target.value || null,
+ })
+ }
+ className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-100 placeholder-zinc-500 outline-none focus:border-blue-500"
+ />
+
+ Leave empty to use the default location
+
+
+
+ {/* Data directory info */}
+ {dataDir && (
+
+
Data directory
+
{dataDir}
+
+ )}
+
+
+ );
+}
diff --git a/tests/components/Settings.test.tsx b/tests/components/Settings.test.tsx
new file mode 100644
index 0000000..f039e06
--- /dev/null
+++ b/tests/components/Settings.test.tsx
@@ -0,0 +1,284 @@
+/**
+ * Tests for Settings component.
+ *
+ * TDD: These tests define the expected behavior before implementation.
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Settings } from "@/components/Settings";
+import type { SettingsData } from "@/components/Settings";
+
+const defaultSettings: SettingsData = {
+ schemaVersion: 1,
+ hotkeys: {
+ toggle: "Meta+Shift+M",
+ capture: "Meta+Shift+C",
+ },
+ lorePath: null,
+ reconciliationHours: 6,
+ floatingWidget: false,
+ defaultDefer: "1h",
+ sounds: true,
+ theme: "dark",
+ notifications: true,
+};
+
+describe("Settings", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("loading state", () => {
+ it("displays current settings on mount", () => {
+ render();
+
+ // Check hotkey displays
+ expect(screen.getByDisplayValue("Meta+Shift+M")).toBeInTheDocument();
+ expect(screen.getByDisplayValue("Meta+Shift+C")).toBeInTheDocument();
+ });
+
+ it("shows data directory info", () => {
+ render(
+
+ );
+
+ expect(screen.getByText(/\.local\/share\/mc/)).toBeInTheDocument();
+ });
+ });
+
+ describe("theme toggle", () => {
+ it("renders theme toggle with current value", () => {
+ render();
+
+ const themeToggle = screen.getByRole("switch", { name: /dark mode/i });
+ expect(themeToggle).toBeChecked();
+ });
+
+ it("calls onSave when theme is toggled", async () => {
+ const user = userEvent.setup();
+ const onSave = vi.fn();
+ render();
+
+ const themeToggle = screen.getByRole("switch", { name: /dark mode/i });
+ await user.click(themeToggle);
+
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({
+ theme: "light",
+ })
+ );
+ });
+ });
+
+ describe("notification preferences", () => {
+ it("renders notification toggle", () => {
+ render();
+
+ const notifToggle = screen.getByRole("switch", { name: /notifications/i });
+ expect(notifToggle).toBeChecked();
+ });
+
+ it("calls onSave when notifications toggled", async () => {
+ const user = userEvent.setup();
+ const onSave = vi.fn();
+ render();
+
+ const notifToggle = screen.getByRole("switch", { name: /notifications/i });
+ await user.click(notifToggle);
+
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({
+ notifications: false,
+ })
+ );
+ });
+ });
+
+ describe("sound effects", () => {
+ it("renders sound effects toggle", () => {
+ render();
+
+ const soundToggle = screen.getByRole("switch", { name: /sound effects/i });
+ expect(soundToggle).toBeChecked();
+ });
+
+ it("calls onSave when sound effects toggled", async () => {
+ const user = userEvent.setup();
+ const onSave = vi.fn();
+ render();
+
+ const soundToggle = screen.getByRole("switch", { name: /sound effects/i });
+ await user.click(soundToggle);
+
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sounds: false,
+ })
+ );
+ });
+ });
+
+ describe("floating widget", () => {
+ it("renders floating widget toggle", () => {
+ render();
+
+ const widgetToggle = screen.getByRole("switch", {
+ name: /floating widget/i,
+ });
+ expect(widgetToggle).not.toBeChecked();
+ });
+
+ it("calls onSave when floating widget toggled", async () => {
+ const user = userEvent.setup();
+ const onSave = vi.fn();
+ render();
+
+ const widgetToggle = screen.getByRole("switch", {
+ name: /floating widget/i,
+ });
+ await user.click(widgetToggle);
+
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({
+ floatingWidget: true,
+ })
+ );
+ });
+ });
+
+ describe("hotkey settings", () => {
+ it("validates hotkey format", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const toggleInput = screen.getByLabelText(/toggle hotkey/i);
+ await user.clear(toggleInput);
+ await user.type(toggleInput, "invalid");
+
+ expect(screen.getByText(/Invalid hotkey format/i)).toBeInTheDocument();
+ });
+
+ it("accepts valid hotkey format", async () => {
+ const user = userEvent.setup();
+ const onSave = vi.fn();
+ render();
+
+ const toggleInput = screen.getByLabelText(/toggle hotkey/i);
+ await user.clear(toggleInput);
+ await user.type(toggleInput, "Meta+Shift+K");
+ await user.tab(); // Blur to trigger save
+
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({
+ hotkeys: expect.objectContaining({
+ toggle: "Meta+Shift+K",
+ }),
+ })
+ );
+ });
+ });
+
+ describe("reconciliation interval", () => {
+ it("displays current interval", () => {
+ render();
+
+ expect(screen.getByDisplayValue("6")).toBeInTheDocument();
+ });
+
+ it("updates interval on change", async () => {
+ const user = userEvent.setup();
+ const onSave = vi.fn();
+ render();
+
+ const intervalInput = screen.getByLabelText(/reconciliation/i);
+ await user.clear(intervalInput);
+ await user.type(intervalInput, "12");
+ await user.tab();
+
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({
+ reconciliationHours: 12,
+ })
+ );
+ });
+ });
+
+ describe("default defer duration", () => {
+ it("displays current default defer", () => {
+ render();
+
+ const deferSelect = screen.getByLabelText(/default defer/i);
+ expect(deferSelect).toHaveValue("1h");
+ });
+
+ it("updates default defer on change", async () => {
+ const user = userEvent.setup();
+ const onSave = vi.fn();
+ render();
+
+ const deferSelect = screen.getByLabelText(/default defer/i);
+ await user.selectOptions(deferSelect, "3h");
+
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({
+ defaultDefer: "3h",
+ })
+ );
+ });
+ });
+
+ describe("keyboard shortcuts display", () => {
+ it("shows keyboard shortcuts section", () => {
+ render();
+
+ expect(screen.getByText(/keyboard shortcuts/i)).toBeInTheDocument();
+ });
+
+ it("displays common shortcuts", () => {
+ render();
+
+ // Check for common shortcut displays
+ expect(screen.getByText(/start task/i)).toBeInTheDocument();
+ expect(screen.getByText(/skip task/i)).toBeInTheDocument();
+ expect(screen.getByText(/command palette/i)).toBeInTheDocument();
+ });
+ });
+
+ describe("lore path", () => {
+ it("shows default lore path when null", () => {
+ render();
+
+ expect(
+ screen.getByPlaceholderText(/\.local\/share\/lore/i)
+ ).toBeInTheDocument();
+ });
+
+ it("shows custom lore path when set", () => {
+ const settingsWithPath = {
+ ...defaultSettings,
+ lorePath: "/custom/path/lore.db",
+ };
+ render();
+
+ expect(screen.getByDisplayValue("/custom/path/lore.db")).toBeInTheDocument();
+ });
+ });
+
+ describe("section organization", () => {
+ it("groups settings into sections", () => {
+ render();
+
+ // Check for section headers (h2 elements)
+ expect(screen.getByRole("heading", { name: /appearance/i })).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: /behavior/i })).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: /hotkeys/i })).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: /^data$/i })).toBeInTheDocument();
+ });
+ });
+});