feat(bd-sec): implement Settings UI component with TDD

Settings includes:
- Theme toggle (dark/light mode)
- Notification preferences toggle
- Sound effects toggle  
- Floating widget toggle
- Hotkey configuration with validation
- Reconciliation interval input
- Default defer duration selector
- Keyboard shortcuts display (read-only)
- Lore database path configuration
- Data directory info display

21 tests covering all settings functionality including:
- Toggle behaviors
- Hotkey validation
- Input persistence on blur
- Section organization
This commit is contained in:
teernisse
2026-02-26 11:00:32 -05:00
parent d1e9c6e65d
commit ac34602b7b
2 changed files with 664 additions and 0 deletions

380
src/components/Settings.tsx Normal file
View File

@@ -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 (
<label
htmlFor={id}
className="flex cursor-pointer items-center justify-between py-2"
>
<span className="text-sm text-zinc-300">{label}</span>
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 rounded-full transition-colors ${
checked ? "bg-blue-600" : "bg-zinc-600"
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
checked ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</label>
);
}
/** Section wrapper component */
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}): React.ReactElement {
return (
<section className="border-b border-zinc-800 pb-6">
<h2 className="mb-4 text-lg font-semibold text-zinc-100">{title}</h2>
<div className="space-y-3">{children}</div>
</section>
);
}
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 (
<div className="mx-auto max-w-lg space-y-6 p-6">
<h1 className="text-2xl font-bold text-zinc-100">Settings</h1>
{/* Appearance */}
<Section title="Appearance">
<Toggle
label="Dark mode"
checked={settings.theme === "dark"}
onChange={handleThemeToggle}
/>
</Section>
{/* Behavior */}
<Section title="Behavior">
<Toggle
label="Notifications"
checked={settings.notifications}
onChange={(v) => handleToggle("notifications", v)}
/>
<Toggle
label="Sound effects"
checked={settings.sounds}
onChange={(v) => handleToggle("sounds", v)}
/>
<Toggle
label="Floating widget"
checked={settings.floatingWidget}
onChange={(v) => handleToggle("floatingWidget", v)}
/>
{/* Reconciliation interval */}
<div className="flex items-center justify-between py-2">
<label
htmlFor="reconciliation"
className="text-sm text-zinc-300"
>
Reconciliation interval (hours)
</label>
<input
id="reconciliation"
type="number"
min="1"
max="24"
value={reconciliationValue}
onChange={(e) => handleReconciliationChange(e.target.value)}
onBlur={handleReconciliationBlur}
className="w-20 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"
/>
</div>
{/* Default defer duration */}
<div className="flex items-center justify-between py-2">
<label
htmlFor="default-defer"
className="text-sm text-zinc-300"
>
Default defer duration
</label>
<select
id="default-defer"
value={settings.defaultDefer}
onChange={(e) => handleSelectChange("defaultDefer", e.target.value)}
className="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"
>
<option value="1h">1 hour</option>
<option value="3h">3 hours</option>
<option value="tomorrow">Tomorrow</option>
<option value="next_week">Next week</option>
</select>
</div>
</Section>
{/* Hotkeys */}
<Section title="Hotkeys">
<div className="space-y-4">
<div>
<label
htmlFor="toggle-hotkey"
className="mb-1 block text-sm text-zinc-300"
>
Toggle hotkey
</label>
<input
id="toggle-hotkey"
type="text"
value={hotkeyValues.toggle}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-400">{hotkeyErrors.toggle}</p>
)}
</div>
<div>
<label
htmlFor="capture-hotkey"
className="mb-1 block text-sm text-zinc-300"
>
Capture hotkey
</label>
<input
id="capture-hotkey"
type="text"
value={hotkeyValues.capture}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-400">{hotkeyErrors.capture}</p>
)}
</div>
</div>
</Section>
{/* Keyboard Shortcuts (read-only) */}
<Section title="Keyboard Shortcuts">
<div className="rounded-md border border-zinc-800 bg-zinc-900/50">
{KEYBOARD_SHORTCUTS.map(({ action, shortcut }) => (
<div
key={action}
className="flex items-center justify-between border-b border-zinc-800 px-3 py-2 last:border-b-0"
>
<span className="text-sm text-zinc-400">{action}</span>
<kbd className="rounded bg-zinc-700 px-2 py-0.5 font-mono text-xs text-zinc-300">
{shortcut}
</kbd>
</div>
))}
</div>
</Section>
{/* Data */}
<Section title="Data">
{/* Lore path */}
<div>
<label
htmlFor="lore-path"
className="mb-1 block text-sm text-zinc-300"
>
Lore database path
</label>
<input
id="lore-path"
type="text"
value={settings.lorePath ?? ""}
placeholder="~/.local/share/lore/lore.db"
onChange={(e) =>
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"
/>
<p className="mt-1 text-xs text-zinc-500">
Leave empty to use the default location
</p>
</div>
{/* Data directory info */}
{dataDir && (
<div className="mt-4 rounded-md border border-zinc-800 bg-zinc-900/50 p-3">
<p className="text-xs text-zinc-500">Data directory</p>
<p className="font-mono text-sm text-zinc-400">{dataDir}</p>
</div>
)}
</Section>
</div>
);
}

