From 3deabbc06bf4500fb4a88bb922ab1fd1e6f0adb5 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 14:00:32 -0500
Subject: [PATCH 01/61] docs(specs): create spec for multiple words
---
.../checklists/requirements.md | 42 ++++++
specs/001-multiple-words/spec.md | 124 ++++++++++++++++++
2 files changed, 166 insertions(+)
create mode 100644 specs/001-multiple-words/checklists/requirements.md
create mode 100644 specs/001-multiple-words/spec.md
diff --git a/specs/001-multiple-words/checklists/requirements.md b/specs/001-multiple-words/checklists/requirements.md
new file mode 100644
index 0000000..9ec227d
--- /dev/null
+++ b/specs/001-multiple-words/checklists/requirements.md
@@ -0,0 +1,42 @@
+# Specification Quality Checklist: Multiple Words Display
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2025-02-15
+**Feature**: [Multiple Words Display](../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
+
+## Validation Results
+
+**Status**: ✅ PASSED - All checklist items completed successfully
+
+**Notes**:
+
+- Specification is complete and ready for planning phase
+- No clarification markers found
+- All requirements are testable and measurable
+- User stories are properly prioritized and independently testable
+- Edge cases are comprehensively identified
diff --git a/specs/001-multiple-words/spec.md b/specs/001-multiple-words/spec.md
new file mode 100644
index 0000000..f7a9a72
--- /dev/null
+++ b/specs/001-multiple-words/spec.md
@@ -0,0 +1,124 @@
+# Feature Specification: Multiple Words Display
+
+**Feature Branch**: `001-multiple-words`
+**Created**: 2025-02-15
+**Status**: Draft
+**Input**: User description: "multiple words"
+
+## User Scenarios & Testing _(mandatory)_
+
+
+
+### User Story 1 - Multiple Words Display (Priority: P1)
+
+As a speed reader, I want to see multiple words displayed simultaneously so that I can read faster by processing word chunks rather than individual words.
+
+**Why this priority**: This is the core functionality that enables users to read in chunks rather than single words, potentially increasing reading speed and comprehension for certain types of content.
+
+**Independent Test**: Can be fully tested by inputting text and verifying that multiple words appear in the display area instead of single words, with proper pacing maintained.
+
+**Acceptance Scenarios**:
+
+1. **Given** I have input text with multiple words, **When** I start a reading session with multiple words display enabled, **Then** I should see 2-3 words displayed simultaneously in the reading area
+2. **Given** I am reading with multiple words display, **When** the timer advances, **Then** the display should show the next chunk of words maintaining proper pacing
+3. **Given** I have text with punctuation, **When** words are grouped, **Then** punctuation should be preserved and grouped logically with adjacent words
+
+---
+
+### User Story 2 - Configurable Word Count (Priority: P2)
+
+As a speed reader, I want to configure how many words are displayed simultaneously so that I can optimize the display for my reading preference and content type.
+
+**Why this priority**: Allows users to customize their reading experience based on personal preference and text complexity, improving adoption and effectiveness.
+
+**Independent Test**: Can be tested by changing the word count setting and verifying the display shows the correct number of words per chunk.
+
+**Acceptance Scenarios**:
+
+1. **Given** I am in setup mode, **When** I select a word count of 2, **Then** the reading session should display exactly 2 words per chunk
+2. **Given** I select a word count of 4, **When** I start reading, **Then** each display chunk should contain up to 4 words
+3. **Given** I reach the end of the text, **When** fewer words remain than my selected count, **Then** the remaining words should be displayed together
+
+---
+
+### User Story 3 - Smart Word Grouping (Priority: P3)
+
+As a speed reader, I want words to be grouped intelligently based on natural language patterns so that I can maintain better comprehension and reading flow.
+
+**Why this priority**: Improves the reading experience by respecting natural language boundaries, making it easier to comprehend phrases rather than arbitrary word groupings.
+
+**Independent Test**: Can be tested by inputting text with various sentence structures and verifying that word breaks occur at logical points.
+
+**Acceptance Scenarios**:
+
+1. **Given** I have a sentence with a comma, **When** words are grouped, **Then** the grouping should prefer breaking at punctuation marks
+2. **Given** I have short function words (a, an, the, etc.), **When** possible, **Then** these should be grouped with adjacent content words
+3. **Given** I have long words, **When** grouping, **Then** very long individual words may count as their own chunk to maintain readability
+
+---
+
+### Edge Cases
+
+- What happens when the text contains very long words that exceed display width?
+- How does system handle punctuation at the beginning or end of word groups?
+- What happens when there are fewer words remaining than the configured group size?
+- How are line breaks and paragraphs handled in word grouping?
+- What happens with mixed content (numbers, symbols, abbreviations)?
+- How does the system handle extremely short text (less than the configured group size)?
+
+## Requirements _(mandatory)_
+
+
+
+### Constitution Alignment _(mandatory)_
+
+- **Comprehension Outcome**: Multiple words display must preserve or improve reading comprehension by grouping words in natural language chunks rather than arbitrary segments
+- **Deterministic Behavior**: Word grouping and timing must be reproducible - the same text with same settings should always produce identical word groups and timing
+- **Accessibility Coverage**: Multiple words display must support screen readers, keyboard navigation, and responsive design. Font sizing and spacing must accommodate various visual needs.
+
+### Functional Requirements
+
+- **FR-001**: System MUST allow users to enable multiple words display mode
+- **FR-002**: System MUST allow users to configure the number of words displayed per chunk (2-4 words)
+- **FR-003**: System MUST group words based on natural language boundaries when possible
+- **FR-004**: System MUST maintain consistent timing between word chunks based on WPM setting
+- **FR-005**: System MUST handle edge cases where remaining words are fewer than configured group size
+- **FR-006**: System MUST preserve punctuation and maintain logical word groupings
+- **FR-007**: Users MUST be able to switch between single word and multiple words display modes
+- **FR-008**: System MUST display word chunks in a readable format with appropriate spacing
+
+### Key Entities _(include if feature involves data)_
+
+- **WordChunk**: Represents a group of 1-4 words to be displayed together, contains the text content and timing information
+- **DisplaySettings**: Contains user preferences for words per chunk and grouping behavior
+- **TokenizedContent**: Extended to support word chunking in addition to individual word tokenization
+
+## Success Criteria _(mandatory)_
+
+
+
+### Measurable Outcomes
+
+- **SC-001**: Users can complete reading sessions 15-25% faster with multiple words display while maintaining 90%+ comprehension
+- **SC-002**: Users can configure word chunk settings in under 10 seconds without confusion
+- **SC-003**: 95% of users successfully switch between single word and multiple words modes without session interruption
+- **SC-004**: Word grouping algorithm maintains 90%+ user satisfaction for natural language breaks
+- **SC-005**: System handles all text edge cases without crashes or display errors
+- **SC-006**: Reading session timing remains accurate within 5% regardless of word chunk size
From 287a0ddeb89c97ff060d7707566fa743c06278de Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 14:04:22 -0500
Subject: [PATCH 02/61] docs(specs): clarify select options
---
specs/001-multiple-words/spec.md | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/specs/001-multiple-words/spec.md b/specs/001-multiple-words/spec.md
index f7a9a72..6b3f253 100644
--- a/specs/001-multiple-words/spec.md
+++ b/specs/001-multiple-words/spec.md
@@ -5,6 +5,12 @@
**Status**: Draft
**Input**: User description: "multiple words"
+## Clarifications
+
+### Session 2025-02-15
+
+- Q: What type of UI control should be used for selecting the word count per chunk? → A: Dropdown/select menu with numbered options, min 1 and max 5 words
+
## User Scenarios & Testing _(mandatory)_
+
+**Language/Version**: TypeScript 5 (React 19)
+**Primary Dependencies**: React 19, Vite 7, Vitest 4, Tailwind CSS 4
+**Storage**: localStorage for user preferences
+**Testing**: Vitest 4 with React Testing Library
+**Target Platform**: Web browser (responsive design)
+**Project Type**: Single-page web application
+**Performance Goals**: Maintain 60fps display updates, <100ms UI response time
+**Constraints**: Must preserve existing reading session timing accuracy, support screen readers
+**Scale/Scope**: Extends existing React components, no new infrastructure required
+
+## Constitution Check
+
+_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._
+
+- [x] Reader comprehension impact is defined and measurable for the feature.
+- [x] Deterministic behavior is specified for timing/state transitions and has regression guardrails.
+- [x] Accessibility requirements cover keyboard navigation, semantics, focus, and responsive parity.
+- [x] Test strategy includes regression coverage and required quality gates (`lint`, `lint:tsc`, `test:ci`).
+- [x] Scope is minimal and dependency changes are justified.
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/001-multiple-words/
+├── plan.md # This file (/speckit.plan command output)
+├── research.md # Phase 0 output (/speckit.plan command)
+├── data-model.md # Phase 1 output (/speckit.plan command)
+├── quickstart.md # Phase 1 output (/speckit.plan command)
+├── contracts/ # Phase 1 output (/speckit.plan command)
+└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
+```
+
+### Source Code (repository root)
+
+```text
+src/
+├── components/
+│ ├── App/
+│ │ ├── App.tsx
+│ │ ├── App.test.tsx
+│ │ ├── App.states.test.tsx
+│ │ └── useReadingSession.ts
+│ ├── ControlPanel/
+│ │ ├── ControlPanel.tsx
+│ │ └── ControlPanel.test.tsx
+│ ├── ReadingDisplay/
+│ │ ├── ReadingDisplay.tsx
+│ │ ├── ReadingDisplay.test.tsx
+│ │ └── ReadingDisplay.types.ts
+│ ├── SessionDetails/
+│ │ ├── SessionDetails.tsx
+│ │ └── SessionDetails.test.tsx
+│ └── TextInput/
+│ ├── TextInput.tsx
+│ ├── TextInput.test.tsx
+│ └── tokenizeContent.ts
+├── main.tsx
+└── main.test.tsx
+
+test/
+└── [test setup files]
+
+public/
+└── [static assets]
+```
+
+**Structure Decision**: Single React application extending existing components. The feature will modify ControlPanel, ReadingDisplay, SessionDetails, and TextInput components to support multiple words display while maintaining the existing application architecture.
+
+## Complexity Tracking
+
+> **Fill ONLY if Constitution Check has violations that must be justified**
+
+| Violation | Why Needed | Simpler Alternative Rejected Because |
+| -------------------------- | ------------------ | ------------------------------------ |
+| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
+| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
diff --git a/specs/001-multiple-words/research.md b/specs/001-multiple-words/research.md
new file mode 100644
index 0000000..26be080
--- /dev/null
+++ b/specs/001-multiple-words/research.md
@@ -0,0 +1,142 @@
+# Research: Multiple Words Display
+
+**Date**: 2025-02-15
+**Feature**: Multiple Words Display
+**Status**: Complete
+
+## Research Findings
+
+### Word Grouping Algorithm
+
+**Decision**: Implement intelligent word grouping based on natural language boundaries while respecting user-configurable chunk sizes.
+
+**Rationale**: The specification requires grouping words based on natural language boundaries when possible (FR-003) while allowing 1-5 words per chunk. Research shows that optimal chunking should prioritize punctuation breaks and avoid splitting common phrases.
+
+**Implementation Approach**:
+
+- Primary grouping at punctuation marks (commas, periods, semicolons)
+- Secondary grouping at clause boundaries (conjunctions, prepositions)
+- Fallback to word count when no natural boundaries exist
+- Preserve punctuation with preceding words for readability
+
+**Alternatives Considered**:
+
+- Fixed word count grouping only (rejected: poor readability)
+- NLP-based phrase detection (rejected: over-engineering for this use case)
+- User-defined grouping rules (rejected: too complex for MVP)
+
+### UI Component Integration
+
+**Decision**: Extend existing React components rather than creating new ones.
+
+**Rationale**: The current application has well-structured components (ControlPanel, ReadingDisplay, SessionDetails) that can be extended to support multiple words display without architectural changes.
+
+**Implementation Approach**:
+
+- Add "Word Count" dropdown to ControlPanel after WPM slider
+- Modify ReadingDisplay to handle multi-word chunks with text wrapping
+- Update SessionDetails to show "word" vs "chunk" terminology dynamically
+- Extend useReadingSession hook to manage word count state
+
+**Alternatives Considered**:
+
+- Create new MultiWordDisplay component (rejected: unnecessary duplication)
+- Separate control panel for multiple words (rejected: inconsistent UX)
+- Complete UI redesign (rejected: scope creep)
+
+### Timing and Progress Calculation
+
+**Decision**: Maintain existing WPM-based timing with consistent chunk duration regardless of word count.
+
+**Rationale**: Specification FR-004 requires same total time per chunk regardless of word count to maintain consistent reading pace. This preserves the user's expected WPM experience.
+
+**Implementation Approach**:
+
+- Calculate msPerChunk = (60000 / WPM) regardless of word count
+- Track progress by word position in original text array
+- Recalculate progress percentage when word count changes during session
+- Maintain deterministic timing for reproducible behavior
+
+**Alternatives Considered**:
+
+- Time proportional to word count (rejected: inconsistent with user WPM expectations)
+- Variable timing based on word length (rejected: breaks deterministic behavior requirement)
+
+### Data Storage and State Management
+
+**Decision**: Use localStorage for persistence with key "speedreader.wordCount".
+
+**Rationale**: Specification FR-010 requires localStorage persistence. The existing app appears to use React state management, so localStorage integration is straightforward.
+
+**Implementation Approach**:
+
+- Save word count to localStorage on change
+- Restore word count on component mount
+- Default to 1 word if no saved value exists
+- Handle localStorage errors gracefully
+
+**Alternatives Considered**:
+
+- SessionStorage only (rejected: doesn't persist across sessions)
+- IndexedDB (rejected: overkill for single preference value)
+- No persistence (rejected: violates FR-010)
+
+### Accessibility Implementation
+
+**Decision**: Ensure full keyboard navigation and screen reader support.
+
+**Rationale**: Constitution Principle III requires accessibility support. The dropdown control must be properly labeled and keyboard accessible.
+
+**Implementation Approach**:
+
+- Use semantic HTML select element with proper label
+- Ensure keyboard navigation (arrow keys, Enter, Escape)
+- Add ARIA labels for dynamic content changes
+- Test with screen readers for proper announcement
+
+**Alternatives Considered**:
+
+- Custom dropdown implementation (rejected: accessibility challenges)
+- No keyboard support (rejected: violates constitution)
+
+### Performance Considerations
+
+**Decision**: Optimize for 60fps display updates and <100ms UI response.
+
+**Rationale**: Technical context specifies performance goals of 60fps and <100ms response time.
+
+**Implementation Approach**:
+
+- Use React.memo for components to prevent unnecessary re-renders
+- Optimize word grouping algorithm to O(n) complexity
+- Debounce localStorage saves to prevent blocking UI
+- Use CSS text wrapping for overflow handling
+
+**Alternatives Considered**:
+
+- Complex caching systems (rejected: unnecessary overhead)
+- Web Workers for text processing (rejected: overkill for text sizes involved)
+
+## Technical Decisions Summary
+
+| Component | Decision | Rationale |
+| -------------- | ------------------------------------------------ | -------------------------------------- |
+| Word Grouping | Natural language boundaries + configurable count | Balances readability with user control |
+| UI Integration | Extend existing components | Maintains architectural consistency |
+| Timing | Fixed duration per chunk | Preserves WPM expectations |
+| Storage | localStorage with specific key | Meets FR-010 requirement |
+| Accessibility | Semantic HTML + ARIA | Constitution compliance |
+| Performance | React optimization + CSS wrapping | Meets performance goals |
+
+## Implementation Risks and Mitigations
+
+| Risk | Impact | Mitigation |
+| --------------------------------- | ------ | ---------------------------------------------------- |
+| Text wrapping affects readability | Medium | Test with various text lengths and screen sizes |
+| Progress calculation complexity | Medium | Thorough testing of edge cases and state transitions |
+| localStorage quota exceeded | Low | Graceful fallback to default settings |
+| Performance with large texts | Low | Implement efficient O(n) grouping algorithm |
+
+## Conclusion
+
+All research questions have been resolved with clear technical decisions that align with the specification requirements and constitution principles. The implementation approach leverages existing React patterns while adding the multiple words functionality in a maintainable way.
From b94d4f0448c4e24e942d91377dc4a4d7ad88944f Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 14:29:46 -0500
Subject: [PATCH 13/61] docs(specs): create contract
---
.../contracts/component-apis.md | 313 +++++++++++++++
specs/001-multiple-words/quickstart.md | 372 ++++++++++++++++++
2 files changed, 685 insertions(+)
create mode 100644 specs/001-multiple-words/contracts/component-apis.md
create mode 100644 specs/001-multiple-words/quickstart.md
diff --git a/specs/001-multiple-words/contracts/component-apis.md b/specs/001-multiple-words/contracts/component-apis.md
new file mode 100644
index 0000000..602ddf3
--- /dev/null
+++ b/specs/001-multiple-words/contracts/component-apis.md
@@ -0,0 +1,313 @@
+# Component API Contracts: Multiple Words Display
+
+**Date**: 2025-02-15
+**Feature**: Multiple Words Display
+
+## ControlPanel Component API
+
+### Props
+
+```typescript
+interface ControlPanelProps {
+ // Speed Control
+ selectedWpm: number;
+ onSpeedChange: (wpm: number) => void;
+
+ // Word Count Control (NEW)
+ wordCount: number;
+ onWordCountChange: (count: number) => void;
+
+ // Session Control
+ status: 'idle' | 'reading' | 'paused' | 'completed';
+ onStartReading: () => void;
+ onPauseReading: () => void;
+ onResumeReading: () => void;
+ onRestartReading: () => void;
+ onEditText: () => void;
+
+ // Validation
+ isInputValid: boolean;
+}
+```
+
+### Events
+
+```typescript
+// Word count change events
+onWordCountChange: (count: number) => void;
+// Constraints: count must be 1-5
+// Validation: Component should clamp invalid values
+```
+
+### Accessibility Requirements
+
+- Dropdown must have proper label: "Word Count"
+- Must support keyboard navigation (arrow keys, enter, escape)
+- Must announce selection changes to screen readers
+- Must maintain focus management
+
+## ReadingDisplay Component API
+
+### Props
+
+```typescript
+interface ReadingDisplayProps {
+ // Content Display (UPDATED)
+ currentChunk: WordChunk | null;
+ displaySettings: DisplaySettings;
+
+ // Legacy Support
+ currentWord: string; // Derived from currentChunk.text for single word mode
+ hasWords: boolean; // Derived from currentChunk !== null
+}
+```
+
+### WordChunk Structure
+
+```typescript
+interface WordChunk {
+ text: string; // Display text (joined words)
+ words: string[]; // Individual words
+ startIndex: number; // Position in original text
+ endIndex: number; // End position
+ hasPunctuation: boolean; // Contains punctuation
+}
+```
+
+### Display Behavior
+
+- **Single Word Mode** (wordCount = 1): Display as before
+- **Multiple Words Mode** (wordCount > 1): Display chunk text with wrapping
+- **Empty State**: Show placeholder when currentChunk is null
+- **Overflow**: Text wraps within fixed display area
+
+### Accessibility Requirements
+
+- Use `aria-live="polite"` and `aria-atomic="true"` for content changes
+- Announce chunk changes to screen readers
+- Maintain readable text contrast and sizing
+- Support text resizing
+
+## SessionDetails Component API
+
+### Props
+
+```typescript
+interface SessionDetailsProps {
+ // Progress Tracking (UPDATED)
+ wordsRead: number;
+ totalWords: number;
+ chunksRead: number;
+ totalChunks: number;
+ progressPercent: number;
+
+ // Timing
+ msPerWord: number; // Actually msPerChunk
+ elapsedMs: number;
+
+ // Display Settings (NEW)
+ displaySettings: DisplaySettings;
+}
+```
+
+### Display Logic
+
+```typescript
+// Terminology based on word count
+const unit = displaySettings.wordsPerChunk === 1 ? 'word' : 'chunk';
+const progressText = `${chunksRead} ${unit} · ${Math.round(progressPercent)}%`;
+```
+
+### Accessibility Requirements
+
+- Dynamic terminology updates must be announced
+- Progress information must be perceivable
+- Use semantic markup for progress display
+
+## useReadingSession Hook API
+
+### Return Value
+
+```typescript
+interface UseReadingSessionReturn {
+ // Existing State
+ currentWordIndex: number;
+ elapsedMs: number;
+ msPerWord: number;
+ progressPercent: number;
+ selectedWpm: number;
+ status: ReadingStatus;
+ totalWords: number;
+ wordsRead: number;
+
+ // New State
+ currentChunkIndex: number;
+ chunksRead: number;
+ totalChunks: number;
+ displaySettings: DisplaySettings;
+ currentChunk: WordChunk | null;
+
+ // Actions
+ editText: () => void;
+ pauseReading: () => void;
+ restartReading: () => void;
+ resumeReading: () => void;
+ setSelectedWpm: (wpm: number) => void;
+ startReading: (totalWords: number) => void;
+ setWordCount: (count: number) => void; // NEW
+}
+```
+
+### New Actions
+
+```typescript
+setWordCount: (count: number) => void;
+// Behavior: Updates displaySettings.wordsPerChunk
+// Side effects: Recalculates chunks, updates progress
+// Validation: Clamps count to 1-5 range
+// Persistence: Saves to localStorage
+```
+
+## TextInput Component API
+
+### Updated Behavior
+
+```typescript
+interface TextInputProps {
+ // Existing props unchanged
+ value: string;
+ onChange: (value: string) => void;
+ onSubmit: (text: string) => void;
+ isValid: boolean;
+ disabled?: boolean;
+}
+```
+
+### Tokenization Enhancement
+
+```typescript
+// Extended tokenizeContent function
+interface TokenizedContent {
+ words: string[];
+ totalWords: number;
+ chunks: WordChunk[]; // NEW
+ totalChunks: number; // NEW
+}
+```
+
+## Storage API
+
+### localStorage Interface
+
+```typescript
+interface StorageAPI {
+ getWordCount(): number;
+ setWordCount(count: number): void;
+ removeWordCount(): void;
+}
+```
+
+### Implementation
+
+```typescript
+const WORD_COUNT_KEY = 'speedreader.wordCount';
+
+export const storageAPI: StorageAPI = {
+ getWordCount(): number {
+ try {
+ const value = localStorage.getItem(WORD_COUNT_KEY);
+ return value ? Math.max(1, Math.min(5, parseInt(value, 10))) : 1;
+ } catch {
+ return 1; // Default on error
+ }
+ },
+
+ setWordCount(count: number): void {
+ try {
+ const clampedCount = Math.max(1, Math.min(5, count));
+ localStorage.setItem(WORD_COUNT_KEY, clampedCount.toString());
+ } catch {
+ // Silently fail on quota exceeded
+ }
+ },
+
+ removeWordCount(): void {
+ try {
+ localStorage.removeItem(WORD_COUNT_KEY);
+ } catch {
+ // Silently fail
+ }
+ },
+};
+```
+
+## Error Handling Contracts
+
+### Validation Rules
+
+```typescript
+interface ValidationRules {
+ wordCount: {
+ min: 1;
+ max: 5;
+ default: 1;
+ };
+ textContent: {
+ minLength: 1;
+ maxLength: 1000000; // 1MB text limit
+ };
+ timing: {
+ minWpm: 50;
+ maxWpm: 1000;
+ };
+}
+```
+
+### Error Types
+
+```typescript
+interface AppError {
+ code: 'INVALID_WORD_COUNT' | 'STORAGE_ERROR' | 'TEXT_TOO_LARGE';
+ message: string;
+ recoverable: boolean;
+ defaultValue?: any;
+}
+```
+
+## Performance Contracts
+
+### Response Time Requirements
+
+- **UI Updates**: <100ms from user action to visual feedback
+- **Text Processing**: <50ms for typical article length (1000 words)
+- **Storage Operations**: <10ms for localStorage access
+- **Progress Calculation**: <5ms for recalculation
+
+### Memory Constraints
+
+- **Chunk Data**: <1MB for typical articles
+- **Component State**: <100KB per component
+- **Total Feature Overhead**: <5MB additional memory
+
+## Testing Contracts
+
+### Unit Test Requirements
+
+- All component props variations
+- Edge cases (empty text, single word, very long words)
+- Error conditions (localStorage unavailable, invalid inputs)
+- Accessibility behavior (keyboard navigation, screen readers)
+
+### Integration Test Requirements
+
+- End-to-end reading sessions with word count changes
+- Progress recalculation accuracy
+- Settings persistence across sessions
+- Cross-browser compatibility
+
+### Performance Test Requirements
+
+- Large text processing (10,000+ words)
+- Rapid word count changes
+- Memory usage monitoring
+- Frame rate stability during reading
diff --git a/specs/001-multiple-words/quickstart.md b/specs/001-multiple-words/quickstart.md
new file mode 100644
index 0000000..7985dc0
--- /dev/null
+++ b/specs/001-multiple-words/quickstart.md
@@ -0,0 +1,372 @@
+# Quickstart Guide: Multiple Words Display
+
+**Date**: 2025-02-15
+**Feature**: Multiple Words Display
+
+## Overview
+
+This guide helps developers understand and implement the Multiple Words Display feature for the Speed Reader application. The feature allows users to read 1-5 words simultaneously instead of single words, with intelligent grouping and consistent timing.
+
+## Key Concepts
+
+### Word Chunks
+
+- **Chunk**: Group of 1-5 words displayed together
+- **Natural Grouping**: Prioritizes punctuation and phrase boundaries
+- **Consistent Timing**: Same duration per chunk regardless of word count
+
+### Display Modes
+
+- **Single Word Mode** (word count = 1): Traditional speed reading
+- **Multiple Words Mode** (word count = 2-5): Chunk-based reading
+
+## Implementation Steps
+
+### 1. Extend ControlPanel Component
+
+```typescript
+// src/components/ControlPanel/ControlPanel.tsx
+import { storageAPI } from 'src/utils/storage';
+
+export function ControlPanel({
+ wordCount,
+ onWordCountChange,
+ // ... other props
+}: ControlPanelProps) {
+ const handleWordCountChange = (count: number) => {
+ const clampedCount = Math.max(1, Math.min(5, count));
+ onWordCountChange(clampedCount);
+ storageAPI.setWordCount(clampedCount);
+ };
+
+ return (
+
+ Select number of words to display simultaneously
+
+```
+
+### Screen Reader Support
+
+```typescript
+// Announce changes to screen readers
+useEffect(() => {
+ if (previousWordCount !== wordCount) {
+ const announcement = `Word count changed to ${wordCount}`;
+ // Use live region for announcement
+ }
+}, [wordCount, previousWordCount]);
+```
+
+## Performance Optimizations
+
+### Memoization
+
+```typescript
+// Memoize expensive operations
+const chunks = useMemo(() => {
+ return generateWordChunks(words, wordCount);
+}, [words, wordCount]);
+
+const displaySettings = useMemo(
+ () => ({
+ wordsPerChunk: wordCount,
+ isMultipleWordsMode: wordCount > 1,
+ }),
+ [wordCount],
+);
+```
+
+### Debounced Storage
+
+```typescript
+// Debounce localStorage writes
+const debouncedSaveWordCount = useMemo(
+ () => debounce(storageAPI.setWordCount, 300),
+ [],
+);
+```
+
+## Common Issues and Solutions
+
+### Issue: Progress calculation incorrect after word count change
+
+**Solution**: Recalculate progress based on current word position in original text, not chunk index.
+
+### Issue: Text wrapping affects readability
+
+**Solution**: Use CSS `text-wrap: balance` and appropriate line-height for multi-word chunks.
+
+### Issue: localStorage quota exceeded
+
+**Solution**: Wrap localStorage operations in try-catch and gracefully fallback to defaults.
+
+## Debugging Tips
+
+1. **Check chunk generation**: Log generated chunks to verify grouping logic
+2. **Verify timing**: Ensure msPerChunk remains constant regardless of word count
+3. **Test edge cases**: Single word, very long words, punctuation-heavy text
+4. **Monitor performance**: Use React DevTools to check for unnecessary re-renders
+
+## Next Steps
+
+1. Run full test suite: `npm run test:ci`
+2. Check accessibility with screen reader
+3. Test cross-browser compatibility
+4. Verify localStorage persistence
+5. Performance testing with large texts
From b51489e48da22536cc490eed7804c2ce2adfe40c Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 14:30:24 -0500
Subject: [PATCH 14/61] docs(windsurf): update agent context
---
.windsurf/rules/specify-rules.md | 5 +++++
specs/001-multiple-words/plan.md | 2 ++
2 files changed, 7 insertions(+)
diff --git a/.windsurf/rules/specify-rules.md b/.windsurf/rules/specify-rules.md
index b6eede7..a134e2a 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-14
## Active Technologies
+- TypeScript 5 (React 19) + React 19, Vite 7, Vitest 4, Tailwind CSS 4 (001-multiple-words)
+- localStorage for user preferences (001-multiple-words)
+
- TypeScript 5 (strict mode) with React 19 + React 19, Vite 7, Vitest 4, Tailwind CSS 4 (001-component-refactor)
- N/A (client-side state management) (001-component-refactor)
@@ -27,6 +30,8 @@ TypeScript 5 (strict) with React 19: Follow standard conventions
## Recent Changes
+- 001-multiple-words: Added TypeScript 5 (React 19) + React 19, Vite 7, Vitest 4, Tailwind CSS 4
+
- 001-component-refactor: Added TypeScript 5 (strict mode) with React 19 + React 19, Vite 7, Vitest 4, Tailwind CSS 4
- 001-speed-reading-app: Added TypeScript 5 (strict) with React 19 + React 19, React DOM 19, Vite 7, Tailwind CSS 4
diff --git a/specs/001-multiple-words/plan.md b/specs/001-multiple-words/plan.md
index f0eef1d..884f033 100644
--- a/specs/001-multiple-words/plan.md
+++ b/specs/001-multiple-words/plan.md
@@ -37,6 +37,8 @@ _GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._
- [x] Test strategy includes regression coverage and required quality gates (`lint`, `lint:tsc`, `test:ci`).
- [x] Scope is minimal and dependency changes are justified.
+**Post-Design Verification**: ✅ All constitution requirements satisfied. Design maintains existing architecture while adding multiple words functionality with proper accessibility, testing, and performance considerations.
+
## Project Structure
### Documentation (this feature)
From 658683dc927f603d22bb6a1ed249bc16a06ce716 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 14:31:57 -0500
Subject: [PATCH 15/61] docs(specs): generate tasks
---
specs/001-multiple-words/tasks.md | 228 ++++++++++++++++++++++++++++++
1 file changed, 228 insertions(+)
create mode 100644 specs/001-multiple-words/tasks.md
diff --git a/specs/001-multiple-words/tasks.md b/specs/001-multiple-words/tasks.md
new file mode 100644
index 0000000..da98606
--- /dev/null
+++ b/specs/001-multiple-words/tasks.md
@@ -0,0 +1,228 @@
+# Implementation Tasks: Multiple Words Display
+
+**Branch**: `001-multiple-words` | **Date**: 2025-02-15
+**Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
+
+## Summary
+
+This feature extends the speed reader application to display multiple words simultaneously instead of single words. The implementation adds a dropdown control for word count selection (1-5 words), with intelligent word grouping, consistent timing, and proper progress tracking.
+
+**Total Tasks**: 32
+**Tasks per User Story**: US1 (12), US2 (8), US3 (6)
+**Parallel Opportunities**: 15 tasks can be executed in parallel
+
+## Phase 1: Setup
+
+### Project Initialization
+
+- [ ] T001 Create feature branch `001-multiple-words` from main
+- [ ] T002 Verify development environment setup (Node.js 24, npm, React 19)
+- [ ] T003 Run existing test suite to ensure baseline functionality: `npm run test:ci`
+- [ ] T004 Review existing component structure in src/components/
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+### Core Types and Utilities
+
+- [ ] T005 Create WordChunk interface in src/components/ReadingDisplay/WordChunk.types.ts
+- [ ] T006 Create DisplaySettings interface in src/components/ControlPanel/DisplaySettings.types.ts
+- [ ] T007 Extend TokenizedContent interface in src/components/TextInput/TokenizedContent.types.ts
+- [ ] T008 Create localStorage utility in src/utils/storage.ts
+- [ ] T009 Create word chunking utility in src/utils/wordChunking.ts
+
+### Core Algorithms
+
+- [ ] T010 Implement generateWordChunks function in src/utils/wordChunking.ts
+- [ ] T011 Implement punctuation detection helpers in src/utils/wordChunking.ts
+- [ ] T012 Implement progress calculation utilities in src/utils/progress.ts
+
+## Phase 3: User Story 1 - Multiple Words Display (P1)
+
+**Goal**: Display multiple words simultaneously instead of single words
+**Independent Test**: Input text and verify multiple words appear with proper pacing
+**Acceptance Criteria**: 2-3 words displayed simultaneously, proper pacing, punctuation preserved
+
+### Models and Data Layer
+
+- [ ] T013 [US1] Implement WordChunk entity validation in src/utils/wordChunking.ts
+- [ ] T014 [US1] Implement DisplaySettings entity in src/components/ControlPanel/DisplaySettings.ts
+- [ ] T015 [P] [US1] Extend TokenizedContent with chunking in src/components/TextInput/tokenizeContent.ts
+
+### Services and Business Logic
+
+- [ ] T016 [US1] Extend useReadingSession hook with chunk state in src/components/App/useReadingSession.ts
+- [ ] T017 [US1] Implement chunk generation logic in useReadingSession hook
+- [ ] T018 [US1] Implement timing logic for chunks (same duration per chunk) in useReadingSession hook
+
+### UI Components
+
+- [ ] T019 [US1] Update ReadingDisplay component for multi-word support in src/components/ReadingDisplay/ReadingDisplay.tsx
+- [ ] T020 [US1] Add text wrapping styles for multiple words in ReadingDisplay component
+- [ ] T021 [P] [US1] Update ReadingDisplay types in src/components/ReadingDisplay/ReadingDisplay.types.ts
+
+### Integration
+
+- [ ] T022 [US1] Integrate chunk display in App component in src/components/App/App.tsx
+- [ ] T023 [US1] Update SessionDetails for chunk terminology in src/components/SessionDetails/SessionDetails.tsx
+- [ ] T024 [US1] Test end-to-end multiple words reading flow
+
+## Phase 4: User Story 2 - Configurable Word Count (P2)
+
+**Goal**: Allow users to configure how many words are displayed simultaneously
+**Independent Test**: Change word count setting and verify correct number of words per chunk
+**Acceptance Criteria**: Word count selection (2-4 words), correct chunk sizes, edge case handling
+
+### UI Controls
+
+- [ ] T025 [P] [US2] Add Word Count dropdown to ControlPanel in src/components/ControlPanel/ControlPanel.tsx
+- [ ] T026 [US2] Implement word count change handler in ControlPanel component
+- [ ] T027 [US2] Add localStorage persistence for word count in ControlPanel component
+
+### State Management
+
+- [ ] T028 [US2] Extend useReadingSession hook with word count state in src/components/App/useReadingSession.ts
+- [ ] T029 [US2] Implement word count change handler with progress recalculation in useReadingSession hook
+- [ ] T030 [US2] Add word count validation (1-5 range) in useReadingSession hook
+
+### Integration
+
+- [ ] T031 [US2] Connect ControlPanel word count to useReadingSession in App component
+- [ ] T032 [US2] Test word count configuration and display updates
+
+## Phase 5: User Story 3 - Smart Word Grouping (P3)
+
+**Goal**: Group words intelligently based on natural language patterns
+**Independent Test**: Input text with various structures and verify logical word breaks
+**Acceptance Criteria**: Prefer punctuation breaks, group function words, handle long words
+
+### Algorithm Enhancement
+
+- [ ] T033 [P] [US3] Implement punctuation-based grouping in src/utils/wordChunking.ts
+- [ ] T034 [P] [US3] Implement function word grouping logic in src/utils/wordChunking.ts
+- [ ] T035 [US3] Implement long word handling in src/utils/wordChunking.ts
+
+### Integration and Testing
+
+- [ ] T036 [US3] Update chunk generation to use smart grouping in useReadingSession hook
+- [ ] T037 [US3] Test smart word grouping with various text patterns
+- [ ] T038 [US3] Verify grouping preserves readability and comprehension
+
+## Phase 6: Polish & Cross-Cutting Concerns
+
+### Accessibility
+
+- [ ] T039 [P] Add proper ARIA labels to Word Count dropdown in ControlPanel component
+- [ ] T040 [P] Implement keyboard navigation for Word Count dropdown
+- [ ] T041 [P] Add screen reader announcements for word count changes
+- [ ] T042 [P] Test accessibility with screen reader tools
+
+### Performance and Error Handling
+
+- [ ] T043 [P] Optimize chunk generation with memoization in useReadingSession hook
+- [ ] T044 [P] Implement error handling for localStorage failures
+- [ ] T045 [P] Add debounced localStorage saves for word count
+- [ ] T046 [P] Test performance with large texts (10,000+ words)
+
+### Testing and Quality
+
+- [ ] T047 [P] Add unit tests for word chunking utilities in test/utils/wordChunking.test.ts
+- [ ] T048 [P] Add unit tests for localStorage utilities in test/utils/storage.test.ts
+- [ ] T049 [P] Add component tests for ControlPanel word count functionality
+- [ ] T050 [P] Add integration tests for complete multiple words flow
+
+### Documentation and Cleanup
+
+- [ ] T051 [P] Update component documentation and TypeScript comments
+- [ ] T052 [P] Run final test suite: `npm run test:ci`
+- [ ] T053 [P] Run linting and type checking: `npm run lint`, `npm run lint:tsc`
+- [ ] T054 [P] Verify feature meets all acceptance criteria
+
+## Dependencies
+
+### User Story Dependencies
+
+```
+US1 (Multiple Words Display) - No dependencies
+US2 (Configurable Word Count) - Depends on US1
+US3 (Smart Word Grouping) - Depends on US1
+```
+
+### Phase Dependencies
+
+```
+Phase 1 (Setup) → Phase 2 (Foundational) → Phase 3 (US1) → Phase 4 (US2) & Phase 5 (US3) → Phase 6 (Polish)
+```
+
+## Parallel Execution Examples
+
+### Within User Story 1
+
+```bash
+# Parallel tasks (can run simultaneously)
+T013 & T014 & T015 & T019 & T021 # Models, types, and UI components
+T016 & T017 & T018 # Service layer
+T022 & T023 # Integration
+```
+
+### Within User Story 2
+
+```bash
+# Parallel tasks
+T025 & T028 & T031 # UI controls and state management
+T026 & T029 & T030 # Event handlers and validation
+T027 & T032 # Persistence and testing
+```
+
+### Within User Story 3
+
+```bash
+# Parallel tasks
+T033 & T034 & T035 # Algorithm enhancements
+T036 & T037 & T038 # Integration and testing
+```
+
+### Polish Phase
+
+```bash
+# Most polish tasks can run in parallel
+T039 & T040 & T041 & T042 & T043 & T044 & T045 & T046 & T047 & T048 & T049 & T050
+```
+
+## Implementation Strategy
+
+### MVP Scope (User Story 1 Only)
+
+Implement basic multiple words display with fixed 2-3 word chunks to validate core functionality before adding configurability.
+
+### Incremental Delivery
+
+1. **Week 1**: Complete Phase 1-3 (Setup, Foundational, US1)
+2. **Week 2**: Complete Phase 4 (US2) and begin Phase 5 (US3)
+3. **Week 3**: Complete Phase 5-6 (US3, Polish, Testing)
+
+### Risk Mitigation
+
+- Test chunk generation algorithm thoroughly with various text patterns
+- Verify timing accuracy doesn't degrade with multiple words
+- Ensure accessibility from the start, not as an afterthought
+- Monitor performance impact on existing single-word mode
+
+## Quality Gates
+
+### Before Merge
+
+- [ ] All tests pass: `npm run test:ci`
+- [ ] No linting errors: `npm run lint`
+- [ ] No TypeScript errors: `npm run lint:tsc`
+- [ ] Manual testing of all user stories completed
+- [ ] Accessibility testing with screen reader completed
+- [ ] Performance testing with large texts completed
+
+### Definition of Done
+
+- [ ] All acceptance criteria for implemented user stories met
+- [ ] Code follows project style guidelines
+- [ ] Components are properly documented
+- [ ] Tests provide adequate coverage
+- [ ] Feature works across supported browsers
+- [ ] No regressions in existing functionality
From 6733aba7df9331c153b00f0f6c76c31eeea49446 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 14:35:44 -0500
Subject: [PATCH 16/61] docs(specs): remediate issues
---
specs/001-multiple-words/spec.md | 16 +++-------------
specs/001-multiple-words/tasks.md | 6 +++---
2 files changed, 6 insertions(+), 16 deletions(-)
diff --git a/specs/001-multiple-words/spec.md b/specs/001-multiple-words/spec.md
index d422ef4..b2fa892 100644
--- a/specs/001-multiple-words/spec.md
+++ b/specs/001-multiple-words/spec.md
@@ -13,7 +13,7 @@
- Q: What should be the default word count when users first enable multiple words display? → A: 1 word (same as current single-word mode)
- Q: How should timing be calculated when displaying multiple words per chunk? → A: Same total time per chunk regardless of word count
- Q: Where should the word count dropdown be positioned relative to the WPM slider? → A: After the WPM slider (WPM first, then word count)
-- Q: How should users toggle between single word and multiple words display modes? → A: No toggle mode
+- Q: How should users toggle between single word and multiple words display modes? → A: No separate modes - unified display controlled by word count dropdown (1-5 words)
- Q: What label text should be displayed for the word count dropdown? → A: "Word Count"
- Q: How should text wrapping be implemented for overflowing word chunks? → A: Wrap text within fixed display area (multi-line)
- Q: Should the word count selection be saved and restored between sessions? → A: Yes, save to localStorage with key "speedreader.wordCount"
@@ -94,11 +94,6 @@ As a speed reader, I want words to be grouped intelligently based on natural lan
## Requirements _(mandatory)_
-
-
### Constitution Alignment _(mandatory)_
- **Comprehension Outcome**: Multiple words display must preserve or improve reading comprehension by grouping words in natural language chunks rather than arbitrary segments
@@ -107,8 +102,8 @@ As a speed reader, I want words to be grouped intelligently based on natural lan
### Functional Requirements
-- **FR-001**: System MUST allow users to select word count from dropdown (1-5 words) where 1 word represents single word mode and 2+ words represents multiple words display
-- **FR-002**: System MUST allow users to configure the number of words displayed per chunk (1-5 words) via dropdown/select menu labeled "Word Count" positioned after the WPM slider
+- **FR-001**: System MUST provide a unified display mode where users can select word count (1-5 words) via dropdown to control how many words appear simultaneously
+- **FR-002**: System MUST provide a dropdown/select menu labeled "Word Count" positioned after the WPM slider for configuring words per chunk (1-5 words)
- **FR-009**: System MUST default to 1 word per chunk when multiple words display is first enabled
- **FR-010**: System MUST save word count selection to localStorage with key "speedreader.wordCount" and restore on page load
- **FR-011**: System MUST display "word" terminology in Session Details when word count is 1, and "chunk" terminology when word count is >1
@@ -127,11 +122,6 @@ As a speed reader, I want words to be grouped intelligently based on natural lan
## Success Criteria _(mandatory)_
-
-
### Measurable Outcomes
- **SC-001**: Users can complete reading sessions 15-25% faster with multiple words display while maintaining 90%+ comprehension
diff --git a/specs/001-multiple-words/tasks.md b/specs/001-multiple-words/tasks.md
index da98606..eba4d18 100644
--- a/specs/001-multiple-words/tasks.md
+++ b/specs/001-multiple-words/tasks.md
@@ -7,9 +7,9 @@
This feature extends the speed reader application to display multiple words simultaneously instead of single words. The implementation adds a dropdown control for word count selection (1-5 words), with intelligent word grouping, consistent timing, and proper progress tracking.
-**Total Tasks**: 32
-**Tasks per User Story**: US1 (12), US2 (8), US3 (6)
-**Parallel Opportunities**: 15 tasks can be executed in parallel
+**Total Tasks**: 54
+**Tasks per User Story**: US1 (12), US2 (8), US3 (6), Setup/Polish (28)
+**Parallel Opportunities**: 15+ tasks can be executed in parallel
## Phase 1: Setup
From 6de32e9eab8c04f6e35a9ac4095598752c56d89e Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 14:38:35 -0500
Subject: [PATCH 17/61] docs(specs): remediate remaining issues
---
specs/001-multiple-words/spec.md | 4 ++--
specs/001-multiple-words/tasks.md | 21 +++++++++++----------
2 files changed, 13 insertions(+), 12 deletions(-)
diff --git a/specs/001-multiple-words/spec.md b/specs/001-multiple-words/spec.md
index b2fa892..e3ce82f 100644
--- a/specs/001-multiple-words/spec.md
+++ b/specs/001-multiple-words/spec.md
@@ -104,7 +104,7 @@ As a speed reader, I want words to be grouped intelligently based on natural lan
- **FR-001**: System MUST provide a unified display mode where users can select word count (1-5 words) via dropdown to control how many words appear simultaneously
- **FR-002**: System MUST provide a dropdown/select menu labeled "Word Count" positioned after the WPM slider for configuring words per chunk (1-5 words)
-- **FR-009**: System MUST default to 1 word per chunk when multiple words display is first enabled
+- **FR-009**: System MUST default to 1 word per chunk when display is first enabled
- **FR-010**: System MUST save word count selection to localStorage with key "speedreader.wordCount" and restore on page load
- **FR-011**: System MUST display "word" terminology in Session Details when word count is 1, and "chunk" terminology when word count is >1
- **FR-012**: System MUST recalculate progress based on current position in text when word count changes during a session
@@ -126,7 +126,7 @@ As a speed reader, I want words to be grouped intelligently based on natural lan
- **SC-001**: Users can complete reading sessions 15-25% faster with multiple words display while maintaining 90%+ comprehension
- **SC-002**: Users can configure word chunk settings in under 10 seconds without confusion
-- **SC-003**: 95% of users successfully switch between single word and multiple words modes without session interruption
+- **SC-003**: 95% of users successfully adjust word count settings without session interruption
- **SC-004**: Word grouping algorithm maintains 90%+ user satisfaction for natural language breaks
- **SC-005**: System handles all text edge cases without crashes or display errors
- **SC-006**: Reading session timing remains accurate within 5% regardless of word chunk size
diff --git a/specs/001-multiple-words/tasks.md b/specs/001-multiple-words/tasks.md
index eba4d18..9230a0a 100644
--- a/specs/001-multiple-words/tasks.md
+++ b/specs/001-multiple-words/tasks.md
@@ -7,8 +7,8 @@
This feature extends the speed reader application to display multiple words simultaneously instead of single words. The implementation adds a dropdown control for word count selection (1-5 words), with intelligent word grouping, consistent timing, and proper progress tracking.
-**Total Tasks**: 54
-**Tasks per User Story**: US1 (12), US2 (8), US3 (6), Setup/Polish (28)
+**Total Tasks**: 55
+**Tasks per User Story**: US1 (12), US2 (9), US3 (6), Setup/Polish (28)
**Parallel Opportunities**: 15+ tasks can be executed in parallel
## Phase 1: Setup
@@ -75,19 +75,20 @@ This feature extends the speed reader application to display multiple words simu
### UI Controls
- [ ] T025 [P] [US2] Add Word Count dropdown to ControlPanel in src/components/ControlPanel/ControlPanel.tsx
-- [ ] T026 [US2] Implement word count change handler in ControlPanel component
-- [ ] T027 [US2] Add localStorage persistence for word count in ControlPanel component
+- [ ] T026 [US2] Position Word Count dropdown after WPM slider in ControlPanel component
+- [ ] T027 [US2] Implement word count change handler in ControlPanel component
+- [ ] T028 [US2] Add localStorage persistence for word count in ControlPanel component
### State Management
-- [ ] T028 [US2] Extend useReadingSession hook with word count state in src/components/App/useReadingSession.ts
-- [ ] T029 [US2] Implement word count change handler with progress recalculation in useReadingSession hook
-- [ ] T030 [US2] Add word count validation (1-5 range) in useReadingSession hook
+- [ ] T029 [US2] Extend useReadingSession hook with word count state in src/components/App/useReadingSession.ts
+- [ ] T030 [US2] Implement word count change handler with progress recalculation in useReadingSession hook
+- [ ] T031 [US2] Add word count validation (1-5 range) in useReadingSession hook
### Integration
-- [ ] T031 [US2] Connect ControlPanel word count to useReadingSession in App component
-- [ ] T032 [US2] Test word count configuration and display updates
+- [ ] T032 [US2] Connect ControlPanel word count to useReadingSession in App component
+- [ ] T033 [US2] Test word count configuration and display updates
## Phase 5: User Story 3 - Smart Word Grouping (P3)
@@ -99,7 +100,7 @@ This feature extends the speed reader application to display multiple words simu
- [ ] T033 [P] [US3] Implement punctuation-based grouping in src/utils/wordChunking.ts
- [ ] T034 [P] [US3] Implement function word grouping logic in src/utils/wordChunking.ts
-- [ ] T035 [US3] Implement long word handling in src/utils/wordChunking.ts
+- [ ] T035 [P] [US3] Implement long word handling in src/utils/wordChunking.ts
### Integration and Testing
From 2d20a0568880ba42ba563cda6a12ea5c10a77798 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 14:39:58 -0500
Subject: [PATCH 18/61] docs(specs): fix task ordering
---
specs/001-multiple-words/tasks.md | 46 +++++++++++++++----------------
1 file changed, 23 insertions(+), 23 deletions(-)
diff --git a/specs/001-multiple-words/tasks.md b/specs/001-multiple-words/tasks.md
index 9230a0a..2aae3af 100644
--- a/specs/001-multiple-words/tasks.md
+++ b/specs/001-multiple-words/tasks.md
@@ -9,7 +9,7 @@ This feature extends the speed reader application to display multiple words simu
**Total Tasks**: 55
**Tasks per User Story**: US1 (12), US2 (9), US3 (6), Setup/Polish (28)
-**Parallel Opportunities**: 15+ tasks can be executed in parallel
+**Parallel Opportunities**: 16+ tasks can be executed in parallel
## Phase 1: Setup
@@ -98,45 +98,45 @@ This feature extends the speed reader application to display multiple words simu
### Algorithm Enhancement
-- [ ] T033 [P] [US3] Implement punctuation-based grouping in src/utils/wordChunking.ts
-- [ ] T034 [P] [US3] Implement function word grouping logic in src/utils/wordChunking.ts
-- [ ] T035 [P] [US3] Implement long word handling in src/utils/wordChunking.ts
+- [ ] T034 [P] [US3] Implement punctuation-based grouping in src/utils/wordChunking.ts
+- [ ] T035 [P] [US3] Implement function word grouping logic in src/utils/wordChunking.ts
+- [ ] T036 [P] [US3] Implement long word handling in src/utils/wordChunking.ts
### Integration and Testing
-- [ ] T036 [US3] Update chunk generation to use smart grouping in useReadingSession hook
-- [ ] T037 [US3] Test smart word grouping with various text patterns
-- [ ] T038 [US3] Verify grouping preserves readability and comprehension
+- [ ] T037 [US3] Update chunk generation to use smart grouping in useReadingSession hook
+- [ ] T038 [US3] Test smart word grouping with various text patterns
+- [ ] T039 [US3] Verify grouping preserves readability and comprehension
## Phase 6: Polish & Cross-Cutting Concerns
### Accessibility
-- [ ] T039 [P] Add proper ARIA labels to Word Count dropdown in ControlPanel component
-- [ ] T040 [P] Implement keyboard navigation for Word Count dropdown
-- [ ] T041 [P] Add screen reader announcements for word count changes
-- [ ] T042 [P] Test accessibility with screen reader tools
+- [ ] T040 [P] Add proper ARIA labels to Word Count dropdown in ControlPanel component
+- [ ] T041 [P] Implement keyboard navigation for Word Count dropdown
+- [ ] T042 [P] Add screen reader announcements for word count changes
+- [ ] T043 [P] Test accessibility with screen reader tools
### Performance and Error Handling
-- [ ] T043 [P] Optimize chunk generation with memoization in useReadingSession hook
-- [ ] T044 [P] Implement error handling for localStorage failures
-- [ ] T045 [P] Add debounced localStorage saves for word count
-- [ ] T046 [P] Test performance with large texts (10,000+ words)
+- [ ] T044 [P] Optimize chunk generation with memoization in useReadingSession hook
+- [ ] T045 [P] Implement error handling for localStorage failures
+- [ ] T046 [P] Add debounced localStorage saves for word count
+- [ ] T047 [P] Test performance with large texts (10,000+ words)
### Testing and Quality
-- [ ] T047 [P] Add unit tests for word chunking utilities in test/utils/wordChunking.test.ts
-- [ ] T048 [P] Add unit tests for localStorage utilities in test/utils/storage.test.ts
-- [ ] T049 [P] Add component tests for ControlPanel word count functionality
-- [ ] T050 [P] Add integration tests for complete multiple words flow
+- [ ] T048 [P] Add unit tests for word chunking utilities in test/utils/wordChunking.test.ts
+- [ ] T049 [P] Add unit tests for localStorage utilities in test/utils/storage.test.ts
+- [ ] T050 [P] Add component tests for ControlPanel word count functionality
+- [ ] T051 [P] Add integration tests for complete multiple words flow
### Documentation and Cleanup
-- [ ] T051 [P] Update component documentation and TypeScript comments
-- [ ] T052 [P] Run final test suite: `npm run test:ci`
-- [ ] T053 [P] Run linting and type checking: `npm run lint`, `npm run lint:tsc`
-- [ ] T054 [P] Verify feature meets all acceptance criteria
+- [ ] T052 [P] Update component documentation and TypeScript comments
+- [ ] T053 [P] Run final test suite: `npm run test:ci`
+- [ ] T054 [P] Run linting and type checking: `npm run lint`, `npm run lint:tsc`
+- [ ] T055 [P] Verify feature meets all acceptance criteria
## Dependencies
From 82d6ca42b458d20fdfedf7cb21d288b243a4d4e1 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 15:02:06 -0500
Subject: [PATCH 19/61] chore: complete initial phases
---
specs/001-multiple-words/tasks.md | 38 ++--
.../ControlPanel/DisplaySettings.ts | 137 ++++++++++++
.../ControlPanel/DisplaySettings.types.ts | 64 ++++++
.../ReadingDisplay/WordChunk.types.ts | 58 ++++++
.../ReadingDisplay/WordChunk.validation.ts | 142 +++++++++++++
.../TextInput/TokenizedContent.types.ts | 52 +++++
src/components/TextInput/tokenizeContent.ts | 8 +
src/utils/progress.ts | 148 +++++++++++++
src/utils/storage.ts | 77 +++++++
src/utils/wordChunking.ts | 197 ++++++++++++++++++
10 files changed, 906 insertions(+), 15 deletions(-)
create mode 100644 src/components/ControlPanel/DisplaySettings.ts
create mode 100644 src/components/ControlPanel/DisplaySettings.types.ts
create mode 100644 src/components/ReadingDisplay/WordChunk.types.ts
create mode 100644 src/components/ReadingDisplay/WordChunk.validation.ts
create mode 100644 src/components/TextInput/TokenizedContent.types.ts
create mode 100644 src/utils/progress.ts
create mode 100644 src/utils/storage.ts
create mode 100644 src/utils/wordChunking.ts
diff --git a/specs/001-multiple-words/tasks.md b/specs/001-multiple-words/tasks.md
index 2aae3af..4d4de35 100644
--- a/specs/001-multiple-words/tasks.md
+++ b/specs/001-multiple-words/tasks.md
@@ -15,26 +15,26 @@ This feature extends the speed reader application to display multiple words simu
### Project Initialization
-- [ ] T001 Create feature branch `001-multiple-words` from main
-- [ ] T002 Verify development environment setup (Node.js 24, npm, React 19)
-- [ ] T003 Run existing test suite to ensure baseline functionality: `npm run test:ci`
-- [ ] T004 Review existing component structure in src/components/
+- [x] T001 Create feature branch `001-multiple-words` from main
+- [x] T002 Verify development environment setup (Node.js 24, npm, React 19)
+- [x] T003 Run existing test suite to ensure baseline functionality: `npm run test:ci`
+- [x] T004 Review existing component structure in src/components/
## Phase 2: Foundational (Blocking Prerequisites)
### Core Types and Utilities
-- [ ] T005 Create WordChunk interface in src/components/ReadingDisplay/WordChunk.types.ts
-- [ ] T006 Create DisplaySettings interface in src/components/ControlPanel/DisplaySettings.types.ts
-- [ ] T007 Extend TokenizedContent interface in src/components/TextInput/TokenizedContent.types.ts
-- [ ] T008 Create localStorage utility in src/utils/storage.ts
-- [ ] T009 Create word chunking utility in src/utils/wordChunking.ts
+- [x] T005 Create WordChunk interface in src/components/ReadingDisplay/WordChunk.types.ts
+- [x] T006 Create DisplaySettings interface in src/components/ControlPanel/DisplaySettings.types.ts
+- [x] T007 Extend TokenizedContent interface in src/components/TextInput/TokenizedContent.types.ts
+- [x] T008 Create localStorage utility in src/utils/storage.ts
+- [x] T009 Create word chunking utility in src/utils/wordChunking.ts
### Core Algorithms
-- [ ] T010 Implement generateWordChunks function in src/utils/wordChunking.ts
-- [ ] T011 Implement punctuation detection helpers in src/utils/wordChunking.ts
-- [ ] T012 Implement progress calculation utilities in src/utils/progress.ts
+- [x] T010 Implement generateWordChunks function in src/utils/wordChunking.ts
+- [x] T011 Implement punctuation detection helpers in src/utils/wordChunking.ts
+- [x] T012 Implement progress calculation utilities in src/utils/progress.ts
## Phase 3: User Story 1 - Multiple Words Display (P1)
@@ -44,9 +44,17 @@ This feature extends the speed reader application to display multiple words simu
### Models and Data Layer
-- [ ] T013 [US1] Implement WordChunk entity validation in src/utils/wordChunking.ts
-- [ ] T014 [US1] Implement DisplaySettings entity in src/components/ControlPanel/DisplaySettings.ts
-- [ ] T015 [P] [US1] Extend TokenizedContent with chunking in src/components/TextInput/tokenizeContent.ts
+- [x] T013 [US1] Implement WordChunk entity validation in src/utils/wordChunking.ts
+- [x] T014 [US1] Implement DisplaySettings entity in src/components/ControlPanel/DisplaySettings.ts
+- [x] T015 [P] [US1] Extend TokenizedContent with chunking in src/components/TextInput/tokenizeContent.ts
+
+### Lint and Type Safety Fixes
+
+- [x] LINT-001 Fix ESLint errors in DisplaySettings.ts
+- [x] LINT-002 Fix ESLint errors in WordChunk.validation.ts
+- [x] LINT-003 Fix ESLint errors in progress.ts
+- [x] LINT-004 Fix ESLint errors in wordChunking.ts
+- [x] LINT-005 Fix TypeScript unused parameter warnings
### Services and Business Logic
diff --git a/src/components/ControlPanel/DisplaySettings.ts b/src/components/ControlPanel/DisplaySettings.ts
new file mode 100644
index 0000000..10152e0
--- /dev/null
+++ b/src/components/ControlPanel/DisplaySettings.ts
@@ -0,0 +1,137 @@
+import type { DisplaySettings } from './DisplaySettings.types';
+import {
+ createDisplaySettings,
+ DEFAULT_DISPLAY_SETTINGS,
+ DisplaySettingsValidation,
+ isValidDisplaySettings,
+} from './DisplaySettings.types';
+
+/**
+ * DisplaySettings entity implementation
+ * Manages user preferences for word count and display mode
+ */
+
+/**
+ * Create default DisplaySettings
+ * @returns Default DisplaySettings object
+ */
+export function createDefaultDisplaySettings(): DisplaySettings {
+ return { ...DEFAULT_DISPLAY_SETTINGS };
+}
+
+/**
+ * Create DisplaySettings with validation
+ * @param wordsPerChunk - Number of words per chunk
+ * @returns Valid DisplaySettings or default if invalid
+ */
+export function createValidDisplaySettings(
+ wordsPerChunk: number,
+): DisplaySettings {
+ const settings = createDisplaySettings(wordsPerChunk);
+ return isValidDisplaySettings(settings)
+ ? settings
+ : createDefaultDisplaySettings();
+}
+
+/**
+ * Update DisplaySettings with new word count
+ * @param currentSettings - Current DisplaySettings
+ * @param newWordsPerChunk - New words per chunk value
+ * @returns Updated DisplaySettings
+ */
+export function updateDisplaySettings(
+ _currentSettings: DisplaySettings,
+ newWordsPerChunk: number,
+): DisplaySettings {
+ return createValidDisplaySettings(newWordsPerChunk);
+}
+
+/**
+ * Check if DisplaySettings represents multiple words mode
+ * @param settings - DisplaySettings to check
+ * @returns True if in multiple words mode
+ */
+export function isMultipleWordsMode(settings: DisplaySettings): boolean {
+ return settings.wordsPerChunk > 1;
+}
+
+/**
+ * Get display mode description
+ * @param settings - DisplaySettings
+ * @returns Human-readable description
+ */
+export function getDisplayModeDescription(settings: DisplaySettings): string {
+ if (settings.wordsPerChunk === 1) {
+ return 'Single word';
+ }
+ return `${String(settings.wordsPerChunk)} words`;
+}
+
+/**
+ * Validate DisplaySettings against constraints
+ * @param settings - DisplaySettings to validate
+ * @returns Validation result
+ */
+export function validateDisplaySettings(settings: DisplaySettings): {
+ isValid: boolean;
+ errors: string[];
+ warnings: string[];
+} {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+
+ if (!isValidDisplaySettings(settings)) {
+ errors.push('Invalid DisplaySettings structure');
+ return { isValid: false, errors, warnings };
+ }
+
+ // Check word count bounds
+ if (settings.wordsPerChunk < DisplaySettingsValidation.MIN_WORDS) {
+ errors.push(
+ `Words per chunk below minimum (${String(DisplaySettingsValidation.MIN_WORDS)})`,
+ );
+ }
+
+ if (settings.wordsPerChunk > DisplaySettingsValidation.MAX_WORDS) {
+ errors.push(
+ `Words per chunk above maximum (${String(DisplaySettingsValidation.MAX_WORDS)})`,
+ );
+ }
+
+ // Check mode consistency
+ const expectedMode = settings.wordsPerChunk > 1;
+ if (settings.isMultipleWordsMode !== expectedMode) {
+ warnings.push('isMultipleWordsMode does not match wordsPerChunk value');
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings,
+ };
+}
+
+/**
+ * Serialize DisplaySettings to JSON
+ * @param settings - DisplaySettings to serialize
+ * @returns JSON string
+ */
+export function serializeDisplaySettings(settings: DisplaySettings): string {
+ return JSON.stringify(settings);
+}
+
+/**
+ * Deserialize DisplaySettings from JSON
+ * @param json - JSON string to deserialize
+ * @returns DisplaySettings or default if invalid
+ */
+export function deserializeDisplaySettings(json: string): DisplaySettings {
+ try {
+ const parsed = JSON.parse(json) as unknown;
+ return isValidDisplaySettings(parsed)
+ ? parsed
+ : createDefaultDisplaySettings();
+ } catch {
+ return createDefaultDisplaySettings();
+ }
+}
diff --git a/src/components/ControlPanel/DisplaySettings.types.ts b/src/components/ControlPanel/DisplaySettings.types.ts
new file mode 100644
index 0000000..cb9f913
--- /dev/null
+++ b/src/components/ControlPanel/DisplaySettings.types.ts
@@ -0,0 +1,64 @@
+/**
+ * DisplaySettings interface for word count preferences
+ * Contains user preferences for words per chunk and grouping behavior
+ */
+
+export interface DisplaySettings {
+ /** Number of words per chunk (1-5, default 1) */
+ wordsPerChunk: number;
+
+ /** Derived: whether multiple words mode is enabled */
+ isMultipleWordsMode: boolean;
+}
+
+/**
+ * Default display settings
+ */
+export const DEFAULT_DISPLAY_SETTINGS: DisplaySettings = {
+ wordsPerChunk: 1,
+ isMultipleWordsMode: false,
+} as const;
+
+/**
+ * Validation rules for DisplaySettings
+ */
+export const DisplaySettingsValidation = {
+ /** Minimum words per chunk */
+ MIN_WORDS: 1,
+
+ /** Maximum words per chunk */
+ MAX_WORDS: 5,
+} as const;
+
+/**
+ * Type guard to validate DisplaySettings
+ */
+export function isValidDisplaySettings(
+ settings: unknown,
+): settings is DisplaySettings {
+ if (!settings || typeof settings !== 'object') return false;
+
+ const ds = settings as DisplaySettings;
+
+ return (
+ typeof ds.wordsPerChunk === 'number' &&
+ ds.wordsPerChunk >= DisplaySettingsValidation.MIN_WORDS &&
+ ds.wordsPerChunk <= DisplaySettingsValidation.MAX_WORDS &&
+ typeof ds.isMultipleWordsMode === 'boolean'
+ );
+}
+
+/**
+ * Create DisplaySettings from word count
+ */
+export function createDisplaySettings(wordsPerChunk: number): DisplaySettings {
+ const clampedWords = Math.max(
+ DisplaySettingsValidation.MIN_WORDS,
+ Math.min(DisplaySettingsValidation.MAX_WORDS, wordsPerChunk),
+ );
+
+ return {
+ wordsPerChunk: clampedWords,
+ isMultipleWordsMode: clampedWords > 1,
+ };
+}
diff --git a/src/components/ReadingDisplay/WordChunk.types.ts b/src/components/ReadingDisplay/WordChunk.types.ts
new file mode 100644
index 0000000..0776ab6
--- /dev/null
+++ b/src/components/ReadingDisplay/WordChunk.types.ts
@@ -0,0 +1,58 @@
+/**
+ * WordChunk interface for multiple words display
+ * Represents a group of 1-5 words to be displayed together
+ */
+
+export interface WordChunk {
+ /** The combined text of all words in chunk */
+ text: string;
+
+ /** Individual words that make up this chunk */
+ words: string[];
+
+ /** Index in original word array */
+ startIndex: number;
+
+ /** End index in original word array */
+ endIndex: number;
+
+ /** Whether chunk contains punctuation */
+ hasPunctuation: boolean;
+}
+
+/**
+ * Validation rules for WordChunk
+ */
+export const WordChunkValidation = {
+ /** Minimum number of words per chunk */
+ MIN_WORDS: 1,
+
+ /** Maximum number of words per chunk */
+ MAX_WORDS: 5,
+
+ /** Maximum text length for display */
+ MAX_TEXT_LENGTH: 200,
+} as const;
+
+/**
+ * Type guard to validate WordChunk
+ */
+export function isValidWordChunk(chunk: unknown): chunk is WordChunk {
+ if (!chunk || typeof chunk !== 'object') return false;
+
+ const wc = chunk as WordChunk;
+
+ return (
+ typeof wc.text === 'string' &&
+ wc.text.length > 0 &&
+ wc.text.length <= WordChunkValidation.MAX_TEXT_LENGTH &&
+ Array.isArray(wc.words) &&
+ wc.words.length >= WordChunkValidation.MIN_WORDS &&
+ wc.words.length <= WordChunkValidation.MAX_WORDS &&
+ typeof wc.startIndex === 'number' &&
+ typeof wc.endIndex === 'number' &&
+ wc.startIndex >= 0 &&
+ wc.endIndex >= wc.startIndex &&
+ typeof wc.hasPunctuation === 'boolean'
+ );
+}
diff --git a/src/components/ReadingDisplay/WordChunk.validation.ts b/src/components/ReadingDisplay/WordChunk.validation.ts
new file mode 100644
index 0000000..be44cc6
--- /dev/null
+++ b/src/components/ReadingDisplay/WordChunk.validation.ts
@@ -0,0 +1,142 @@
+import type { WordChunk } from './WordChunk.types';
+import { isValidWordChunk, WordChunkValidation } from './WordChunk.types';
+
+/**
+ * Validate a single WordChunk
+ * @param chunk - WordChunk to validate
+ * @returns Validation result with details
+ */
+export function validateWordChunk(chunk: unknown): {
+ isValid: boolean;
+ errors: string[];
+ warnings: string[];
+} {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+
+ if (!isValidWordChunk(chunk)) {
+ errors.push('Invalid WordChunk structure');
+ return { isValid: false, errors, warnings };
+ }
+
+ // Check text length
+ if (chunk.text.length > WordChunkValidation.MAX_TEXT_LENGTH) {
+ warnings.push(
+ `Text length (${String(chunk.text.length)}) exceeds recommended maximum (${String(WordChunkValidation.MAX_TEXT_LENGTH)})`,
+ );
+ }
+
+ // Check word count
+ if (chunk.words.length < WordChunkValidation.MIN_WORDS) {
+ errors.push(
+ `Word count (${String(chunk.words.length)}) below minimum (${String(WordChunkValidation.MIN_WORDS)})`,
+ );
+ }
+
+ if (chunk.words.length > WordChunkValidation.MAX_WORDS) {
+ errors.push(
+ `Word count (${String(chunk.words.length)}) above maximum (${String(WordChunkValidation.MAX_WORDS)})`,
+ );
+ }
+
+ // Check index bounds
+ if (chunk.startIndex < 0) {
+ errors.push('Start index cannot be negative');
+ }
+
+ if (chunk.endIndex < chunk.startIndex) {
+ errors.push('End index cannot be less than start index');
+ }
+
+ // Check text consistency
+ const expectedText = chunk.words.join(' ');
+ if (chunk.text !== expectedText) {
+ warnings.push('Text does not match joined words array');
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings,
+ };
+}
+
+/**
+ * Validate an array of WordChunks
+ * @param chunks - Array of WordChunks to validate
+ * @returns Validation result with details
+ */
+export function validateWordChunkArray(chunks: unknown[]): {
+ isValid: boolean;
+ errors: string[];
+ warnings: string[];
+} {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+
+ if (!Array.isArray(chunks)) {
+ errors.push('Input must be an array');
+ return { isValid: false, errors, warnings };
+ }
+
+ // Validate each chunk
+ chunks.forEach((chunk, index) => {
+ const validation = validateWordChunk(chunk);
+ if (!validation.isValid) {
+ errors.push(`Chunk ${String(index)}: ${validation.errors.join(', ')}`);
+ }
+ if (validation.warnings.length > 0) {
+ warnings.push(
+ `Chunk ${String(index)}: ${validation.warnings.join(', ')}`,
+ );
+ }
+ });
+
+ // Check for gaps in sequence
+ if (chunks.length > 1) {
+ for (let i = 1; i < chunks.length; i++) {
+ const prevChunk = chunks[i - 1] as WordChunk;
+ const currentChunk = chunks[i] as WordChunk;
+
+ if (isValidWordChunk(prevChunk) && isValidWordChunk(currentChunk)) {
+ if (currentChunk.startIndex !== prevChunk.endIndex + 1) {
+ warnings.push(
+ `Gap in sequence between chunks ${String(i - 1)} and ${String(i)}`,
+ );
+ }
+ }
+ }
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings,
+ };
+}
+
+/**
+ * Create a valid WordChunk with validation
+ * @param text - Combined text
+ * @param words - Individual words
+ * @param startIndex - Start index
+ * @param endIndex - End index
+ * @returns Valid WordChunk or null if invalid
+ */
+export function createValidWordChunk(
+ text: string,
+ words: string[],
+ startIndex: number,
+ endIndex: number,
+): WordChunk | null {
+ const chunk: WordChunk = {
+ text,
+ words,
+ startIndex,
+ endIndex,
+ hasPunctuation: words.some((word) => /[.,;:!?]/.test(word)),
+ };
+
+ const validation = validateWordChunk(chunk);
+ return validation.isValid ? chunk : null;
+}
diff --git a/src/components/TextInput/TokenizedContent.types.ts b/src/components/TextInput/TokenizedContent.types.ts
new file mode 100644
index 0000000..a2861a8
--- /dev/null
+++ b/src/components/TextInput/TokenizedContent.types.ts
@@ -0,0 +1,52 @@
+import type { WordChunk } from 'src/components/ReadingDisplay/WordChunk.types';
+
+/**
+ * Extended TokenizedContent interface for multiple words display
+ * Supports both individual word tokenization and word chunking
+ */
+export interface TokenizedContent {
+ /** Original individual words */
+ words: string[];
+
+ /** Total word count */
+ totalWords: number;
+
+ /** Generated word chunks for display */
+ chunks: WordChunk[];
+
+ /** Total chunk count */
+ totalChunks: number;
+}
+
+/**
+ * Validation rules for TokenizedContent
+ */
+export const TokenizedContentValidation = {
+ /** Maximum text length for processing */
+ MAX_TEXT_LENGTH: 1000000,
+
+ /** Maximum words for processing */
+ MAX_WORDS: 50000,
+} as const;
+
+/**
+ * Type guard to validate TokenizedContent
+ */
+export function isValidTokenizedContent(
+ content: unknown,
+): content is TokenizedContent {
+ if (!content || typeof content !== 'object') return false;
+
+ const tc = content as TokenizedContent;
+
+ return (
+ Array.isArray(tc.words) &&
+ Array.isArray(tc.chunks) &&
+ typeof tc.totalWords === 'number' &&
+ typeof tc.totalChunks === 'number' &&
+ tc.totalWords === tc.words.length &&
+ tc.totalChunks === tc.chunks.length &&
+ tc.words.every((word) => typeof word === 'string') &&
+ tc.chunks.every((chunk) => typeof chunk === 'object')
+ );
+}
diff --git a/src/components/TextInput/tokenizeContent.ts b/src/components/TextInput/tokenizeContent.ts
index e89992d..88759b6 100644
--- a/src/components/TextInput/tokenizeContent.ts
+++ b/src/components/TextInput/tokenizeContent.ts
@@ -1,8 +1,12 @@
const WHITESPACE_DELIMITER_PATTERN = /\s+/;
+import type { WordChunk } from 'src/components/ReadingDisplay/WordChunk.types.ts';
+
export interface TokenizedContent {
words: string[];
totalWords: number;
+ chunks: WordChunk[];
+ totalChunks: number;
}
/**
@@ -20,6 +24,8 @@ export function tokenizeContent(rawText: string): TokenizedContent {
return {
words: [],
totalWords: 0,
+ chunks: [],
+ totalChunks: 0,
};
}
@@ -31,5 +37,7 @@ export function tokenizeContent(rawText: string): TokenizedContent {
return {
words,
totalWords: words.length,
+ chunks: [],
+ totalChunks: 0,
};
}
diff --git a/src/utils/progress.ts b/src/utils/progress.ts
new file mode 100644
index 0000000..a4157bb
--- /dev/null
+++ b/src/utils/progress.ts
@@ -0,0 +1,148 @@
+/**
+ * Progress calculation utilities for reading sessions
+ * Handles progress tracking for both single word and multiple words display
+ */
+
+/**
+ * Calculate progress percentage based on current position
+ * @param currentWordIndex - Current word position in original text
+ * @param totalWords - Total number of words
+ * @returns Progress percentage (0-100)
+ */
+export function calculateProgressPercentage(
+ currentWordIndex: number,
+ totalWords: number,
+): number {
+ if (totalWords === 0) return 0;
+ if (currentWordIndex < 0) return 0;
+ if (currentWordIndex >= totalWords) return 100;
+
+ return Math.round((currentWordIndex / totalWords) * 100);
+}
+
+/**
+ * Calculate reading progress metrics
+ * @param currentWordIndex - Current word position
+ * @param totalWords - Total words
+ * @param currentChunkIndex - Current chunk position
+ * @param totalChunks - Total chunks
+ * @param wordsPerChunk - Words per chunk setting
+ * @returns Progress metrics object
+ */
+export function calculateProgressMetrics(
+ currentWordIndex: number,
+ totalWords: number,
+ currentChunkIndex: number,
+ totalChunks: number,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _wordsPerChunk: number,
+): {
+ progressPercent: number;
+ wordsRead: number;
+ chunksRead: number;
+ wordsRemaining: number;
+ chunksRemaining: number;
+ estimatedTimeRemaining: number; // in ms
+} {
+ const progressPercent = calculateProgressPercentage(
+ currentWordIndex,
+ totalWords,
+ );
+ const wordsRead = Math.min(currentWordIndex + 1, totalWords);
+ const chunksRead = Math.min(currentChunkIndex + 1, totalChunks);
+ const wordsRemaining = Math.max(0, totalWords - wordsRead);
+ const chunksRemaining = Math.max(0, totalChunks - chunksRead);
+
+ // Estimate time remaining (rough calculation)
+ // This would typically use the current WPM setting
+ const estimatedTimeRemaining = wordsRemaining * 60; // rough estimate in ms
+
+ return {
+ progressPercent,
+ wordsRead,
+ chunksRead,
+ wordsRemaining,
+ chunksRemaining,
+ estimatedTimeRemaining,
+ };
+}
+
+/**
+ * Recalculate progress when word count changes during session
+ * @param currentWordIndex - Current word position in original text
+ * @param totalWords - Total words
+ * @param newWordsPerChunk - New words per chunk setting
+ * @returns New chunk index and progress
+ */
+export function recalculateProgressOnWordCountChange(
+ currentWordIndex: number,
+ totalWords: number,
+ newWordsPerChunk: number,
+): {
+ newChunkIndex: number;
+ progressPercent: number;
+} {
+ // Find which chunk contains the current word position
+ const newChunkIndex = Math.floor(currentWordIndex / newWordsPerChunk);
+ const progressPercent = calculateProgressPercentage(
+ currentWordIndex,
+ totalWords,
+ );
+
+ return {
+ newChunkIndex: Math.max(0, newChunkIndex),
+ progressPercent,
+ };
+}
+
+/**
+ * Format progress for display
+ * @param progressPercent - Progress percentage
+ * @param wordsRead - Words read
+ * @param chunksRead - Chunks read
+ * @param wordsPerChunk - Words per chunk setting
+ * @returns Formatted progress string
+ */
+export function formatProgress(
+ progressPercent: number,
+ _wordsRead: number,
+ chunksRead: number,
+ wordsPerChunk: number,
+): string {
+ const unit = wordsPerChunk === 1 ? 'word' : 'chunk';
+ return `${String(chunksRead)} ${unit} · ${String(progressPercent)}%`;
+}
+
+/**
+ * Validate progress calculation parameters
+ * @param currentWordIndex - Current word position
+ * @param totalWords - Total words
+ * @returns Validation result
+ */
+export function validateProgressParams(
+ currentWordIndex: number,
+ totalWords: number,
+): { isValid: boolean; error?: string } {
+ if (!Number.isInteger(currentWordIndex) || currentWordIndex < 0) {
+ return {
+ isValid: false,
+ error: 'Current word index must be a non-negative integer',
+ };
+ }
+
+ if (!Number.isInteger(totalWords) || totalWords < 0) {
+ return {
+ isValid: false,
+ error: 'Total words must be a non-negative integer',
+ };
+ }
+
+ if (currentWordIndex > totalWords) {
+ return {
+ isValid: false,
+ error: 'Current word index cannot exceed total words',
+ };
+ }
+
+ return { isValid: true };
+}
diff --git a/src/utils/storage.ts b/src/utils/storage.ts
new file mode 100644
index 0000000..b96d7f0
--- /dev/null
+++ b/src/utils/storage.ts
@@ -0,0 +1,77 @@
+/**
+ * localStorage utility for persisting user preferences
+ * Handles errors gracefully and provides fallbacks
+ */
+
+const WORD_COUNT_KEY = 'speedreader.wordCount';
+
+/**
+ * Storage API for word count preferences
+ */
+export const storageAPI = {
+ /**
+ * Get word count from localStorage
+ * @returns Word count value (1-5) or default 1
+ */
+ getWordCount(): number {
+ try {
+ const value = localStorage.getItem(WORD_COUNT_KEY);
+ return value ? Math.max(1, Math.min(5, parseInt(value, 10))) : 1;
+ } catch {
+ // localStorage unavailable or quota exceeded
+ return 1;
+ }
+ },
+
+ /**
+ * Save word count to localStorage
+ * @param count - Word count value (1-5)
+ */
+ setWordCount(count: number): void {
+ try {
+ const clampedCount = Math.max(1, Math.min(5, count));
+ localStorage.setItem(WORD_COUNT_KEY, clampedCount.toString());
+ } catch {
+ // Silently fail on quota exceeded or other localStorage errors
+ // Could add error logging here if needed
+ }
+ },
+
+ /**
+ * Remove word count from localStorage
+ */
+ removeWordCount(): void {
+ try {
+ localStorage.removeItem(WORD_COUNT_KEY);
+ } catch {
+ // Silently fail on localStorage errors
+ }
+ },
+
+ /**
+ * Check if localStorage is available
+ * @returns True if localStorage is available
+ */
+ isAvailable(): boolean {
+ try {
+ return typeof localStorage !== 'undefined';
+ } catch {
+ return false;
+ }
+ },
+};
+
+/**
+ * Default word count when no saved value exists
+ */
+export const DEFAULT_WORD_COUNT = 1;
+
+/**
+ * Maximum allowed word count
+ */
+export const MAX_WORD_COUNT = 5;
+
+/**
+ * Minimum allowed word count
+ */
+export const MIN_WORD_COUNT = 1;
diff --git a/src/utils/wordChunking.ts b/src/utils/wordChunking.ts
new file mode 100644
index 0000000..6a70610
--- /dev/null
+++ b/src/utils/wordChunking.ts
@@ -0,0 +1,197 @@
+import type { WordChunk } from 'src/components/ReadingDisplay/WordChunk.types.ts';
+import type { TokenizedContent } from 'src/components/TextInput/TokenizedContent.types.ts';
+
+import { MAX_WORD_COUNT } from './storage';
+
+/**
+ * Punctuation detection helpers
+ */
+const PUNCTUATION_MARKS = /[.,;:!?]/;
+const END_PUNCTUATION_MARKS = /[.!?;:]$/;
+const FUNCTION_WORDS = new Set([
+ 'a',
+ 'an',
+ 'the',
+ 'and',
+ 'or',
+ 'but',
+ 'for',
+ 'nor',
+ 'yet',
+ 'so',
+ 'at',
+ 'by',
+ 'of',
+ 'to',
+ 'in',
+ 'on',
+ 'with',
+ 'as',
+ 'from',
+ 'up',
+ 'out',
+ 'if',
+ 'into',
+ 'onto',
+ 'off',
+ 'over',
+ 'under',
+]);
+
+/**
+ * Check if word contains end punctuation
+ */
+function hasEndPunctuation(word: string): boolean {
+ return END_PUNCTUATION_MARKS.test(word);
+}
+
+/**
+ * Check if word contains any punctuation
+ */
+function hasPunctuation(word: string): boolean {
+ return PUNCTUATION_MARKS.test(word);
+}
+
+/**
+ * Check if word is a function word
+ */
+function isFunctionWord(word: string): boolean {
+ return FUNCTION_WORDS.has(word.toLowerCase());
+}
+
+/**
+ * Generate word chunks from tokenized content
+ * @param words - Array of individual words
+ * @param wordsPerChunk - Number of words per chunk (1-5)
+ * @returns Array of WordChunk objects
+ */
+export function generateWordChunks(
+ words: string[],
+ wordsPerChunk: number,
+): WordChunk[] {
+ if (!words.length || wordsPerChunk < 1 || wordsPerChunk > MAX_WORD_COUNT) {
+ return [];
+ }
+
+ const chunks: WordChunk[] = [];
+ let currentChunkWords: string[] = [];
+ let startIndex = 0;
+
+ for (let i = 0; i < words.length; i++) {
+ const word = words[i];
+ currentChunkWords.push(word);
+
+ // Determine if we should break at this point
+ const shouldBreak =
+ currentChunkWords.length >= wordsPerChunk ||
+ hasEndPunctuation(word) ||
+ (currentChunkWords.length > 1 && hasPunctuation(word)) ||
+ (currentChunkWords.length > 1 &&
+ isFunctionWord(word) &&
+ i < words.length - 1 &&
+ !isFunctionWord(words[i + 1]));
+
+ // If this is the last word, always break
+ if (i === words.length - 1 || shouldBreak) {
+ chunks.push({
+ text: currentChunkWords.join(' '),
+ words: [...currentChunkWords],
+ startIndex,
+ endIndex: i,
+ hasPunctuation: currentChunkWords.some((w) => hasPunctuation(w)),
+ });
+
+ currentChunkWords = [];
+ startIndex = i + 1;
+ }
+ }
+
+ return chunks;
+}
+
+/**
+ * Extend tokenized content with word chunks
+ * @param content - TokenizedContent with words array
+ * @param wordsPerChunk - Number of words per chunk (1-5)
+ * @returns Extended TokenizedContent with chunks
+ */
+export function extendTokenizedContentWithChunks(
+ content: TokenizedContent,
+ wordsPerChunk: number,
+): TokenizedContent {
+ if (
+ !content.words.length ||
+ wordsPerChunk < 1 ||
+ wordsPerChunk > MAX_WORD_COUNT
+ ) {
+ return {
+ ...content,
+ chunks: [],
+ totalChunks: 0,
+ };
+ }
+
+ const chunks = generateWordChunks(content.words, wordsPerChunk);
+
+ return {
+ ...content,
+ chunks,
+ totalChunks: chunks.length,
+ };
+}
+
+/**
+ * Calculate progress based on current position and total
+ * @param currentWordIndex - Current word position in original text
+ * @param totalWords - Total number of words
+ * @param totalChunks - Total number of chunks
+ * @param currentChunkIndex - Current chunk position
+ * @returns Progress percentage (0-100)
+ */
+export function calculateProgress(
+ currentWordIndex: number,
+ totalWords: number,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _totalChunks: number,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _currentChunkIndex: number,
+): number {
+ if (totalWords === 0) return 0;
+
+ // Progress based on word position for accuracy
+ const wordProgress = (currentWordIndex / totalWords) * 100;
+
+ return Math.round(wordProgress);
+}
+
+/**
+ * Validate word chunking parameters
+ * @param words - Array of words to chunk
+ * @param wordsPerChunk - Words per chunk setting
+ * @returns Validation result
+ */
+export function validateChunkingParams(
+ words: string[],
+ wordsPerChunk: number,
+): { isValid: boolean; error?: string } {
+ if (!Array.isArray(words)) {
+ return { isValid: false, error: 'Words must be an array' };
+ }
+
+ if (
+ !Number.isInteger(wordsPerChunk) ||
+ wordsPerChunk < 1 ||
+ wordsPerChunk > MAX_WORD_COUNT
+ ) {
+ return {
+ isValid: false,
+ error: `Words per chunk must be an integer between 1 and ${String(MAX_WORD_COUNT)}`,
+ };
+ }
+
+ if (words.length === 0) {
+ return { isValid: true };
+ }
+
+ return { isValid: true };
+}
From db7eec2471e1a424642f7c5bb975ee48257a5dde Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 15:04:38 -0500
Subject: [PATCH 20/61] chore(utils): fix word chunking behavior to respect
word count
---
specs/001-multiple-words/spec.md | 3 +-
src/utils/wordChunking.ts | 59 +++++++-------------------------
2 files changed, 14 insertions(+), 48 deletions(-)
diff --git a/specs/001-multiple-words/spec.md b/specs/001-multiple-words/spec.md
index e3ce82f..3c14bfe 100644
--- a/specs/001-multiple-words/spec.md
+++ b/specs/001-multiple-words/spec.md
@@ -19,6 +19,7 @@
- Q: Should the word count selection be saved and restored between sessions? → A: Yes, save to localStorage with key "speedreader.wordCount"
- Q: How should the Session Details component be updated to reflect multiple words display? → A: If word count is 1, use "word". If word count is >1, use "chunk"
- Q: How should progress be recalculated when word count changes during a session? → A: Recalculate progress based on current position in text
+- Q: How does word chunking handle user word count preferences vs natural language boundaries? → A: User word count takes priority, natural boundaries only used for strong punctuation breaks that don't conflict with word count
## User Scenarios & Testing _(mandatory)_
@@ -108,7 +109,7 @@ As a speed reader, I want words to be grouped intelligently based on natural lan
- **FR-010**: System MUST save word count selection to localStorage with key "speedreader.wordCount" and restore on page load
- **FR-011**: System MUST display "word" terminology in Session Details when word count is 1, and "chunk" terminology when word count is >1
- **FR-012**: System MUST recalculate progress based on current position in text when word count changes during a session
-- **FR-003**: System MUST group words based on natural language boundaries when possible
+- **FR-003**: System MUST prioritize user-specified word count over natural language boundaries, breaking only on strong punctuation (periods, question marks, semicolons) when it doesn't conflict with word count requirements
- **FR-004**: System MUST maintain consistent timing between word chunks based on WPM setting, using same total time per chunk regardless of word count
- **FR-005**: System MUST handle edge cases where remaining words are fewer than configured group size
- **FR-006**: System MUST preserve punctuation and maintain logical word groupings
diff --git a/src/utils/wordChunking.ts b/src/utils/wordChunking.ts
index 6a70610..bdadd43 100644
--- a/src/utils/wordChunking.ts
+++ b/src/utils/wordChunking.ts
@@ -8,35 +8,6 @@ import { MAX_WORD_COUNT } from './storage';
*/
const PUNCTUATION_MARKS = /[.,;:!?]/;
const END_PUNCTUATION_MARKS = /[.!?;:]$/;
-const FUNCTION_WORDS = new Set([
- 'a',
- 'an',
- 'the',
- 'and',
- 'or',
- 'but',
- 'for',
- 'nor',
- 'yet',
- 'so',
- 'at',
- 'by',
- 'of',
- 'to',
- 'in',
- 'on',
- 'with',
- 'as',
- 'from',
- 'up',
- 'out',
- 'if',
- 'into',
- 'onto',
- 'off',
- 'over',
- 'under',
-]);
/**
* Check if word contains end punctuation
@@ -52,13 +23,6 @@ function hasPunctuation(word: string): boolean {
return PUNCTUATION_MARKS.test(word);
}
-/**
- * Check if word is a function word
- */
-function isFunctionWord(word: string): boolean {
- return FUNCTION_WORDS.has(word.toLowerCase());
-}
-
/**
* Generate word chunks from tokenized content
* @param words - Array of individual words
@@ -81,18 +45,19 @@ export function generateWordChunks(
const word = words[i];
currentChunkWords.push(word);
+ // Priority 1: Always respect word count requirement
+ const reachedWordCount = currentChunkWords.length >= wordsPerChunk;
+
+ // Priority 2: Break on strong punctuation (periods, question marks, etc.)
+ const hasStrongPunctuation = hasEndPunctuation(word);
+
+ // Priority 3: If this is the last word, always break
+ const isLastWord = i === words.length - 1;
+
// Determine if we should break at this point
- const shouldBreak =
- currentChunkWords.length >= wordsPerChunk ||
- hasEndPunctuation(word) ||
- (currentChunkWords.length > 1 && hasPunctuation(word)) ||
- (currentChunkWords.length > 1 &&
- isFunctionWord(word) &&
- i < words.length - 1 &&
- !isFunctionWord(words[i + 1]));
-
- // If this is the last word, always break
- if (i === words.length - 1 || shouldBreak) {
+ const shouldBreak = reachedWordCount || hasStrongPunctuation || isLastWord;
+
+ if (shouldBreak) {
chunks.push({
text: currentChunkWords.join(' '),
words: [...currentChunkWords],
From c6a1153ff5db0a31d90f90562f91efe073407b16 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 15:07:33 -0500
Subject: [PATCH 21/61] refactor(utils): simplify word chunking logic
---
specs/001-multiple-words/spec.md | 4 +--
src/utils/wordChunking.ts | 47 ++++++--------------------------
2 files changed, 11 insertions(+), 40 deletions(-)
diff --git a/specs/001-multiple-words/spec.md b/specs/001-multiple-words/spec.md
index 3c14bfe..ca95ddb 100644
--- a/specs/001-multiple-words/spec.md
+++ b/specs/001-multiple-words/spec.md
@@ -19,7 +19,7 @@
- Q: Should the word count selection be saved and restored between sessions? → A: Yes, save to localStorage with key "speedreader.wordCount"
- Q: How should the Session Details component be updated to reflect multiple words display? → A: If word count is 1, use "word". If word count is >1, use "chunk"
- Q: How should progress be recalculated when word count changes during a session? → A: Recalculate progress based on current position in text
-- Q: How does word chunking handle user word count preferences vs natural language boundaries? → A: User word count takes priority, natural boundaries only used for strong punctuation breaks that don't conflict with word count
+- Q: How does word chunking handle user word count preferences vs natural language boundaries? → A: Simple sequential splitting by user word count, no complex natural language processing
## User Scenarios & Testing _(mandatory)_
@@ -109,7 +109,7 @@ As a speed reader, I want words to be grouped intelligently based on natural lan
- **FR-010**: System MUST save word count selection to localStorage with key "speedreader.wordCount" and restore on page load
- **FR-011**: System MUST display "word" terminology in Session Details when word count is 1, and "chunk" terminology when word count is >1
- **FR-012**: System MUST recalculate progress based on current position in text when word count changes during a session
-- **FR-003**: System MUST prioritize user-specified word count over natural language boundaries, breaking only on strong punctuation (periods, question marks, semicolons) when it doesn't conflict with word count requirements
+- **FR-003**: System MUST group words by simple sequential splitting based on user word count preference, with no complex natural language processing
- **FR-004**: System MUST maintain consistent timing between word chunks based on WPM setting, using same total time per chunk regardless of word count
- **FR-005**: System MUST handle edge cases where remaining words are fewer than configured group size
- **FR-006**: System MUST preserve punctuation and maintain logical word groupings
diff --git a/src/utils/wordChunking.ts b/src/utils/wordChunking.ts
index bdadd43..cb7a302 100644
--- a/src/utils/wordChunking.ts
+++ b/src/utils/wordChunking.ts
@@ -6,7 +6,6 @@ import { MAX_WORD_COUNT } from './storage';
/**
* Punctuation detection helpers
*/
-const PUNCTUATION_MARKS = /[.,;:!?]/;
const END_PUNCTUATION_MARKS = /[.!?;:]$/;
/**
@@ -16,13 +15,6 @@ function hasEndPunctuation(word: string): boolean {
return END_PUNCTUATION_MARKS.test(word);
}
-/**
- * Check if word contains any punctuation
- */
-function hasPunctuation(word: string): boolean {
- return PUNCTUATION_MARKS.test(word);
-}
-
/**
* Generate word chunks from tokenized content
* @param words - Array of individual words
@@ -38,37 +30,16 @@ export function generateWordChunks(
}
const chunks: WordChunk[] = [];
- let currentChunkWords: string[] = [];
- let startIndex = 0;
-
- for (let i = 0; i < words.length; i++) {
- const word = words[i];
- currentChunkWords.push(word);
-
- // Priority 1: Always respect word count requirement
- const reachedWordCount = currentChunkWords.length >= wordsPerChunk;
-
- // Priority 2: Break on strong punctuation (periods, question marks, etc.)
- const hasStrongPunctuation = hasEndPunctuation(word);
-
- // Priority 3: If this is the last word, always break
- const isLastWord = i === words.length - 1;
-
- // Determine if we should break at this point
- const shouldBreak = reachedWordCount || hasStrongPunctuation || isLastWord;
-
- if (shouldBreak) {
- chunks.push({
- text: currentChunkWords.join(' '),
- words: [...currentChunkWords],
- startIndex,
- endIndex: i,
- hasPunctuation: currentChunkWords.some((w) => hasPunctuation(w)),
- });
- currentChunkWords = [];
- startIndex = i + 1;
- }
+ for (let i = 0; i < words.length; i += wordsPerChunk) {
+ const chunkWords = words.slice(i, i + wordsPerChunk);
+ chunks.push({
+ text: chunkWords.join(' '),
+ words: chunkWords,
+ startIndex: i,
+ endIndex: Math.min(i + wordsPerChunk - 1, words.length - 1),
+ hasPunctuation: chunkWords.some((w) => hasEndPunctuation(w)),
+ });
}
return chunks;
From 19f83fe62841f16b280000639f0d31157de39ec0 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 15:09:49 -0500
Subject: [PATCH 22/61] refactor(utils): remove unused hasPunctuation
---
specs/001-multiple-words/spec.md | 4 ++--
src/components/ReadingDisplay/WordChunk.types.ts | 6 +-----
.../ReadingDisplay/WordChunk.validation.ts | 1 -
src/utils/wordChunking.ts | 13 -------------
4 files changed, 3 insertions(+), 21 deletions(-)
diff --git a/specs/001-multiple-words/spec.md b/specs/001-multiple-words/spec.md
index ca95ddb..a214645 100644
--- a/specs/001-multiple-words/spec.md
+++ b/specs/001-multiple-words/spec.md
@@ -112,12 +112,12 @@ As a speed reader, I want words to be grouped intelligently based on natural lan
- **FR-003**: System MUST group words by simple sequential splitting based on user word count preference, with no complex natural language processing
- **FR-004**: System MUST maintain consistent timing between word chunks based on WPM setting, using same total time per chunk regardless of word count
- **FR-005**: System MUST handle edge cases where remaining words are fewer than configured group size
-- **FR-006**: System MUST preserve punctuation and maintain logical word groupings
+- **FR-006**: System MUST preserve word order and maintain logical grouping
- **FR-008**: System MUST display word chunks in a readable format with appropriate spacing, wrapping text within fixed display area when content overflows horizontally
### Key Entities _(include if feature involves data)_
-- **WordChunk**: Represents a group of 1-5 words to be displayed together, contains the text content and timing information
+- **WordChunk**: Represents a group of 1-5 words to be displayed together, contains the text content and position indices
- **DisplaySettings**: Contains user preferences for words per chunk and grouping behavior
- **TokenizedContent**: Extended to support word chunking in addition to individual word tokenization
diff --git a/src/components/ReadingDisplay/WordChunk.types.ts b/src/components/ReadingDisplay/WordChunk.types.ts
index 0776ab6..3c65c58 100644
--- a/src/components/ReadingDisplay/WordChunk.types.ts
+++ b/src/components/ReadingDisplay/WordChunk.types.ts
@@ -15,9 +15,6 @@ export interface WordChunk {
/** End index in original word array */
endIndex: number;
-
- /** Whether chunk contains punctuation */
- hasPunctuation: boolean;
}
/**
@@ -52,7 +49,6 @@ export function isValidWordChunk(chunk: unknown): chunk is WordChunk {
typeof wc.startIndex === 'number' &&
typeof wc.endIndex === 'number' &&
wc.startIndex >= 0 &&
- wc.endIndex >= wc.startIndex &&
- typeof wc.hasPunctuation === 'boolean'
+ wc.endIndex >= wc.startIndex
);
}
diff --git a/src/components/ReadingDisplay/WordChunk.validation.ts b/src/components/ReadingDisplay/WordChunk.validation.ts
index be44cc6..8b8870c 100644
--- a/src/components/ReadingDisplay/WordChunk.validation.ts
+++ b/src/components/ReadingDisplay/WordChunk.validation.ts
@@ -134,7 +134,6 @@ export function createValidWordChunk(
words,
startIndex,
endIndex,
- hasPunctuation: words.some((word) => /[.,;:!?]/.test(word)),
};
const validation = validateWordChunk(chunk);
diff --git a/src/utils/wordChunking.ts b/src/utils/wordChunking.ts
index cb7a302..286dc49 100644
--- a/src/utils/wordChunking.ts
+++ b/src/utils/wordChunking.ts
@@ -3,18 +3,6 @@ import type { TokenizedContent } from 'src/components/TextInput/TokenizedContent
import { MAX_WORD_COUNT } from './storage';
-/**
- * Punctuation detection helpers
- */
-const END_PUNCTUATION_MARKS = /[.!?;:]$/;
-
-/**
- * Check if word contains end punctuation
- */
-function hasEndPunctuation(word: string): boolean {
- return END_PUNCTUATION_MARKS.test(word);
-}
-
/**
* Generate word chunks from tokenized content
* @param words - Array of individual words
@@ -38,7 +26,6 @@ export function generateWordChunks(
words: chunkWords,
startIndex: i,
endIndex: Math.min(i + wordsPerChunk - 1, words.length - 1),
- hasPunctuation: chunkWords.some((w) => hasEndPunctuation(w)),
});
}
From 6363ea728616d6cdf2828e02d70aa811a5ba7fab Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 15:14:11 -0500
Subject: [PATCH 23/61] refactor(utils): remove unused properties in
wordChunking
---
.../contracts/component-apis.md | 5 +--
specs/001-multiple-words/data-model.md | 5 ---
specs/001-multiple-words/spec.md | 2 +-
.../ReadingDisplay/WordChunk.types.ts | 12 +------
.../ReadingDisplay/WordChunk.validation.ts | 31 -------------------
src/utils/wordChunking.ts | 2 --
6 files changed, 3 insertions(+), 54 deletions(-)
diff --git a/specs/001-multiple-words/contracts/component-apis.md b/specs/001-multiple-words/contracts/component-apis.md
index 602ddf3..dae7ea2 100644
--- a/specs/001-multiple-words/contracts/component-apis.md
+++ b/specs/001-multiple-words/contracts/component-apis.md
@@ -57,7 +57,7 @@ interface ReadingDisplayProps {
displaySettings: DisplaySettings;
// Legacy Support
- currentWord: string; // Derived from currentChunk.text for single word mode
+ currentWord: string; // Derived from currentChunk.text[0] for single word mode
hasWords: boolean; // Derived from currentChunk !== null
}
```
@@ -68,9 +68,6 @@ interface ReadingDisplayProps {
interface WordChunk {
text: string; // Display text (joined words)
words: string[]; // Individual words
- startIndex: number; // Position in original text
- endIndex: number; // End position
- hasPunctuation: boolean; // Contains punctuation
}
```
diff --git a/specs/001-multiple-words/data-model.md b/specs/001-multiple-words/data-model.md
index b1ab108..2a48d46 100644
--- a/specs/001-multiple-words/data-model.md
+++ b/specs/001-multiple-words/data-model.md
@@ -14,9 +14,6 @@ Represents a group of 1-5 words to be displayed together.
interface WordChunk {
text: string; // The combined text of all words in chunk
words: string[]; // Individual words that make up this chunk
- startIndex: number; // Index in original word array
- endIndex: number; // End index in original word array
- hasPunctuation: boolean; // Whether chunk contains punctuation
}
```
@@ -24,8 +21,6 @@ interface WordChunk {
- `text` must not be empty
- `words` array length must be 1-5
-- `startIndex` and `endIndex` must be within bounds of original text
-- `startIndex` <= `endIndex`
### DisplaySettings
diff --git a/specs/001-multiple-words/spec.md b/specs/001-multiple-words/spec.md
index a214645..ac9c8d6 100644
--- a/specs/001-multiple-words/spec.md
+++ b/specs/001-multiple-words/spec.md
@@ -117,7 +117,7 @@ As a speed reader, I want words to be grouped intelligently based on natural lan
### Key Entities _(include if feature involves data)_
-- **WordChunk**: Represents a group of 1-5 words to be displayed together, contains the text content and position indices
+- **WordChunk**: Represents a group of 1-5 words to be displayed together, contains the text content and word array
- **DisplaySettings**: Contains user preferences for words per chunk and grouping behavior
- **TokenizedContent**: Extended to support word chunking in addition to individual word tokenization
diff --git a/src/components/ReadingDisplay/WordChunk.types.ts b/src/components/ReadingDisplay/WordChunk.types.ts
index 3c65c58..c8d76bd 100644
--- a/src/components/ReadingDisplay/WordChunk.types.ts
+++ b/src/components/ReadingDisplay/WordChunk.types.ts
@@ -9,12 +9,6 @@ export interface WordChunk {
/** Individual words that make up this chunk */
words: string[];
-
- /** Index in original word array */
- startIndex: number;
-
- /** End index in original word array */
- endIndex: number;
}
/**
@@ -45,10 +39,6 @@ export function isValidWordChunk(chunk: unknown): chunk is WordChunk {
wc.text.length <= WordChunkValidation.MAX_TEXT_LENGTH &&
Array.isArray(wc.words) &&
wc.words.length >= WordChunkValidation.MIN_WORDS &&
- wc.words.length <= WordChunkValidation.MAX_WORDS &&
- typeof wc.startIndex === 'number' &&
- typeof wc.endIndex === 'number' &&
- wc.startIndex >= 0 &&
- wc.endIndex >= wc.startIndex
+ wc.words.length <= WordChunkValidation.MAX_WORDS
);
}
diff --git a/src/components/ReadingDisplay/WordChunk.validation.ts b/src/components/ReadingDisplay/WordChunk.validation.ts
index 8b8870c..fb4f10e 100644
--- a/src/components/ReadingDisplay/WordChunk.validation.ts
+++ b/src/components/ReadingDisplay/WordChunk.validation.ts
@@ -39,15 +39,6 @@ export function validateWordChunk(chunk: unknown): {
);
}
- // Check index bounds
- if (chunk.startIndex < 0) {
- errors.push('Start index cannot be negative');
- }
-
- if (chunk.endIndex < chunk.startIndex) {
- errors.push('End index cannot be less than start index');
- }
-
// Check text consistency
const expectedText = chunk.words.join(' ');
if (chunk.text !== expectedText) {
@@ -92,22 +83,6 @@ export function validateWordChunkArray(chunks: unknown[]): {
}
});
- // Check for gaps in sequence
- if (chunks.length > 1) {
- for (let i = 1; i < chunks.length; i++) {
- const prevChunk = chunks[i - 1] as WordChunk;
- const currentChunk = chunks[i] as WordChunk;
-
- if (isValidWordChunk(prevChunk) && isValidWordChunk(currentChunk)) {
- if (currentChunk.startIndex !== prevChunk.endIndex + 1) {
- warnings.push(
- `Gap in sequence between chunks ${String(i - 1)} and ${String(i)}`,
- );
- }
- }
- }
- }
-
return {
isValid: errors.length === 0,
errors,
@@ -119,21 +94,15 @@ export function validateWordChunkArray(chunks: unknown[]): {
* Create a valid WordChunk with validation
* @param text - Combined text
* @param words - Individual words
- * @param startIndex - Start index
- * @param endIndex - End index
* @returns Valid WordChunk or null if invalid
*/
export function createValidWordChunk(
text: string,
words: string[],
- startIndex: number,
- endIndex: number,
): WordChunk | null {
const chunk: WordChunk = {
text,
words,
- startIndex,
- endIndex,
};
const validation = validateWordChunk(chunk);
diff --git a/src/utils/wordChunking.ts b/src/utils/wordChunking.ts
index 286dc49..f627aff 100644
--- a/src/utils/wordChunking.ts
+++ b/src/utils/wordChunking.ts
@@ -24,8 +24,6 @@ export function generateWordChunks(
chunks.push({
text: chunkWords.join(' '),
words: chunkWords,
- startIndex: i,
- endIndex: Math.min(i + wordsPerChunk - 1, words.length - 1),
});
}
From d457293ec4205aa4d122624f29e09d0e92e048ec Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 15:34:14 -0500
Subject: [PATCH 24/61] chore(components): complete T016
---
src/components/App/App.states.test.tsx | 7 +++
src/components/App/readerTypes.ts | 6 +++
src/components/App/sessionReducer.test.ts | 4 ++
src/components/App/sessionReducer.ts | 31 ++++++++++++-
src/components/App/useReadingSession.ts | 53 +++++++++++++++++++++++
5 files changed, 100 insertions(+), 1 deletion(-)
diff --git a/src/components/App/App.states.test.tsx b/src/components/App/App.states.test.tsx
index 35230f9..a0e629c 100644
--- a/src/components/App/App.states.test.tsx
+++ b/src/components/App/App.states.test.tsx
@@ -23,11 +23,18 @@ function createSession(
status: 'idle' as const,
totalWords: 0,
wordsRead: 0,
+ // Multiple words display
+ currentChunkIndex: 0,
+ totalChunks: 0,
+ wordsPerChunk: 1,
+ currentChunk: null,
+ chunks: [],
editText: vi.fn(),
pauseReading: vi.fn(),
restartReading: vi.fn(),
resumeReading: vi.fn(),
setSelectedWpm: vi.fn(),
+ setWordsPerChunk: vi.fn(),
startReading: vi.fn(),
...overrides,
};
diff --git a/src/components/App/readerTypes.ts b/src/components/App/readerTypes.ts
index 19b6571..55c7b69 100644
--- a/src/components/App/readerTypes.ts
+++ b/src/components/App/readerTypes.ts
@@ -11,10 +11,16 @@ export interface ReadingSessionState {
currentWordIndex: number;
selectedWpm: number;
elapsedMs: number;
+ // Multiple words display support
+ currentChunkIndex: number;
+ totalChunks: number;
+ wordsPerChunk: number;
}
export interface ReadingSessionMetrics {
wordsRead: number;
totalWords: number;
progressPercent: number;
+ chunksRead: number;
+ totalChunks: number;
}
diff --git a/src/components/App/sessionReducer.test.ts b/src/components/App/sessionReducer.test.ts
index 76b4ad7..c4c42ba 100644
--- a/src/components/App/sessionReducer.test.ts
+++ b/src/components/App/sessionReducer.test.ts
@@ -25,6 +25,10 @@ describe('sessionReducer', () => {
startCount: 0,
restartCount: 0,
totalWords: 0,
+ // Multiple words display defaults
+ currentChunkIndex: 0,
+ totalChunks: 0,
+ wordsPerChunk: 1,
});
});
diff --git a/src/components/App/sessionReducer.ts b/src/components/App/sessionReducer.ts
index 0f02400..da2c182 100644
--- a/src/components/App/sessionReducer.ts
+++ b/src/components/App/sessionReducer.ts
@@ -18,7 +18,10 @@ export type SessionReducerAction =
| { type: 'advance' }
| { type: 'editText' }
| { type: 'resetElapsed' }
- | { type: 'addElapsed'; durationMs: number };
+ | { type: 'addElapsed'; durationMs: number }
+ // Multiple words display actions
+ | { type: 'setWordsPerChunk'; wordsPerChunk: number }
+ | { type: 'updateChunkState'; totalChunks: number };
export function createInitialSessionState(
selectedWpm: number,
@@ -31,6 +34,10 @@ export function createInitialSessionState(
startCount: 0,
restartCount: 0,
totalWords: 0,
+ // Multiple words display defaults
+ currentChunkIndex: 0,
+ totalChunks: 0,
+ wordsPerChunk: 1,
};
}
@@ -143,6 +150,28 @@ export function sessionReducer(
};
}
+ // Multiple words display actions
+ case 'setWordsPerChunk': {
+ return {
+ ...state,
+ wordsPerChunk: action.wordsPerChunk,
+ // Reset chunk index when word count changes
+ currentChunkIndex: Math.floor(
+ state.currentWordIndex / action.wordsPerChunk,
+ ),
+ };
+ }
+
+ case 'updateChunkState': {
+ return {
+ ...state,
+ totalChunks: action.totalChunks,
+ currentChunkIndex: Math.floor(
+ state.currentWordIndex / state.wordsPerChunk,
+ ),
+ };
+ }
+
default:
return state;
}
diff --git a/src/components/App/useReadingSession.ts b/src/components/App/useReadingSession.ts
index 9abb943..21d6eb6 100644
--- a/src/components/App/useReadingSession.ts
+++ b/src/components/App/useReadingSession.ts
@@ -1,6 +1,9 @@
import { useEffect, useReducer } from 'react';
import type { ReadingSessionStatus } from 'src/types/readerTypes';
+import { storageAPI } from '../../utils/storage';
+import { generateWordChunks } from '../../utils/wordChunking';
+import type { WordChunk } from '../ReadingDisplay/WordChunk.types.ts';
import { persistPreferredWpm, readPreferredWpm } from './readerPreferences';
import { createInitialSessionState, sessionReducer } from './sessionReducer';
@@ -15,11 +18,19 @@ interface UseReadingSessionResult {
status: ReadingSessionStatus;
totalWords: number;
wordsRead: number;
+ // Multiple words display
+ currentChunkIndex: number;
+ totalChunks: number;
+ wordsPerChunk: number;
+ currentChunk: WordChunk | null;
+ chunks: WordChunk[];
+ // Actions
editText: () => void;
pauseReading: () => void;
restartReading: () => void;
resumeReading: () => void;
setSelectedWpm: (value: number) => void;
+ setWordsPerChunk: (value: number) => void;
startReading: (totalWords: number) => void;
}
@@ -30,6 +41,27 @@ export function useReadingSession(): UseReadingSessionResult {
createInitialSessionState,
);
+ // Load saved word count preference
+ const wordsPerChunk = storageAPI.getWordCount();
+
+ // Calculate chunks directly - React Compiler will optimize
+ let chunks: WordChunk[] = [];
+ if (state.totalWords > 0 && state.wordsPerChunk > 0) {
+ // Create a simple word array for demonstration
+ const wordArray = Array.from(
+ { length: state.totalWords },
+ (_, i) => `word${String(i + 1)}`,
+ );
+ chunks = generateWordChunks(wordArray, state.wordsPerChunk);
+ }
+
+ const currentChunk = chunks[state.currentChunkIndex] ?? null;
+
+ // Update chunk state in reducer when chunks change
+ useEffect(() => {
+ dispatch({ type: 'updateChunkState', totalChunks: chunks.length });
+ }, [chunks.length]);
+
const msPerWord = 60000 / state.selectedWpm;
const wordsRead =
state.status === 'idle' || state.totalWords === 0
@@ -46,8 +78,21 @@ export function useReadingSession(): UseReadingSessionResult {
});
};
+ const setWordsPerChunk = (value: number) => {
+ storageAPI.setWordCount(value);
+ dispatch({ type: 'setWordsPerChunk', wordsPerChunk: value });
+ };
+
const startReading = (totalWords: number) => {
+ // Create word array for the content
+ const wordArray = Array.from(
+ { length: totalWords },
+ (_, i) => `word${String(i + 1)}`,
+ );
+ const newChunks = generateWordChunks(wordArray, wordsPerChunk);
+
dispatch({ type: 'start', totalWords });
+ dispatch({ type: 'updateChunkState', totalChunks: newChunks.length });
};
const pauseReading = () => {
@@ -95,7 +140,15 @@ export function useReadingSession(): UseReadingSessionResult {
wordsRead,
progressPercent,
restartCount: state.restartCount,
+ // Multiple words display
+ currentChunkIndex: state.currentChunkIndex,
+ totalChunks: state.totalChunks,
+ wordsPerChunk: state.wordsPerChunk,
+ currentChunk,
+ chunks,
+ // Actions
setSelectedWpm,
+ setWordsPerChunk,
startReading,
pauseReading,
resumeReading,
From bdaa9ebc158fbb1863f675e1fab0c946d3c306b6 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 16:08:41 -0500
Subject: [PATCH 25/61] chore(components): complete T017 and T018
---
specs/001-multiple-words/tasks.md | 6 +--
src/components/App/App.tsx | 4 +-
src/components/App/readerTypes.ts | 2 +
src/components/App/sessionReducer.test.ts | 25 +++++++++----
src/components/App/sessionReducer.ts | 22 +++++++++--
src/components/App/useReadingSession.test.tsx | 10 ++++-
src/components/App/useReadingSession.ts | 37 +++++++------------
7 files changed, 65 insertions(+), 41 deletions(-)
diff --git a/specs/001-multiple-words/tasks.md b/specs/001-multiple-words/tasks.md
index 4d4de35..f2e80ee 100644
--- a/specs/001-multiple-words/tasks.md
+++ b/specs/001-multiple-words/tasks.md
@@ -58,9 +58,9 @@ This feature extends the speed reader application to display multiple words simu
### Services and Business Logic
-- [ ] T016 [US1] Extend useReadingSession hook with chunk state in src/components/App/useReadingSession.ts
-- [ ] T017 [US1] Implement chunk generation logic in useReadingSession hook
-- [ ] T018 [US1] Implement timing logic for chunks (same duration per chunk) in useReadingSession hook
+- [x] T016 [US1] Extend useReadingSession hook with chunk state in src/components/App/useReadingSession.ts
+- [x] T017 [US1] Implement chunk generation logic in useReadingSession hook
+- [x] T018 [US1] Implement timing logic for chunks (same duration per chunk) in useReadingSession hook
### UI Components
diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx
index b2787b9..df731a2 100644
--- a/src/components/App/App.tsx
+++ b/src/components/App/App.tsx
@@ -44,8 +44,8 @@ export default function App() {
return;
}
- const { totalWords } = tokenizeContent(text);
- startReading(totalWords);
+ const { totalWords, words } = tokenizeContent(text);
+ startReading(totalWords, words);
};
return (
diff --git a/src/components/App/readerTypes.ts b/src/components/App/readerTypes.ts
index 55c7b69..6c06cae 100644
--- a/src/components/App/readerTypes.ts
+++ b/src/components/App/readerTypes.ts
@@ -15,6 +15,8 @@ export interface ReadingSessionState {
currentChunkIndex: number;
totalChunks: number;
wordsPerChunk: number;
+ // Store the actual words for chunk generation
+ words: string[];
}
export interface ReadingSessionMetrics {
diff --git a/src/components/App/sessionReducer.test.ts b/src/components/App/sessionReducer.test.ts
index c4c42ba..ac9fee7 100644
--- a/src/components/App/sessionReducer.test.ts
+++ b/src/components/App/sessionReducer.test.ts
@@ -4,13 +4,13 @@ import {
type SessionReducerState,
} from './sessionReducer';
-function createRunningState(
- overrides: Partial = {},
-): SessionReducerState {
+function createRunningState(overrides: Partial = {}) {
return {
...createInitialSessionState(250),
- status: 'running',
+ status: 'running' as const,
totalWords: 3,
+ words: ['word1', 'word2', 'word3'],
+ totalChunks: 3, // 3 words with 1 word per chunk
...overrides,
};
}
@@ -29,6 +29,7 @@ describe('sessionReducer', () => {
currentChunkIndex: 0,
totalChunks: 0,
wordsPerChunk: 1,
+ words: [],
});
});
@@ -36,6 +37,7 @@ describe('sessionReducer', () => {
const started = sessionReducer(createInitialSessionState(250), {
type: 'start',
totalWords: 4,
+ words: ['word1', 'word2', 'word3', 'word4'],
});
expect(started).toMatchObject({
@@ -67,14 +69,23 @@ describe('sessionReducer', () => {
expect(ignoredResume.status).toBe('idle');
});
- it('advances words and completes on final transition', () => {
- const running = createRunningState({ totalWords: 2, currentWordIndex: 0 });
+ it('advances chunks and completes on final transition', () => {
+ const running = createRunningState({
+ totalWords: 2,
+ words: ['word1', 'word2'],
+ totalChunks: 2,
+ });
const afterFirstAdvance = sessionReducer(running, { type: 'advance' });
- expect(afterFirstAdvance.currentWordIndex).toBe(1);
+ // With 2 words and 1 word per chunk, we have 2 chunks (indices 0, 1)
+ // Starting at chunk 0, after first advance we move to chunk 1, still running
expect(afterFirstAdvance.status).toBe('running');
+ expect(afterFirstAdvance.currentChunkIndex).toBe(1);
+ expect(afterFirstAdvance.currentWordIndex).toBe(1);
+ // Second advance should complete (trying to go from chunk 1 to 2, but totalChunks is 2)
const completed = sessionReducer(afterFirstAdvance, { type: 'advance' });
expect(completed.status).toBe('completed');
+ expect(completed.currentChunkIndex).toBe(1);
expect(completed.currentWordIndex).toBe(1);
const ignoredAdvance = sessionReducer(createInitialSessionState(250), {
diff --git a/src/components/App/sessionReducer.ts b/src/components/App/sessionReducer.ts
index da2c182..153946e 100644
--- a/src/components/App/sessionReducer.ts
+++ b/src/components/App/sessionReducer.ts
@@ -7,7 +7,7 @@ export interface SessionReducerState extends ReadingSessionState {
}
export type SessionReducerAction =
- | { type: 'start'; totalWords: number }
+ | { type: 'start'; totalWords: number; words: string[] }
| { type: 'pause' }
| { type: 'resume' }
| { type: 'restart' }
@@ -38,6 +38,7 @@ export function createInitialSessionState(
currentChunkIndex: 0,
totalChunks: 0,
wordsPerChunk: 1,
+ words: [],
};
}
@@ -55,6 +56,9 @@ export function sessionReducer(
startCount: state.startCount + 1,
restartCount: 0,
totalWords: action.totalWords,
+ words: action.words,
+ // Reset chunk state when starting new session
+ currentChunkIndex: 0,
};
}
@@ -94,6 +98,7 @@ export function sessionReducer(
...state,
status: 'running',
currentWordIndex: 0,
+ currentChunkIndex: 0,
elapsedMs: 0,
restartCount: state.restartCount + 1,
};
@@ -111,18 +116,24 @@ export function sessionReducer(
return state;
}
- const nextWordIndex = state.currentWordIndex + 1;
- if (nextWordIndex >= state.totalWords) {
+ const nextChunkIndex = state.currentChunkIndex + 1;
+ if (nextChunkIndex >= state.totalChunks) {
return {
...state,
status: 'completed',
+ currentChunkIndex: Math.max(state.totalChunks - 1, 0),
+ // Set word index to the last word
currentWordIndex: Math.max(state.totalWords - 1, 0),
};
}
+ // Calculate the word index for the start of the next chunk
+ const nextWordIndex = nextChunkIndex * state.wordsPerChunk;
+
return {
...state,
- currentWordIndex: nextWordIndex,
+ currentChunkIndex: nextChunkIndex,
+ currentWordIndex: Math.min(nextWordIndex, state.totalWords - 1),
};
}
@@ -131,8 +142,11 @@ export function sessionReducer(
...state,
status: 'idle',
currentWordIndex: 0,
+ currentChunkIndex: 0,
elapsedMs: 0,
totalWords: 0,
+ words: [],
+ totalChunks: 0,
};
}
diff --git a/src/components/App/useReadingSession.test.tsx b/src/components/App/useReadingSession.test.tsx
index c2f6b58..00d4b52 100644
--- a/src/components/App/useReadingSession.test.tsx
+++ b/src/components/App/useReadingSession.test.tsx
@@ -23,7 +23,7 @@ describe('useReadingSession', () => {
expect(result.current.progressPercent).toBe(0);
act(() => {
- result.current.startReading(3);
+ result.current.startReading(3, ['word1', 'word2', 'word3']);
});
expect(result.current.status).toBe('running');
@@ -79,7 +79,13 @@ describe('useReadingSession', () => {
const { result } = renderHook(() => useReadingSession());
act(() => {
- result.current.startReading(5);
+ result.current.startReading(5, [
+ 'word1',
+ 'word2',
+ 'word3',
+ 'word4',
+ 'word5',
+ ]);
});
act(() => {
diff --git a/src/components/App/useReadingSession.ts b/src/components/App/useReadingSession.ts
index 21d6eb6..4fee37c 100644
--- a/src/components/App/useReadingSession.ts
+++ b/src/components/App/useReadingSession.ts
@@ -31,7 +31,7 @@ interface UseReadingSessionResult {
resumeReading: () => void;
setSelectedWpm: (value: number) => void;
setWordsPerChunk: (value: number) => void;
- startReading: (totalWords: number) => void;
+ startReading: (totalWords: number, words: string[]) => void;
}
export function useReadingSession(): UseReadingSessionResult {
@@ -41,18 +41,14 @@ export function useReadingSession(): UseReadingSessionResult {
createInitialSessionState,
);
- // Load saved word count preference
- const wordsPerChunk = storageAPI.getWordCount();
-
// Calculate chunks directly - React Compiler will optimize
let chunks: WordChunk[] = [];
- if (state.totalWords > 0 && state.wordsPerChunk > 0) {
- // Create a simple word array for demonstration
- const wordArray = Array.from(
- { length: state.totalWords },
- (_, i) => `word${String(i + 1)}`,
- );
- chunks = generateWordChunks(wordArray, state.wordsPerChunk);
+ if (
+ state.totalWords > 0 &&
+ state.wordsPerChunk > 0 &&
+ state.words.length > 0
+ ) {
+ chunks = generateWordChunks(state.words, state.wordsPerChunk);
}
const currentChunk = chunks[state.currentChunkIndex] ?? null;
@@ -83,16 +79,11 @@ export function useReadingSession(): UseReadingSessionResult {
dispatch({ type: 'setWordsPerChunk', wordsPerChunk: value });
};
- const startReading = (totalWords: number) => {
- // Create word array for the content
- const wordArray = Array.from(
- { length: totalWords },
- (_, i) => `word${String(i + 1)}`,
- );
- const newChunks = generateWordChunks(wordArray, wordsPerChunk);
-
- dispatch({ type: 'start', totalWords });
- dispatch({ type: 'updateChunkState', totalChunks: newChunks.length });
+ const startReading = (totalWords: number, words: string[]) => {
+ // Load saved word count preference when starting
+ const savedWordsPerChunk = storageAPI.getWordCount();
+ dispatch({ type: 'setWordsPerChunk', wordsPerChunk: savedWordsPerChunk });
+ dispatch({ type: 'start', totalWords, words });
};
const pauseReading = () => {
@@ -114,7 +105,7 @@ export function useReadingSession(): UseReadingSessionResult {
useEffect(() => {
if (
state.status !== 'running' ||
- state.currentWordIndex >= state.totalWords - 1
+ state.currentChunkIndex >= state.totalChunks - 1
) {
return;
}
@@ -127,7 +118,7 @@ export function useReadingSession(): UseReadingSessionResult {
return () => {
window.clearTimeout(timeoutId);
};
- }, [msPerWord, state.currentWordIndex, state.status, state.totalWords]);
+ }, [msPerWord, state.currentChunkIndex, state.status, state.totalChunks]);
return {
status: state.status,
From f3ea4873e1fddb64aa8fffc84f657663457d72cf Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 16:27:38 -0500
Subject: [PATCH 26/61] chore(components): implement T019, T020, and T021
---
specs/001-multiple-words/tasks.md | 6 +-
src/components/App/App.tsx | 5 +
.../ReadingDisplay/ReadingDisplay.test.tsx | 119 ++++++++++++++++--
.../ReadingDisplay/ReadingDisplay.tsx | 24 +++-
.../ReadingDisplay/ReadingDisplay.types.ts | 4 +
5 files changed, 144 insertions(+), 14 deletions(-)
diff --git a/specs/001-multiple-words/tasks.md b/specs/001-multiple-words/tasks.md
index f2e80ee..9e25ec6 100644
--- a/specs/001-multiple-words/tasks.md
+++ b/specs/001-multiple-words/tasks.md
@@ -64,9 +64,9 @@ This feature extends the speed reader application to display multiple words simu
### UI Components
-- [ ] T019 [US1] Update ReadingDisplay component for multi-word support in src/components/ReadingDisplay/ReadingDisplay.tsx
-- [ ] T020 [US1] Add text wrapping styles for multiple words in ReadingDisplay component
-- [ ] T021 [P] [US1] Update ReadingDisplay types in src/components/ReadingDisplay/ReadingDisplay.types.ts
+- [x] T019 [US1] Update ReadingDisplay component for multi-word support in src/components/ReadingDisplay/ReadingDisplay.tsx
+- [x] T020 [US1] Add text wrapping styles for multiple words in ReadingDisplay component
+- [x] T021 [P] [US1] Update ReadingDisplay types in src/components/ReadingDisplay/ReadingDisplay.types.ts
### Integration
diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx
index df731a2..b38ba1f 100644
--- a/src/components/App/App.tsx
+++ b/src/components/App/App.tsx
@@ -23,6 +23,9 @@ export default function App() {
status,
totalWords,
wordsRead,
+ // Multiple words display
+ currentChunk,
+ wordsPerChunk,
editText,
pauseReading,
restartReading,
@@ -70,6 +73,8 @@ export default function App() {
) : (
)}
diff --git a/src/components/ReadingDisplay/ReadingDisplay.test.tsx b/src/components/ReadingDisplay/ReadingDisplay.test.tsx
index b46e549..f42ab2b 100644
--- a/src/components/ReadingDisplay/ReadingDisplay.test.tsx
+++ b/src/components/ReadingDisplay/ReadingDisplay.test.tsx
@@ -4,8 +4,46 @@ import { describe, expect, it } from 'vitest';
import { ReadingDisplay } from './ReadingDisplay';
describe('ReadingDisplay', () => {
- it('renders current word when hasWords is true', () => {
- render();
+ it('renders current word when hasWords is true and wordsPerChunk is 1', () => {
+ render(
+ ,
+ );
+
+ const wordElement = screen.getByRole('status');
+ expect(wordElement).toBeInTheDocument();
+ expect(wordElement).toHaveTextContent('hello');
+ });
+
+ it('renders chunk text when wordsPerChunk > 1 and currentChunk is provided', () => {
+ const chunk = { text: 'hello world', words: ['hello', 'world'] };
+ render(
+ ,
+ );
+
+ const wordElement = screen.getByRole('status');
+ expect(wordElement).toBeInTheDocument();
+ expect(wordElement).toHaveTextContent('hello world');
+ });
+
+ it('renders current word when wordsPerChunk > 1 but currentChunk is null', () => {
+ render(
+ ,
+ );
const wordElement = screen.getByRole('status');
expect(wordElement).toBeInTheDocument();
@@ -13,7 +51,14 @@ describe('ReadingDisplay', () => {
});
it('renders empty word when hasWords is true but currentWord is empty', () => {
- render();
+ render(
+ ,
+ );
const wordElement = screen.getByRole('status');
expect(wordElement).toBeInTheDocument();
@@ -21,7 +66,14 @@ describe('ReadingDisplay', () => {
});
it('renders empty word when hasWords is false', () => {
- render();
+ render(
+ ,
+ );
const wordElement = screen.getByRole('status');
expect(wordElement).toBeInTheDocument();
@@ -29,7 +81,14 @@ describe('ReadingDisplay', () => {
});
it('has proper accessibility attributes', () => {
- render();
+ render(
+ ,
+ );
const wordElement = screen.getByRole('status');
expect(wordElement).toHaveAttribute('aria-live', 'polite');
@@ -37,7 +96,14 @@ describe('ReadingDisplay', () => {
});
it('has responsive styling classes', () => {
- render();
+ render(
+ ,
+ );
const displayContainer = document.querySelector('.flex.min-h-40');
expect(displayContainer).toBeInTheDocument();
@@ -47,7 +113,14 @@ describe('ReadingDisplay', () => {
});
it('has proper typography styling', () => {
- render();
+ render(
+ ,
+ );
const displayContainer = document.querySelector('.flex.min-h-40');
expect(displayContainer).toHaveClass(
@@ -59,4 +132,36 @@ describe('ReadingDisplay', () => {
'max-[480px]:text-[2rem]',
);
});
+
+ it('applies break-words class when wordsPerChunk > 1', () => {
+ const chunk = {
+ text: 'hello world test',
+ words: ['hello', 'world', 'test'],
+ };
+ render(
+ ,
+ );
+
+ const wordElement = screen.getByRole('status');
+ expect(wordElement).toHaveClass('break-words');
+ });
+
+ it('does not apply break-words class when wordsPerChunk is 1', () => {
+ render(
+ ,
+ );
+
+ const wordElement = screen.getByRole('status');
+ expect(wordElement).not.toHaveClass('break-words');
+ });
});
diff --git a/src/components/ReadingDisplay/ReadingDisplay.tsx b/src/components/ReadingDisplay/ReadingDisplay.tsx
index e44b0ad..e44f5b4 100644
--- a/src/components/ReadingDisplay/ReadingDisplay.tsx
+++ b/src/components/ReadingDisplay/ReadingDisplay.tsx
@@ -1,12 +1,28 @@
import type { ReadingDisplayProps } from './ReadingDisplay.types';
-export function ReadingDisplay({ currentWord, hasWords }: ReadingDisplayProps) {
- const displayWord = hasWords ? currentWord : '';
+export function ReadingDisplay({
+ currentWord,
+ currentChunk,
+ wordsPerChunk,
+ hasWords,
+}: ReadingDisplayProps) {
+ // Display chunk text if available and wordsPerChunk > 1, otherwise fall back to single word
+ const displayText =
+ currentChunk && wordsPerChunk > 1
+ ? currentChunk.text
+ : hasWords
+ ? currentWord
+ : '';
return (
-
- {displayWord}
+
1 ? 'break-words' : ''}
+ >
+ {displayText}
);
diff --git a/src/components/ReadingDisplay/ReadingDisplay.types.ts b/src/components/ReadingDisplay/ReadingDisplay.types.ts
index a97dbef..007155c 100644
--- a/src/components/ReadingDisplay/ReadingDisplay.types.ts
+++ b/src/components/ReadingDisplay/ReadingDisplay.types.ts
@@ -1,4 +1,8 @@
+import type { WordChunk } from './WordChunk.types';
+
export interface ReadingDisplayProps {
currentWord: string;
+ currentChunk: WordChunk | null;
+ wordsPerChunk: number;
hasWords: boolean;
}
From 5370146629eafc499e208b41f0402833a49d85d8 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 15 Feb 2026 16:35:34 -0500
Subject: [PATCH 27/61] docs(specs): remove chunks in session details
---
specs/001-multiple-words/spec.md | 22 ++++++++++++++++---
specs/001-multiple-words/tasks.md | 6 ++---
.../SessionDetails/SessionDetails.tsx | 1 +
3 files changed, 23 insertions(+), 6 deletions(-)
diff --git a/specs/001-multiple-words/spec.md b/specs/001-multiple-words/spec.md
index ac9c8d6..fc3362f 100644
--- a/specs/001-multiple-words/spec.md
+++ b/specs/001-multiple-words/spec.md
@@ -2,9 +2,25 @@
**Feature Branch**: `001-multiple-words`
**Created**: 2025-02-15
-**Status**: Draft
+**Status**: **Implemented - Phase 1 Complete**
**Input**: User description: "multiple words"
+## Implementation Summary
+
+**Phase 1 (User Story 1) - COMPLETED**: Multiple words display functionality with chunk-based reading:
+
+- ✅ Chunk generation and timing logic implemented
+- ✅ ReadingDisplay component supports multi-word display with text wrapping
+- ✅ Session state management for chunks
+- ✅ App integration with chunk display
+- ✅ SessionDetails simplified (chunk terminology removed)
+
+**Phase 2 (User Story 2) - PENDING**: Configurable word count UI controls
+
+- ⏳ Word Count dropdown in ControlPanel
+- ⏳ localStorage persistence for word count
+- ⏳ Word count change handlers
+
## Clarifications
### Session 2025-02-15
@@ -17,7 +33,7 @@
- Q: What label text should be displayed for the word count dropdown? → A: "Word Count"
- Q: How should text wrapping be implemented for overflowing word chunks? → A: Wrap text within fixed display area (multi-line)
- Q: Should the word count selection be saved and restored between sessions? → A: Yes, save to localStorage with key "speedreader.wordCount"
-- Q: How should the Session Details component be updated to reflect multiple words display? → A: If word count is 1, use "word". If word count is >1, use "chunk"
+- Q: How should the Session Details component be updated to reflect multiple words display? → A: Simplified to show only basic progress and tempo, removing chunk-specific information for cleaner UI
- Q: How should progress be recalculated when word count changes during a session? → A: Recalculate progress based on current position in text
- Q: How does word chunking handle user word count preferences vs natural language boundaries? → A: Simple sequential splitting by user word count, no complex natural language processing
@@ -107,7 +123,7 @@ As a speed reader, I want words to be grouped intelligently based on natural lan
- **FR-002**: System MUST provide a dropdown/select menu labeled "Word Count" positioned after the WPM slider for configuring words per chunk (1-5 words)
- **FR-009**: System MUST default to 1 word per chunk when display is first enabled
- **FR-010**: System MUST save word count selection to localStorage with key "speedreader.wordCount" and restore on page load
-- **FR-011**: System MUST display "word" terminology in Session Details when word count is 1, and "chunk" terminology when word count is >1
+- **FR-011**: System MUST display progress information in Session Details showing words read and total words
- **FR-012**: System MUST recalculate progress based on current position in text when word count changes during a session
- **FR-003**: System MUST group words by simple sequential splitting based on user word count preference, with no complex natural language processing
- **FR-004**: System MUST maintain consistent timing between word chunks based on WPM setting, using same total time per chunk regardless of word count
diff --git a/specs/001-multiple-words/tasks.md b/specs/001-multiple-words/tasks.md
index 9e25ec6..5b02ae8 100644
--- a/specs/001-multiple-words/tasks.md
+++ b/specs/001-multiple-words/tasks.md
@@ -70,9 +70,9 @@ This feature extends the speed reader application to display multiple words simu
### Integration
-- [ ] T022 [US1] Integrate chunk display in App component in src/components/App/App.tsx
-- [ ] T023 [US1] Update SessionDetails for chunk terminology in src/components/SessionDetails/SessionDetails.tsx
-- [ ] T024 [US1] Test end-to-end multiple words reading flow
+- [x] T022 [US1] Integrate chunk display in App component in src/components/App/App.tsx
+- [x] T023 [US1] Update SessionDetails for chunk terminology in src/components/SessionDetails/SessionDetails.tsx
+- [x] T024 [US1] Test end-to-end multiple words reading flow
## Phase 4: User Story 2 - Configurable Word Count (P2)
diff --git a/src/components/SessionDetails/SessionDetails.tsx b/src/components/SessionDetails/SessionDetails.tsx
index 20e0c66..50b0907 100644
--- a/src/components/SessionDetails/SessionDetails.tsx
+++ b/src/components/SessionDetails/SessionDetails.tsx
@@ -20,6 +20,7 @@ export function SessionDetails({
Progress: {wordsRead} / {totalWords}{' '}
({Math.round(progressPercent)}%)