diff --git a/.windsurf/rules/specify-rules.md b/.windsurf/rules/specify-rules.md index 3e858be..e81b6cd 100644 --- a/.windsurf/rules/specify-rules.md +++ b/.windsurf/rules/specify-rules.md @@ -4,6 +4,9 @@ Auto-generated from all feature plans. Last updated: 2026-02-07 ## Active Technologies +- TypeScript 5 (strict mode) + React 19, `diff` npm package (already installed — exports `diffChars`, `diffWords`, `diffLines`) (002-toggle-diff-options) +- localStorage (browser-native, no new dependencies) (002-toggle-diff-options) + - N/A (client-side only, no persistence) (001-text-diff) - TypeScript 5.9.3 (strict mode) + React 19.2.4, `diff` (npm — to be added as runtime dependency) (001-text-diff) @@ -26,6 +29,8 @@ TypeScript 5.9.3 (strict mode): Follow standard conventions ## Recent Changes +- 002-toggle-diff-options: Added TypeScript 5 (strict mode) + React 19, `diff` npm package (already installed — exports `diffChars`, `diffWords`, `diffLines`) + - 001-text-diff: Added TypeScript 5.9.3 (strict mode) + React 19.2.4, `diff` (npm — to be added as runtime dependency) - 001-text-diff: Added TypeScript 5.9.3 (strict mode) + React 19.2.4, `diff` (npm — to be added as runtime dependency) diff --git a/specs/002-toggle-diff-options/checklists/requirements.md b/specs/002-toggle-diff-options/checklists/requirements.md new file mode 100644 index 0000000..ccf4641 --- /dev/null +++ b/specs/002-toggle-diff-options/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Toggle Diff Options + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-08 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- Assumption: the `diff` npm library already supports character-level (`diffChars`), word-level (`diffWords`), and line-level (`diffLines`) methods, so no new dependencies are needed. +- Whitespace trimming and case normalization are pre-processing steps applied to input text before passing to the diff function. diff --git a/specs/002-toggle-diff-options/data-model.md b/specs/002-toggle-diff-options/data-model.md new file mode 100644 index 0000000..7e9f5fe --- /dev/null +++ b/specs/002-toggle-diff-options/data-model.md @@ -0,0 +1,68 @@ +# Data Model: Toggle Diff Options + +**Feature Branch**: `002-toggle-diff-options` +**Date**: 2026-02-08 + +## Overview + +This feature adds one new type (`DiffMethod`) and one new hook (`useLocalStorage`) to the existing client-side data model. All data remains in-memory React state with optional localStorage persistence. No databases, APIs, or external storage. + +## New Entities + +### DiffMethod + +The granularity of comparison. A string union type with three possible values. + +- `'characters'` — character-level diff (`diffChars`) +- `'words'` — word-level diff (`diffWords`, current default) +- `'lines'` — line-level diff (`diffLines`) + +**Default**: `'words'` (preserves current behavior) +**Persistence**: localStorage key `'diffMethod'` + +## Modified Entities + +### DiffResult (no structural change) + +The `DiffResult` interface (`segments` + `hasChanges`) remains unchanged. The diff method only affects which library function produces the segments — the output shape is identical across `diffChars`, `diffWords`, and `diffLines`. + +### ViewMode (no structural change) + +The `ViewMode` type (`'unified' | 'side-by-side'`) remains unchanged. It gains localStorage persistence via the same `useLocalStorage` hook. + +**Persistence**: localStorage key `'viewMode'` + +## Hooks + +### useLocalStorage(key, defaultValue) → [T, (value: T) => void] + +A generic hook that mirrors `useState` but reads the initial value from localStorage and writes on every update. + +- **key**: localStorage key string +- **defaultValue**: fallback when key is missing or value is invalid +- **Returns**: `[currentValue, setValue]` tuple (same shape as `useState`) +- **Error handling**: Falls back to `defaultValue` if localStorage read fails or JSON parsing throws + +### useDiff(originalText, modifiedText, method) → DiffResult | null + +Modified to accept a third parameter `method: DiffMethod` that selects which diff function to call. + +- `'characters'` → `diffChars(original, modified)` +- `'words'` → `diffWords(original, modified)` (current behavior) +- `'lines'` → `diffLines(original, modified)` + +## Component Props + +### DiffMethodToggleProps + +``` +activeMethod: DiffMethod — the currently selected diff method +onMethodChange: (method: DiffMethod) => void — callback when user selects a method +``` + +## localStorage Schema + +| Key | Type | Default | Description | +| ------------ | ------------ | ----------- | ------------------------------------ | +| `diffMethod` | `DiffMethod` | `'words'` | Selected diff comparison granularity | +| `viewMode` | `ViewMode` | `'unified'` | Selected diff display mode | diff --git a/specs/002-toggle-diff-options/plan.md b/specs/002-toggle-diff-options/plan.md new file mode 100644 index 0000000..739fc38 --- /dev/null +++ b/specs/002-toggle-diff-options/plan.md @@ -0,0 +1,72 @@ +# Implementation Plan: Toggle Diff Options + +**Branch**: `002-toggle-diff-options` | **Date**: 2026-02-08 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/002-toggle-diff-options/spec.md` + +## Summary + +Add a segmented button group (Characters | Words | Lines) to the diff header that lets users switch between `diffChars`, `diffWords`, and `diffLines` from the `diff` npm library. The selected method and view mode are persisted to localStorage. The diff method toggle is placed on the left side of the diff header; the existing view mode toggle stays on the right. State is lifted in the `App` component following the existing `viewMode` pattern. A new `useLocalStorage` hook provides persistence with type-safe fallback defaults. + +## Technical Context + +**Language/Version**: TypeScript 5 (strict mode) +**Primary Dependencies**: React 19, `diff` npm package (already installed — exports `diffChars`, `diffWords`, `diffLines`) +**Storage**: localStorage (browser-native, no new dependencies) +**Testing**: Vitest 4 with @testing-library/react and @testing-library/user-event +**Target Platform**: Browser (static SPA, any modern browser) +**Project Type**: Single-page web application +**Performance Goals**: Instant recomputation on method change (no debounce) +**Constraints**: Client-side only, 100% test coverage, no new runtime dependencies +**Scale/Scope**: 3 new/modified components, 1 new hook, 1 type addition + +## Constitution Check + +_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._ + +| Principle | Status | Notes | +| ----------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| I. Client-Side Only | ✅ PASS | localStorage is browser-native, no server calls | +| II. Full Test Coverage | ✅ PASS | All new code will have 100% coverage | +| III. Accessibility First | ✅ PASS | Segmented button group uses `role="group"`, `aria-label`, native ` + + + + ); +} diff --git a/src/components/DiffMethodToggle/DiffMethodToggle.types.ts b/src/components/DiffMethodToggle/DiffMethodToggle.types.ts new file mode 100644 index 0000000..edb5a87 --- /dev/null +++ b/src/components/DiffMethodToggle/DiffMethodToggle.types.ts @@ -0,0 +1,8 @@ +import type { DiffMethod } from 'src/types/diff'; + +export interface DiffMethodToggleProps { + /** The currently selected diff method */ + activeMethod: DiffMethod; + /** Callback when the user selects a different method */ + onMethodChange: (method: DiffMethod) => void; +} diff --git a/src/components/DiffMethodToggle/index.ts b/src/components/DiffMethodToggle/index.ts new file mode 100644 index 0000000..d72db61 --- /dev/null +++ b/src/components/DiffMethodToggle/index.ts @@ -0,0 +1 @@ +export { default } from './DiffMethodToggle'; diff --git a/src/hooks/useDiff.test.ts b/src/hooks/useDiff.test.ts index 03368fa..0ef7d6b 100644 --- a/src/hooks/useDiff.test.ts +++ b/src/hooks/useDiff.test.ts @@ -80,4 +80,40 @@ describe('useDiff', () => { rerender({ original: 'hello', modified: 'world' }); expect(result.current).toBe(firstResult); }); + + it('computes character-level diff when method is "characters"', () => { + const { result } = renderHook(() => useDiff('abc', 'aXc', 'characters')); + expect(result.current?.hasChanges).toBe(true); + + const segments = result.current?.segments ?? []; + const removed = segments.filter((s) => s.type === 'removed'); + const added = segments.filter((s) => s.type === 'added'); + expect(removed).toEqual([{ value: 'b', type: 'removed' }]); + expect(added).toEqual([{ value: 'X', type: 'added' }]); + }); + + it('computes line-level diff when method is "lines"', () => { + const { result } = renderHook(() => + useDiff('line1\nline2\n', 'line1\nchanged\n', 'lines'), + ); + expect(result.current?.hasChanges).toBe(true); + + const segments = result.current?.segments ?? []; + const removed = segments.filter((s) => s.type === 'removed'); + const added = segments.filter((s) => s.type === 'added'); + expect(removed[0]?.value).toContain('line2'); + expect(added[0]?.value).toContain('changed'); + }); + + it('defaults to word-level diff when method is omitted', () => { + const withMethod = renderHook(() => + useDiff('hello world', 'hello there', 'words'), + ); + const withoutMethod = renderHook(() => + useDiff('hello world', 'hello there'), + ); + expect(withMethod.result.current?.segments).toEqual( + withoutMethod.result.current?.segments, + ); + }); }); diff --git a/src/hooks/useDiff.ts b/src/hooks/useDiff.ts index c502045..9c059d6 100644 --- a/src/hooks/useDiff.ts +++ b/src/hooks/useDiff.ts @@ -1,21 +1,38 @@ -import { diffWords } from 'diff'; +import type { Change } from 'diff'; +import { diffChars, diffLines, diffWords } from 'diff'; import { useMemo } from 'react'; -import type { DiffResult, DiffSegment } from 'src/types/diff'; +import type { DiffMethod, DiffResult, DiffSegment } from 'src/types/diff'; + +function computeChanges( + method: DiffMethod, + oldStr: string, + newStr: string, +): Change[] { + switch (method) { + case 'characters': + return diffChars(oldStr, newStr); + case 'lines': + return diffLines(oldStr, newStr); + case 'words': + return diffWords(oldStr, newStr); + } +} /** - * Computes a word-level diff between two strings. + * Computes a diff between two strings using the specified method. * Returns null when either input is empty (FR-005). */ export function useDiff( originalText: string, modifiedText: string, + method: DiffMethod = 'words', ): DiffResult | null { return useMemo(() => { if (!originalText || !modifiedText) { return null; } - const changes = diffWords(originalText, modifiedText); + const changes = computeChanges(method, originalText, modifiedText); const segments: DiffSegment[] = changes.map((change) => ({ value: change.value, @@ -27,5 +44,5 @@ export function useDiff( ); return { segments, hasChanges }; - }, [originalText, modifiedText]); + }, [originalText, modifiedText, method]); } diff --git a/src/hooks/useLocalStorage.test.ts b/src/hooks/useLocalStorage.test.ts new file mode 100644 index 0000000..30fa85c --- /dev/null +++ b/src/hooks/useLocalStorage.test.ts @@ -0,0 +1,69 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useLocalStorage } from './useLocalStorage'; + +describe('useLocalStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('returns the default value when key is not in localStorage', () => { + const { result } = renderHook(() => useLocalStorage('testKey', 'default')); + + expect(result.current[0]).toBe('default'); + }); + + it('returns the stored value when key exists in localStorage', () => { + localStorage.setItem('testKey', JSON.stringify('stored')); + + const { result } = renderHook(() => useLocalStorage('testKey', 'default')); + + expect(result.current[0]).toBe('stored'); + }); + + it('persists value to localStorage when setValue is called', () => { + const { result } = renderHook(() => useLocalStorage('testKey', 'default')); + + act(() => { + result.current[1]('updated'); + }); + + expect(result.current[0]).toBe('updated'); + expect(localStorage.getItem('testKey')).toBe(JSON.stringify('updated')); + }); + + it('falls back to default when localStorage contains invalid JSON', () => { + localStorage.setItem('testKey', 'not-valid-json'); + + const { result } = renderHook(() => useLocalStorage('testKey', 'default')); + + expect(result.current[0]).toBe('default'); + }); + + it('works with non-string types', () => { + const { result } = renderHook(() => + useLocalStorage('testKey', { count: 0 }), + ); + + expect(result.current[0]).toEqual({ count: 0 }); + + act(() => { + result.current[1]({ count: 5 }); + }); + + expect(result.current[0]).toEqual({ count: 5 }); + expect(localStorage.getItem('testKey')).toBe(JSON.stringify({ count: 5 })); + }); + + it('handles multiple keys independently', () => { + const { result: result1 } = renderHook(() => useLocalStorage('key1', 'a')); + const { result: result2 } = renderHook(() => useLocalStorage('key2', 'b')); + + act(() => { + result1.current[1]('x'); + }); + + expect(result1.current[0]).toBe('x'); + expect(result2.current[0]).toBe('b'); + }); +}); diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..5e556e4 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,30 @@ +import { useCallback, useState } from 'react'; + +/** + * A generic hook that mirrors useState but persists the value to localStorage. + * Reads the initial value from localStorage on mount, falling back to defaultValue. + * Writes to localStorage on every state update via JSON serialization. + */ +export function useLocalStorage( + key: string, + defaultValue: T, +): [T, (value: T) => void] { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + return item !== null ? (JSON.parse(item) as T) : defaultValue; + } catch { + return defaultValue; + } + }); + + const setValue = useCallback( + (value: T) => { + setStoredValue(value); + localStorage.setItem(key, JSON.stringify(value)); + }, + [key], + ); + + return [storedValue, setValue]; +} diff --git a/src/types/diff.ts b/src/types/diff.ts index 4735571..42ebad0 100644 --- a/src/types/diff.ts +++ b/src/types/diff.ts @@ -19,3 +19,6 @@ export interface DiffResult { /** Available diff display modes */ export type ViewMode = 'unified' | 'side-by-side'; + +/** Available diff comparison methods */ +export type DiffMethod = 'characters' | 'words' | 'lines';