View File

@@ -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(<Settings settings={defaultSettings} onSave={vi.fn()} />);
// Check hotkey displays
expect(screen.getByDisplayValue("Meta+Shift+M")).toBeInTheDocument();
expect(screen.getByDisplayValue("Meta+Shift+C")).toBeInTheDocument();
});
it("shows data directory info", () => {
render(
<Settings
settings={defaultSettings}
onSave={vi.fn()}
dataDir="~/.local/share/mc"
/>
);
expect(screen.getByText(/\.local\/share\/mc/)).toBeInTheDocument();
});
});
describe("theme toggle", () => {
it("renders theme toggle with current value", () => {
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
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(<Settings settings={defaultSettings} onSave={onSave} />);
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(<Settings settings={defaultSettings} onSave={vi.fn()} />);
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(<Settings settings={defaultSettings} onSave={onSave} />);
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(<Settings settings={defaultSettings} onSave={vi.fn()} />);
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(<Settings settings={defaultSettings} onSave={onSave} />);
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(<Settings settings={defaultSettings} onSave={vi.fn()} />);
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(<Settings settings={defaultSettings} onSave={onSave} />);
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(<Settings settings={defaultSettings} onSave={vi.fn()} />);
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(<Settings settings={defaultSettings} onSave={onSave} />);
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(<Settings settings={defaultSettings} onSave={vi.fn()} />);
expect(screen.getByDisplayValue("6")).toBeInTheDocument();
});
it("updates interval on change", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<Settings settings={defaultSettings} onSave={onSave} />);
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(<Settings settings={defaultSettings} onSave={vi.fn()} />);
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(<Settings settings={defaultSettings} onSave={onSave} />);
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(<Settings settings={defaultSettings} onSave={vi.fn()} />);
expect(screen.getByText(/keyboard shortcuts/i)).toBeInTheDocument();
});
it("displays common shortcuts", () => {
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
// 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(<Settings settings={defaultSettings} onSave={vi.fn()} />);
expect(
screen.getByPlaceholderText(/\.local\/share\/lore/i)
).toBeInTheDocument();
});
it("shows custom lore path when set", () => {
const settingsWithPath = {
...defaultSettings,
lorePath: "/custom/path/lore.db",
};
render(<Settings settings={settingsWithPath} onSave={vi.fn()} />);
expect(screen.getByDisplayValue("/custom/path/lore.db")).toBeInTheDocument();
});
});
describe("section organization", () => {
it("groups settings into sections", () => {
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
// 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();
});
});
});