From 2acb9fe405518b928e5cf27efd5e308b0f40bc3b Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 8 Feb 2026 13:27:28 -0500 Subject: [PATCH 1/9] docs(specs): create spec for toggle diff options --- .../checklists/requirements.md | 36 +++++++ specs/002-toggle-diff-options/spec.md | 101 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 specs/002-toggle-diff-options/checklists/requirements.md create mode 100644 specs/002-toggle-diff-options/spec.md 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/spec.md b/specs/002-toggle-diff-options/spec.md new file mode 100644 index 0000000..dcc8ce6 --- /dev/null +++ b/specs/002-toggle-diff-options/spec.md @@ -0,0 +1,101 @@ +# Feature Specification: Toggle Diff Options + +**Feature Branch**: `002-toggle-diff-options` +**Created**: 2026-02-08 +**Status**: Draft +**Input**: User description: "toggle diff options" + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Toggle Diff Method (Priority: P1) + +A user wants to switch between different diff comparison methods to get the most useful view of their changes. The app currently uses word-level diffing only. The user should be able to toggle between character-level, word-level, and line-level diff methods via a control in the diff output area. The selected method immediately recomputes and re-renders the diff output. + +**Why this priority**: The diff method fundamentally changes what the user sees. Word-level diff is not always ideal — character-level is better for small edits (typos, variable renames), while line-level is better for large structural changes (reordering paragraphs, adding/removing blocks). This is the core value of the feature. + +**Independent Test**: Select two texts with known differences, toggle between character/word/line diff methods, and verify the diff output changes to reflect the selected granularity. + +**Acceptance Scenarios**: + +1. **Given** two texts with differences are entered, **When** the user selects "Characters" diff method, **Then** the diff output shows character-level differences +2. **Given** two texts with differences are entered, **When** the user selects "Words" diff method, **Then** the diff output shows word-level differences (current default behavior) +3. **Given** two texts with differences are entered, **When** the user selects "Lines" diff method, **Then** the diff output shows line-level differences +4. **Given** the diff method toggle is displayed, **When** the page first loads, **Then** "Words" is selected as the default method +5. **Given** the diff method is changed, **When** the user types in either textarea, **Then** the diff updates in real time using the currently selected method + +--- + +### User Story 2 - Trim Whitespace Toggle (Priority: P2) + +A user comparing text that may have trailing spaces, inconsistent indentation, or extra blank lines wants the option to ignore whitespace differences. A toggle allows the user to enable or disable whitespace trimming before the diff is computed. When enabled, leading/trailing whitespace on each line is stripped and consecutive blank lines are collapsed before comparison. + +**Why this priority**: Whitespace differences are often noise — especially when comparing code copied from different editors or text pasted from different sources. This toggle lets users focus on meaningful content changes. + +**Independent Test**: Enter two texts that differ only in whitespace (e.g., trailing spaces, extra blank lines), toggle "Trim whitespace" on, and verify the diff shows "No differences found." Toggle it off and verify whitespace differences appear. + +**Acceptance Scenarios**: + +1. **Given** two texts that differ only in whitespace, **When** the user enables "Trim whitespace", **Then** the diff output shows "No differences found" +2. **Given** two texts that differ only in whitespace, **When** "Trim whitespace" is disabled, **Then** the diff output highlights the whitespace differences +3. **Given** "Trim whitespace" is enabled, **When** the user enters texts with both whitespace and content differences, **Then** only content differences are shown +4. **Given** the trim whitespace toggle is displayed, **When** the page first loads, **Then** "Trim whitespace" is disabled by default + +--- + +### User Story 3 - Case Sensitivity Toggle (Priority: P3) + +A user comparing text wants the option to ignore case differences. A toggle allows the user to enable or disable case-insensitive comparison. When enabled, the diff treats uppercase and lowercase letters as equivalent. + +**Why this priority**: Case differences are sometimes irrelevant (e.g., comparing SQL keywords, HTML tags, or prose with inconsistent capitalization). This is a convenience feature that builds on the options pattern established by US1 and US2. + +**Independent Test**: Enter two texts that differ only in letter casing (e.g., "Hello" vs "hello"), toggle "Ignore case" on, and verify the diff shows "No differences found." Toggle it off and verify the case differences appear. + +**Acceptance Scenarios**: + +1. **Given** two texts that differ only in letter casing, **When** the user enables "Ignore case", **Then** the diff output shows "No differences found" +2. **Given** two texts that differ only in letter casing, **When** "Ignore case" is disabled, **Then** the diff output highlights the case differences +3. **Given** "Ignore case" is enabled, **When** the user enters texts with both case and content differences, **Then** only content differences are shown +4. **Given** the case sensitivity toggle is displayed, **When** the page first loads, **Then** "Ignore case" is disabled by default + +--- + +### Edge Cases + +- What happens when the user toggles diff method while both inputs are empty? The diff output remains hidden (no change in behavior). +- What happens when the user enables both "Trim whitespace" and "Ignore case" simultaneously? Both transformations are applied before diffing — whitespace is trimmed first, then case is normalized. +- What happens when switching from line-level diff to character-level diff on very large texts? The diff recomputes immediately; no special handling is needed for the MVP. +- What happens when "Trim whitespace" is enabled and the texts differ only in indentation? The diff shows "No differences found." + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: System MUST provide a control to select the diff comparison method from: characters, words (default), and lines +- **FR-002**: System MUST recompute the diff output immediately when the diff method selection changes +- **FR-003**: System MUST default to word-level diff on page load (preserving current behavior) +- **FR-004**: System MUST provide a toggle to enable/disable whitespace trimming before diff computation +- **FR-005**: When whitespace trimming is enabled, system MUST strip leading and trailing whitespace from each line and collapse consecutive blank lines before comparison +- **FR-006**: System MUST default to whitespace trimming disabled on page load +- **FR-007**: System MUST provide a toggle to enable/disable case-insensitive comparison +- **FR-008**: When case-insensitive comparison is enabled, system MUST normalize both texts to the same case before comparison +- **FR-009**: System MUST default to case-insensitive comparison disabled on page load +- **FR-010**: All diff option controls MUST be visible whenever the diff output is visible +- **FR-011**: All diff option changes MUST take effect immediately without requiring any additional user action +- **FR-012**: Diff options MUST work correctly in combination (e.g., trim whitespace + ignore case + line-level diff) +- **FR-013**: Diff option controls MUST be keyboard accessible + +### Key Entities + +- **DiffMethod**: The granularity of comparison — characters, words, or lines +- **DiffOptions**: A collection of settings that control how the diff is computed — includes the diff method, whitespace trimming flag, and case sensitivity flag + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: User can switch between character, word, and line diff methods in a single click and see the diff output update immediately +- **SC-002**: User can toggle whitespace trimming and see the diff output update to include or exclude whitespace differences +- **SC-003**: User can toggle case sensitivity and see the diff output update to include or exclude case differences +- **SC-004**: All diff option controls are accessible via keyboard navigation +- **SC-005**: All existing tests continue to pass (no regressions) +- **SC-006**: 100% test coverage is maintained From bf927203ae5c17233b47b8cef0d203e2bca98c39 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 8 Feb 2026 13:29:20 -0500 Subject: [PATCH 2/9] docs(specs): remove P2 and P3 from spec --- specs/002-toggle-diff-options/spec.md | 58 +++------------------------ 1 file changed, 6 insertions(+), 52 deletions(-) diff --git a/specs/002-toggle-diff-options/spec.md b/specs/002-toggle-diff-options/spec.md index dcc8ce6..e353edf 100644 --- a/specs/002-toggle-diff-options/spec.md +++ b/specs/002-toggle-diff-options/spec.md @@ -25,46 +25,10 @@ A user wants to switch between different diff comparison methods to get the most --- -### User Story 2 - Trim Whitespace Toggle (Priority: P2) - -A user comparing text that may have trailing spaces, inconsistent indentation, or extra blank lines wants the option to ignore whitespace differences. A toggle allows the user to enable or disable whitespace trimming before the diff is computed. When enabled, leading/trailing whitespace on each line is stripped and consecutive blank lines are collapsed before comparison. - -**Why this priority**: Whitespace differences are often noise — especially when comparing code copied from different editors or text pasted from different sources. This toggle lets users focus on meaningful content changes. - -**Independent Test**: Enter two texts that differ only in whitespace (e.g., trailing spaces, extra blank lines), toggle "Trim whitespace" on, and verify the diff shows "No differences found." Toggle it off and verify whitespace differences appear. - -**Acceptance Scenarios**: - -1. **Given** two texts that differ only in whitespace, **When** the user enables "Trim whitespace", **Then** the diff output shows "No differences found" -2. **Given** two texts that differ only in whitespace, **When** "Trim whitespace" is disabled, **Then** the diff output highlights the whitespace differences -3. **Given** "Trim whitespace" is enabled, **When** the user enters texts with both whitespace and content differences, **Then** only content differences are shown -4. **Given** the trim whitespace toggle is displayed, **When** the page first loads, **Then** "Trim whitespace" is disabled by default - ---- - -### User Story 3 - Case Sensitivity Toggle (Priority: P3) - -A user comparing text wants the option to ignore case differences. A toggle allows the user to enable or disable case-insensitive comparison. When enabled, the diff treats uppercase and lowercase letters as equivalent. - -**Why this priority**: Case differences are sometimes irrelevant (e.g., comparing SQL keywords, HTML tags, or prose with inconsistent capitalization). This is a convenience feature that builds on the options pattern established by US1 and US2. - -**Independent Test**: Enter two texts that differ only in letter casing (e.g., "Hello" vs "hello"), toggle "Ignore case" on, and verify the diff shows "No differences found." Toggle it off and verify the case differences appear. - -**Acceptance Scenarios**: - -1. **Given** two texts that differ only in letter casing, **When** the user enables "Ignore case", **Then** the diff output shows "No differences found" -2. **Given** two texts that differ only in letter casing, **When** "Ignore case" is disabled, **Then** the diff output highlights the case differences -3. **Given** "Ignore case" is enabled, **When** the user enters texts with both case and content differences, **Then** only content differences are shown -4. **Given** the case sensitivity toggle is displayed, **When** the page first loads, **Then** "Ignore case" is disabled by default - ---- - ### Edge Cases - What happens when the user toggles diff method while both inputs are empty? The diff output remains hidden (no change in behavior). -- What happens when the user enables both "Trim whitespace" and "Ignore case" simultaneously? Both transformations are applied before diffing — whitespace is trimmed first, then case is normalized. - What happens when switching from line-level diff to character-level diff on very large texts? The diff recomputes immediately; no special handling is needed for the MVP. -- What happens when "Trim whitespace" is enabled and the texts differ only in indentation? The diff shows "No differences found." ## Requirements _(mandatory)_ @@ -73,29 +37,19 @@ A user comparing text wants the option to ignore case differences. A toggle allo - **FR-001**: System MUST provide a control to select the diff comparison method from: characters, words (default), and lines - **FR-002**: System MUST recompute the diff output immediately when the diff method selection changes - **FR-003**: System MUST default to word-level diff on page load (preserving current behavior) -- **FR-004**: System MUST provide a toggle to enable/disable whitespace trimming before diff computation -- **FR-005**: When whitespace trimming is enabled, system MUST strip leading and trailing whitespace from each line and collapse consecutive blank lines before comparison -- **FR-006**: System MUST default to whitespace trimming disabled on page load -- **FR-007**: System MUST provide a toggle to enable/disable case-insensitive comparison -- **FR-008**: When case-insensitive comparison is enabled, system MUST normalize both texts to the same case before comparison -- **FR-009**: System MUST default to case-insensitive comparison disabled on page load -- **FR-010**: All diff option controls MUST be visible whenever the diff output is visible -- **FR-011**: All diff option changes MUST take effect immediately without requiring any additional user action -- **FR-012**: Diff options MUST work correctly in combination (e.g., trim whitespace + ignore case + line-level diff) -- **FR-013**: Diff option controls MUST be keyboard accessible +- **FR-004**: All diff option controls MUST be visible whenever the diff output is visible +- **FR-005**: All diff option changes MUST take effect immediately without requiring any additional user action +- **FR-006**: Diff option controls MUST be keyboard accessible ### Key Entities - **DiffMethod**: The granularity of comparison — characters, words, or lines -- **DiffOptions**: A collection of settings that control how the diff is computed — includes the diff method, whitespace trimming flag, and case sensitivity flag ## Success Criteria _(mandatory)_ ### Measurable Outcomes - **SC-001**: User can switch between character, word, and line diff methods in a single click and see the diff output update immediately -- **SC-002**: User can toggle whitespace trimming and see the diff output update to include or exclude whitespace differences -- **SC-003**: User can toggle case sensitivity and see the diff output update to include or exclude case differences -- **SC-004**: All diff option controls are accessible via keyboard navigation -- **SC-005**: All existing tests continue to pass (no regressions) -- **SC-006**: 100% test coverage is maintained +- **SC-002**: All diff option controls are accessible via keyboard navigation +- **SC-003**: All existing tests continue to pass (no regressions) +- **SC-004**: 100% test coverage is maintained From b6e08045b9bd914bfc89fe04b4ec08125be541c5 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 8 Feb 2026 13:32:03 -0500 Subject: [PATCH 3/9] docs(specs): clarify button component --- specs/002-toggle-diff-options/spec.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/specs/002-toggle-diff-options/spec.md b/specs/002-toggle-diff-options/spec.md index e353edf..d819044 100644 --- a/specs/002-toggle-diff-options/spec.md +++ b/specs/002-toggle-diff-options/spec.md @@ -5,6 +5,12 @@ **Status**: Draft **Input**: User description: "toggle diff options" +## Clarifications + +### Session 2026-02-08 + +- Q: What UI pattern should the diff method control use? → A: Same segmented button group as the existing ViewToggle — 3 adjacent buttons (Characters | Words | Lines) matching the existing style, placed alongside it in the diff header. + ## User Scenarios & Testing _(mandatory)_ ### User Story 1 - Toggle Diff Method (Priority: P1) @@ -34,7 +40,7 @@ A user wants to switch between different diff comparison methods to get the most ### Functional Requirements -- **FR-001**: System MUST provide a control to select the diff comparison method from: characters, words (default), and lines +- **FR-001**: System MUST provide a segmented button group control (matching the existing view mode toggle pattern) to select the diff comparison method from: characters, words (default), and lines - **FR-002**: System MUST recompute the diff output immediately when the diff method selection changes - **FR-003**: System MUST default to word-level diff on page load (preserving current behavior) - **FR-004**: All diff option controls MUST be visible whenever the diff output is visible From ed6a54156099a5339940c4df920e6cf3e85a243f Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 8 Feb 2026 13:35:42 -0500 Subject: [PATCH 4/9] docs(specs): clarify state management --- specs/002-toggle-diff-options/spec.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/specs/002-toggle-diff-options/spec.md b/specs/002-toggle-diff-options/spec.md index d819044..b09343b 100644 --- a/specs/002-toggle-diff-options/spec.md +++ b/specs/002-toggle-diff-options/spec.md @@ -10,6 +10,8 @@ ### Session 2026-02-08 - Q: What UI pattern should the diff method control use? → A: Same segmented button group as the existing ViewToggle — 3 adjacent buttons (Characters | Words | Lines) matching the existing style, placed alongside it in the diff header. +- Q: Where is the diff method toggle placed relative to the view mode toggle? → A: Left side of diff header — diff method toggle replaces/sits next to the "Diff" label, view mode toggle stays on the right. +- Q: How is the diff method selection state handled? → A: Same as ViewToggle — `useState('words')` in App, passed as prop to the toggle component, App passes diffMethod to useDiff. ## User Scenarios & Testing _(mandatory)_ @@ -46,6 +48,7 @@ A user wants to switch between different diff comparison methods to get the most - **FR-004**: All diff option controls MUST be visible whenever the diff output is visible - **FR-005**: All diff option changes MUST take effect immediately without requiring any additional user action - **FR-006**: Diff option controls MUST be keyboard accessible +- **FR-007**: Diff method state MUST be managed in the App component via the same pattern as the existing view mode state (lifted state, passed as props) ### Key Entities From 0223cbbed0600892290d2b5e1207a8134037c4ae Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 8 Feb 2026 13:37:54 -0500 Subject: [PATCH 5/9] docs(specs): clarify persistence to localStorage --- specs/002-toggle-diff-options/spec.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/specs/002-toggle-diff-options/spec.md b/specs/002-toggle-diff-options/spec.md index b09343b..a1dd738 100644 --- a/specs/002-toggle-diff-options/spec.md +++ b/specs/002-toggle-diff-options/spec.md @@ -12,6 +12,7 @@ - Q: What UI pattern should the diff method control use? → A: Same segmented button group as the existing ViewToggle — 3 adjacent buttons (Characters | Words | Lines) matching the existing style, placed alongside it in the diff header. - Q: Where is the diff method toggle placed relative to the view mode toggle? → A: Left side of diff header — diff method toggle replaces/sits next to the "Diff" label, view mode toggle stays on the right. - Q: How is the diff method selection state handled? → A: Same as ViewToggle — `useState('words')` in App, passed as prop to the toggle component, App passes diffMethod to useDiff. +- Q: Should the user's diff method selection persist across page reloads? → A: Yes, persist to localStorage. Also persist the view mode (Unified/Side-by-Side) selection. ## User Scenarios & Testing _(mandatory)_ @@ -49,6 +50,8 @@ A user wants to switch between different diff comparison methods to get the most - **FR-005**: All diff option changes MUST take effect immediately without requiring any additional user action - **FR-006**: Diff option controls MUST be keyboard accessible - **FR-007**: Diff method state MUST be managed in the App component via the same pattern as the existing view mode state (lifted state, passed as props) +- **FR-008**: System MUST persist the selected diff method to localStorage and restore it on page load, falling back to "words" if no saved value exists +- **FR-009**: System MUST persist the selected view mode (unified/side-by-side) to localStorage and restore it on page load, falling back to "unified" if no saved value exists ### Key Entities From 1f0755d29d26fc06d45dbb8fe863b3df31375045 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 8 Feb 2026 13:41:17 -0500 Subject: [PATCH 6/9] docs(specs): clarify tooltips --- specs/002-toggle-diff-options/spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specs/002-toggle-diff-options/spec.md b/specs/002-toggle-diff-options/spec.md index a1dd738..0187b90 100644 --- a/specs/002-toggle-diff-options/spec.md +++ b/specs/002-toggle-diff-options/spec.md @@ -13,6 +13,7 @@ - Q: Where is the diff method toggle placed relative to the view mode toggle? → A: Left side of diff header — diff method toggle replaces/sits next to the "Diff" label, view mode toggle stays on the right. - Q: How is the diff method selection state handled? → A: Same as ViewToggle — `useState('words')` in App, passed as prop to the toggle component, App passes diffMethod to useDiff. - Q: Should the user's diff method selection persist across page reloads? → A: Yes, persist to localStorage. Also persist the view mode (Unified/Side-by-Side) selection. +- Q: Should the diff method buttons display tooltips? → A: No — the button labels "Characters", "Words", "Lines" are self-explanatory. Keep it simple for the MVP. ## User Scenarios & Testing _(mandatory)_ From 8da1b358aca8b5e253fb54b42e51ebcd0880f016 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 8 Feb 2026 13:48:04 -0500 Subject: [PATCH 7/9] docs(specs): create implementation plan --- .windsurf/rules/specify-rules.md | 5 ++ specs/002-toggle-diff-options/data-model.md | 68 ++++++++++++++++ specs/002-toggle-diff-options/plan.md | 72 +++++++++++++++++ specs/002-toggle-diff-options/quickstart.md | 87 +++++++++++++++++++++ specs/002-toggle-diff-options/research.md | 72 +++++++++++++++++ 5 files changed, 304 insertions(+) create mode 100644 specs/002-toggle-diff-options/data-model.md create mode 100644 specs/002-toggle-diff-options/plan.md create mode 100644 specs/002-toggle-diff-options/quickstart.md create mode 100644 specs/002-toggle-diff-options/research.md 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/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';