diff --git a/.STATUS b/.STATUS index 2ccb8b0f..d4939ac7 100644 --- a/.STATUS +++ b/.STATUS @@ -1,15 +1,15 @@ --- status: active priority: P1 -version: 1.19.0 +version: 1.20.0 sprint: 37 started: 2026-01-08 -updated: 2026-02-23 -released: 2026-02-23 +updated: 2026-02-24 +released: 2026-02-24 editor: hybrid-markdown++ -next_focus: Settings Infrastructure Improvements (feature/settings-improvements) -tests: 2255 -unit_tests: 2255 +next_focus: Fix 67 test file TypeScript errors +tests: 2280 +unit_tests: 2280 e2e_tests: 109 test_file_errors: 67 (non-blocking, documented in docs/planning/TEST-FILE-TYPESCRIPT-ERRORS-2026-01-24.md) tags: @@ -26,10 +26,6 @@ tags: ## Current Focus (Sprint 37) -### In Progress - -1. **Settings Infrastructure Improvements** (WIP) — Extract reusable SettingsToggle, shortcut registry, usePreferences hook, pre-commit ORCHESTRATE guard. Branch: `feature/settings-improvements`. Spec: `docs/specs/SPEC-settings-improvements-2026-02-23.md`. ~3.5h total. - ### Next Up 1. **Fix 67 test file TypeScript errors** (~2.5h) — Non-blocking but noisy. Documented in `docs/planning/TEST-FILE-TYPESCRIPT-ERRORS-2026-01-24.md` @@ -49,14 +45,30 @@ tags: ## Recently Completed -### Pomodoro Focus Timer (2026-02-23) — PR #45 merged to dev +### Session Timer Removal (2026-02-24) — PR #48 + +- Removed legacy session timer from breadcrumb bar (⏸/▶/↺ controls) +- Removed `sessionStartTime` prop chain from 5 components +- StatsPanel Duration card → Pomodoro count from `usePomodoroStore` +- Cleaned 4 localStorage keys and ~50 lines orphaned CSS +- Net: -95 lines, 2,280 tests (2 session-duration tests removed) + +### Settings Infrastructure Improvements (2026-02-24) — PR #47 + +- Reusable `SettingsToggle` component with full accessibility +- `usePreferences` hook with cached state + event-based sync +- `SHORTCUTS` registry (25 shortcuts) with `matchesShortcut()` helper +- Migrated `SettingsModal.tsx` to `usePreferences` hook +- 27 new tests (2,282 total) + +### v1.19.0 (2026-02-23) — Pomodoro Focus Timer (Released) - Pomodoro timer in editor status bar (start/pause click, reset right-click) - Zustand store with symmetric callbacks: `tick(onComplete, onBreakComplete)` - Auto-save on work completion, gentle break toasts - Focus Timer settings in General tab (5 new preferences) - Auto-pin new projects to sidebar -- 62 new tests (2,255 total) +- 62 new tests (2,282 total) ### v1.18.0 (2026-02-22) — Sidebar Vault Expansion Fix + DexieError2 Fix @@ -127,8 +139,8 @@ tags: | Spec | Status | Target | |------|--------|--------| -| Pomodoro Timer | Implemented (PR #45) | v1.19.0 | -| Settings Improvements | In Progress | v1.19.0 | +| Pomodoro Timer | Released | v1.19.0 | +| Settings Improvements | In Progress | v1.20.0 | | Three-Tab Sidebar | Design Approved | v1.17.0 | | Quarto Enhancements | Partially Implemented | v1.15+ | | LaTeX Editor | Proposal | v2.0 | diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..db0cc9d7 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,11 @@ +#!/usr/bin/env sh + +# Guard: Block ORCHESTRATE files on dev/main branches +if git diff --cached --name-only | grep -q '^ORCHESTRATE-'; then + branch=$(git branch --show-current) + if [ "$branch" = "dev" ] || [ "$branch" = "main" ]; then + echo "⚠️ ORCHESTRATE files should not be committed to $branch" + echo " Remove with: git rm ORCHESTRATE-*.md" + exit 1 + fi +fi diff --git a/BRAINSTORM-quarto-code-chunks-2026-02-24.md b/BRAINSTORM-quarto-code-chunks-2026-02-24.md new file mode 100644 index 00000000..50d08e1f --- /dev/null +++ b/BRAINSTORM-quarto-code-chunks-2026-02-24.md @@ -0,0 +1,184 @@ +# Brainstorm: VS Code-Style Quarto Code Chunks + +> **Date:** 2026-02-24 +> **Mode:** max | feat | save +> **Duration:** ~12 min (research + 2 agents) +> **Topic:** Implement VS Code Quarto-style code chunks with distinct background and code font + +## Problem Statement + +Scribe's code chunks (`\`\`\`{r}`, `\`\`\`{python}`) are nearly invisible. The current `rgba(0,0,0,0.04)` background blends into prose, making it hard to scan between text and code in long `.qmd` documents. VS Code's Quarto extension uses clear visual separation — distinct background color, monospace font, language indicators — that lets writers instantly identify code regions while editing. + +## Current State (Scribe) + +| Aspect | Current Implementation | +|--------|----------------------| +| **Background** | `rgba(0,0,0,0.04)` / `rgba(255,255,255,0.04)` — barely visible | +| **Font** | JetBrains Mono via CSS, hardcoded in `index.css` | +| **Detection** | CSS `:has(.tok-codeFence)` selectors (no ViewPlugin) | +| **Left border** | 2px solid accent color | +| **Theme integration** | `.dark` class override — not wired to theme CSS variables | +| **Settings** | No code font preference (only prose font is configurable) | +| **Chunk options** | `#| lines` styled via `:has(.tok-comment):has(.tok-monospace)` | +| **Language badge** | None | +| **Files** | `index.css:6231-6276`, `CodeMirrorEditor.tsx` (no dedicated plugin) | + +## VS Code Quarto Reference + +| Aspect | VS Code Implementation | +|--------|----------------------| +| **Background** | `#E1E1E166` light / `#40404066` dark — clearly visible | +| **Font** | Fixed-width from `--vscode-editor-font-family` | +| **Detection** | Markdown engine token parser via `background.ts` | +| **Decoration** | `TextEditorDecorationType` with `isWholeLine: true` | +| **CodeLens** | "Run Cell", "Run Next Cell", "Run Above" buttons above chunks | +| **Theme** | Uses `notebook.selectedCellBackground` / `notebook.cellEditorBackground` | +| **Config** | Background mode: default, theme-based, or off | + +Sources: +- [Quarto VS Code Extension](https://marketplace.visualstudio.com/items?itemName=quarto.quarto) +- [Quarto HTML Code Blocks docs](https://quarto.org/docs/output-formats/html-code.html) +- [VS Code Notebook Editor](https://quarto.org/docs/tools/vscode/notebook.html) +- [quarto-dev/quarto GitHub: background.ts](https://github.com/quarto-dev/quarto/blob/main/apps/vscode/src/providers/background.ts) +- [quarto-dev/quarto GitHub: cell/codelens.ts](https://github.com/quarto-dev/quarto/blob/main/apps/vscode/src/providers/cell/codelens.ts) +- [quarto-dev/quarto GitHub: theme.ts](https://github.com/quarto-dev/quarto/blob/main/apps/vscode-editor/src/theme.ts) + +## Quick Wins (< 30 min each) + +1. **Bump background opacity** — Change `rgba(0,0,0,0.04)` to `rgba(0,0,0,0.08)` in CSS. Zero-risk, immediate improvement. +2. **Increase left border to 3px** — Thicker accent stripe improves scannability. +3. **Add top/bottom margin on fence lines** — 8-12px spacing creates visual "breathing room" around chunks. + +## Medium Effort (1-2 hours each) + +4. **Add `--nexus-code-bg` CSS variable** — Computed in `applyTheme()` from existing `bgTertiary` values. Auto-adapts to all 10 themes. +5. **Add `--code-font-family` CSS variable** — Separate code font from prose font, applied via `applyFontSettings()`. +6. **Create `CodeChunkDecorationPlugin`** — ViewPlugin using `Decoration.line()` pattern (identical to existing callout system at line 554-597 of CodeMirrorEditor.tsx). + +## Long-term (Future sessions) + +7. **Language badge widget** — Small `[R]` / `[PY]` pill on fence lines. Uses existing `WidgetType` pattern. +8. **Code font Settings UI** — Picker in Settings > Editor tab, filtered to mono fonts. +9. **Chunk option collapsing** — Fold `#|` lines when cursor isn't on them (like heading mark hiding). +10. **Code chunk line numbers** — Optional gutter numbers inside chunks only. + +## Architecture Decision: ViewPlugin vs CSS `:has()` + +**Decision: ViewPlugin for Quarto chunks, CSS `:has()` fallback for plain fences.** + +| Approach | Pros | Cons | +|----------|------|------| +| CSS `:has()` only | Simple, no JS | Can't extend bg beyond text, no badge widgets, browser compat edge cases | +| ViewPlugin only | Full control, proven pattern in callouts | More code, need sort order discipline | +| **Hybrid (recommended)** | Best of both — ViewPlugin for Quarto, CSS fallback for `\`\`\`js` | Two code paths | + +The hybrid approach matches how the codebase already works: callouts use `Decoration.line()` for rich treatment, while basic blockquotes use CSS. + +## Color Strategy + +**Approach: Derive `--nexus-code-bg` from `bgTertiary` in `applyTheme()`** + +For dark themes: `rgba(bgTertiary, 0.45)` — subtle but clearly distinct from `bgPrimary` +For light themes: `rgba(bgTertiary, 0.5)` — visible tint without harsh contrast + +This auto-adapts to all 10 themes + custom themes without per-theme configuration. + +**Alternative considered:** `color-mix(in srgb, var(--nexus-bg-tertiary) 60%, var(--nexus-bg-primary) 40%)` — cleaner CSS but computed in CSS, not JS. Works in modern browsers but CSS `color-mix()` requires Chromium 111+ / Safari 16.2+. + +## Font Strategy + +| Element | Font | Size | Line Height | +|---------|------|------|-------------| +| Prose | User's choice (default: system sans, 15px) | `--editor-font-size` | 1.8 | +| Code content | User's choice from mono fonts (default: JetBrains Mono) | `calc(editor-size * 0.88)` → ~13px | 1.5 | +| Chunk options (`#|`) | Same as code, but smaller | `calc(editor-size * 0.82)` → ~12px | 1.5 | +| Fence lines | Same as code | Same as code | 1.5 | + +The font size reduction (88%) plus line-height change (1.8→1.5) creates clear visual separation without being jarring. + +## What NOT to Include + +| VS Code Feature | Why Skip for Scribe | +|----------------|---------------------| +| Run Cell / CodeLens buttons | Scribe is for writing, not execution. VS Code handles running. | +| Output cells | Quarto renders outputs separately. No inline output. | +| Cell toolbar | Adds IDE complexity. Scribe is distraction-free. | +| Gutter line numbers in chunks | Adds visual noise. Maybe as future opt-in. | +| Drag handles | Not applicable to writing flow. | +| Cell execution status | Scribe doesn't execute code. | + +## Agent Analysis Summary + +### Frontend Architect Agent + +Key findings: +- The existing `RichMarkdownPlugin` (line 440-658) is the exact pattern to follow +- `FencedCode` → `CodeInfo` syntax tree nodes provide language detection +- `Decoration.line()` requires `range(line.from)` not a range — critical detail +- Decorations MUST be sorted by `.from` position before `Decoration.set()` +- `createEditorTheme()` is the canonical place for decoration class styles +- `hexToRgb()` already exists at line 585 for color computation +- `--nexus-code-bg` must be a complete `rgba()` string (CSS can't apply alpha to a variable) + +### UX Designer Agent + +Key findings: +- Current `rgba(0,0,0,0.04)` is invisible — needs 4-8x increase +- `color-mix()` approach auto-adapts to all themes without JS +- Code font should be `--editor-font-size - 1px` (14px at default 15) +- Chunk options (`#|`) should be italic + slightly smaller for hierarchy +- Left border should increase from 2px→3px at 40% accent opacity +- Language badge: `[R]`/`[PY]`/`[JL]` pill, 11px uppercase, always visible at 70% opacity +- ADHD consideration: badge is "wayfinding anchor" for scanning — answers "what kind?" at a glance +- Avoid all IDE chrome (run buttons, toolbars, status indicators) + +## Implementation Phases + +### Phase 1: CSS Variables Foundation (themes.ts) +- Add `--nexus-code-bg` and `--nexus-code-border` in `applyTheme()` +- Add `codeFamily`/`codeSize` to `FontSettings` +- Add `--code-font-family` and `--code-font-size-ratio` in `applyFontSettings()` +- Add `'jetbrains-mono'` to `FONT_FAMILIES` registry + +### Phase 2: ViewPlugin Core (CodeMirrorEditor.tsx) +- Add `LanguageBadgeWidget extends WidgetType` +- Add `CodeChunkDecorationPlugin` with `computeDecorations()` +- Register in extensions array + +### Phase 3: Theme Styles (CodeMirrorEditor.tsx) +- Add `.cm-quarto-*` classes in `createEditorTheme()` +- Test across 4+ themes (dark + light) + +### Phase 4: CSS Cleanup (index.css) +- Replace lines 6231-6276 with simplified plain-fence fallback +- Remove `.dark` overrides + +### Phase 5: Settings UI (EditorSettingsTab.tsx) +- Add Code Font section with mono-filtered picker +- Add size ratio slider (0.75-1.0) + +### Phase 6: Validation +- Cursor-on-fence behavior +- Theme switching with visible chunks +- Nested/adjacent chunks +- Full test suite + +## Files Affected + +| File | Changes | +|------|---------| +| `src/renderer/src/lib/themes.ts` | `FontSettings` interface, `DEFAULT_FONT_SETTINGS`, `FONT_FAMILIES`, `applyTheme()`, `applyFontSettings()` | +| `src/renderer/src/components/CodeMirrorEditor.tsx` | New `CodeChunkDecorationPlugin`, `LanguageBadgeWidget`, `createEditorTheme()` styles, extensions array | +| `src/renderer/src/index.css` | Replace lines 6231-6276 with fallback-only CSS | +| `src/renderer/src/components/Settings/EditorSettingsTab.tsx` | Add Code Font picker section | + +## Recommended Path + +> Start with Phase 1 (CSS variables) + Phase 4 (CSS cleanup) as a single commit. This alone makes code chunks visibly distinct across all themes with zero new JS. Then Phase 2+3 in a second commit for the ViewPlugin. Phase 5 (settings UI) can be a separate PR. + +## Open Questions + +1. Should `color-mix()` (CSS-only) or `rgba()` computation (JS in applyTheme) be used? The JS approach is more compatible but requires the `hexToRgb()` helper. +2. Should language badges be cursor-reveal (hide when editing fence line) like heading marks? +3. Should chunk option `#|` lines collapse when cursor isn't on them? +4. What about inline executable code (`\`{r} 1+1\``)? The VS Code extension styles these too. diff --git a/CHANGELOG.md b/CHANGELOG.md index ad847bef..a1beb5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [v1.20.0] - 2026-02-24 — Settings Infrastructure & Timer Cleanup + +### Added + +- **`SettingsToggle` component** — Reusable toggle switch with `role="switch"`, `aria-checked`, and `aria-label` accessibility attributes. Used by GeneralSettingsTab and EditorSettingsTab. +- **`usePreferences` hook** — Cached preferences via React state with `preferences-changed` event listener for cross-component sync. Provides `prefs`, `updatePref()`, and `togglePref()`. +- **`SHORTCUTS` registry** — Single source of truth for 25 keyboard shortcuts with `matchesShortcut(event, id)` helper for event matching. Replaces manual `e.metaKey && e.key` checks. + +### Changed + +- **StatsPanel Session section** — Duration card replaced with Pomodoro count card showing today's completed sessions from `usePomodoroStore`. +- **WritingProgress** — No longer displays session elapsed time; shows word delta, progress bar, and streak only. +- Migrated `SettingsModal.tsx` to use `usePreferences` hook (removed raw `loadPreferences` calls) + +### Removed + +- **Session timer** — Removed the legacy session timer from the breadcrumb bar (⏸/▶/↺ controls). Raw elapsed time persisted via localStorage causing confusing values like "2296:20" across restarts. +- **sessionStartTime prop chain** — Removed from App.tsx, EditorOrchestrator, HybridEditor, WritingProgress, and StatsPanel interfaces. +- **4 localStorage keys** — `sessionStart`, `timerPaused`, `pausedDuration`, `pauseStart`. +- **~50 lines of CSS** — Orphaned `.focus-timer`, `.timer-btn`, `.timer-value` styles. + +--- + ## [v1.19.0] - 2026-02-23 — Pomodoro Focus Timer ### Added diff --git a/README.md b/README.md index b809bab5..a41ca757 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ > **ADHD-Friendly Distraction-Free Writer** [![Status](https://img.shields.io/badge/status-active-brightgreen)]() -[![Version](https://img.shields.io/badge/version-1.19.0-blue)]() +[![Version](https://img.shields.io/badge/version-1.20.0-blue)]() [![Progress](https://img.shields.io/badge/progress-100%25-brightgreen)]() -[![Tests](https://img.shields.io/badge/tests-2255%20passing-brightgreen)]() +[![Tests](https://img.shields.io/badge/tests-2280%20passing-brightgreen)]() [![Tauri](https://img.shields.io/badge/tauri-2-blue)]() [![React](https://img.shields.io/badge/react-18-blue)]() @@ -240,7 +240,7 @@ scribe/ ## Test Coverage -**2,255 tests passing** across test files: +**2,280 tests passing** across test files: | Test File | Tests | |-----------|-------| diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8ec92700..1f8abc66 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,6 +106,45 @@ graph TD --- +## Preferences System + +The preferences system provides lightweight, event-driven state management +for user settings that persist to `localStorage`. It operates alongside +the Zustand store but uses a simpler read/write model suited to key-value +preferences. + +```mermaid +flowchart LR + LS[localStorage] -->|loadPreferences| HP[usePreferences hook] + HP -->|React state| C[Components] + UP[updatePreferences / togglePref] -->|write-through| LS + UP -->|dispatch| E["preferences-changed event"] + E -->|listener| HP +``` + +### Key Modules + +| Module | File | Purpose | +|--------|------|---------| +| `preferences.ts` | `src/renderer/src/lib/preferences.ts` | Load, save, merge preferences to localStorage | +| `usePreferences()` | `src/renderer/src/hooks/usePreferences.ts` | React hook — cached reads, event-based sync across components | +| `shortcuts.ts` | `src/renderer/src/lib/shortcuts.ts` | `SHORTCUTS` registry — single source of truth for all 27 keyboard shortcuts | +| `matchesShortcut()` | `src/renderer/src/lib/shortcuts.ts` | Registry-based `KeyboardEvent` matching (cmd/shift/alt modifiers) | +| `SettingsToggle` | `src/renderer/src/components/Settings/SettingsToggle.tsx` | Reusable toggle switch component for boolean settings | + +### How It Works + +1. **`loadPreferences()`** reads from `localStorage`, merges with defaults so new fields get sensible values on upgrade. +2. **`usePreferences()`** calls `loadPreferences()` once on mount and caches the result in React state. It listens for the `preferences-changed` custom event to stay in sync with other components. +3. **`updatePreferences()` / `togglePref()`** write-through to `localStorage` and dispatch `preferences-changed`, triggering all mounted hooks to re-read. +4. **`SHORTCUTS`** defines every keyboard shortcut with `key`, `mod`, and `label`. UI components reference `SHORTCUTS.xxx.label` for display; handlers call `matchesShortcut(event, shortcutId)` instead of manual key checks. + +### Pre-commit ORCHESTRATE Guard + +A husky `pre-commit` hook prevents accidental commits of `ORCHESTRATE-*.md` planning files to protected branches. These files belong on feature branches during development and are cleaned up on merge. + +--- + ## Data Flow ```mermaid @@ -206,8 +245,13 @@ scribe/ │ │ ├── SimpleTagAutocomplete.tsx │ │ └── CitationAutocomplete.tsx │ │ +│ ├── hooks/ +│ │ └── usePreferences.ts # Cached prefs hook (event sync) +│ │ │ ├── lib/ │ │ ├── api.ts # Tauri IPC wrapper +│ │ ├── preferences.ts # localStorage preferences R/W +│ │ ├── shortcuts.ts # SHORTCUTS registry + matcher │ │ ├── themes.ts # Theme definitions │ │ └── mathjax.ts # KaTeX processing │ │ diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 7bbf6dc1..2b8062bc 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -12,7 +12,8 @@ Main application component. Manages: - Note selection and editing - Sidebar visibility - Focus mode -- Keyboard shortcuts +- Keyboard shortcuts (delegated to `KeyboardShortcutHandler`) +- User preferences via `usePreferences()` hook **Key State:** ```typescript @@ -174,6 +175,66 @@ Application settings dialog. --- +### SettingsToggle.tsx + +Reusable toggle switch for boolean settings. + +**Props:** +```typescript +interface SettingsToggleProps { + label: string + description: string + checked: boolean + onChange: () => void + testId?: string +} +``` + +**Features:** +- Consistent label + description layout +- Animated toggle knob (accent color when on) +- WCAG accessible: `role="switch"`, `aria-checked`, `aria-label` +- Used by `GeneralSettingsTab` and `EditorSettingsTab` + +**File:** `src/renderer/src/components/Settings/SettingsToggle.tsx` + +--- + +### usePreferences (Hook) + +Cached preferences hook with event-based sync. + +**Returns:** +```typescript +{ + prefs: UserPreferences // Current preferences (cached) + updatePref(key, value) // Update a single preference + togglePref(key) // Toggle a boolean preference +} +``` + +**Behavior:** +- Reads `localStorage` once on mount, caches in React state +- Listens for `preferences-changed` events to stay in sync across components +- Write-through: `updatePref` / `togglePref` immediately persist to `localStorage` + +**File:** `src/renderer/src/hooks/usePreferences.ts` + +--- + +### KeyboardShortcutHandler.tsx + +Global keyboard shortcut handler (extracted from App.tsx). + +**Features:** +- Handles 25 registered shortcuts +- Uses `matchesShortcut(event, shortcutId)` for registry-based event matching +- Manages Tauri menu registration + +**File:** `src/renderer/src/components/KeyboardShortcutHandler.tsx` + +--- + ## Autocomplete Components ### SimpleWikiLinkAutocomplete.tsx diff --git a/docs/ROADMAP-CONSOLIDATED-2026-01-08.md b/docs/ROADMAP-CONSOLIDATED-2026-01-08.md index 38953955..03e2e65c 100644 --- a/docs/ROADMAP-CONSOLIDATED-2026-01-08.md +++ b/docs/ROADMAP-CONSOLIDATED-2026-01-08.md @@ -650,7 +650,7 @@ async fn compile_latex(tex_path: &Path) -> Result { | Sync status indicator | 30m | [ ] Pending | | Streak display | 30m | [ ] Pending | | Words today counter | 30m | [ ] Pending | -| Session timer (move) | 15m | [ ] Pending | +| Pomodoro timer (status bar) | — | [x] Done (v1.19.0) | | Editor mode indicator | 15m | [ ] Pending | | Zoom level control | 15m | [ ] Pending | diff --git a/docs/SCRIBE-DOCUMENTATION.md b/docs/SCRIBE-DOCUMENTATION.md index fc44da44..4e419791 100644 --- a/docs/SCRIBE-DOCUMENTATION.md +++ b/docs/SCRIBE-DOCUMENTATION.md @@ -1,8 +1,8 @@ # Scribe — Comprehensive Technical Documentation -**Version:** 1.19 (Pomodoro release) -**Last Updated:** 2026-02-23 -**Branch:** feature/pomodoro +**Version:** 1.19.1 (Settings Infrastructure) +**Last Updated:** 2026-02-24 +**Branch:** dev --- diff --git a/docs/archive/brainstorms/BRAINSTORM-session-timer-removal-2026-02-24.md b/docs/archive/brainstorms/BRAINSTORM-session-timer-removal-2026-02-24.md new file mode 100644 index 00000000..d0154f08 --- /dev/null +++ b/docs/archive/brainstorms/BRAINSTORM-session-timer-removal-2026-02-24.md @@ -0,0 +1,86 @@ +# Brainstorm: Session Timer Removal & Pomodoro Consolidation + +**Date:** 2026-02-24 +**Mode:** deep feature +**Decision:** Remove session timer entirely, keep Pomodoro in status bar + +## Problem Statement + +Two overlapping timers in the UI cause confusion: +- **Session timer** (top-right breadcrumb): Counts up from app open, persists via localStorage, shows absurd values like `2296:20` across restarts +- **Pomodoro timer** (status bar): Countdown work/break cycles, meaningful focus tracking + +The session timer is confusing (two timers), useless (raw elapsed time has no value), and buggy (persists forever via localStorage). + +## Decisions + +1. **Remove all session time tracking** — word count + Pomodoro count are enough context +2. **Keep Pomodoro in status bar only** — subtle, near word count, correct location +3. **Replace StatsPanel "Duration" card** with Pomodoro completed count from store +4. **Full cleanup** of ~80 lines of raw useState/localStorage from App.tsx + +## Implementation Plan (Bottom-Up) + +### Step 1: Strip Leaf Components + +**WritingProgress.tsx:** +- Remove `sessionStartTime` prop +- Remove `sessionDuration` state and interval +- Remove Clock icon + duration display +- Keep: word delta, progress bar, streak, milestones + +**StatsPanel.tsx:** +- Remove `sessionStartTime` prop and `sessionDuration` memo +- Remove "Duration" card from Session section +- Add `usePomodoroStore(s => s.completedToday)` subscription +- Replace Duration card with "🍅 X completed" card + +### Step 2: Strip Intermediate Components + +**EditorOrchestrator.tsx:** +- Remove `sessionStartTime` from props interface +- Remove prop pass-through to children + +**HybridEditor.tsx:** +- Remove `sessionStartTime` from props interface +- Remove prop pass-through to WritingProgress + +### Step 3: Clean Up App.tsx + +Remove from state: +- `sessionStart` + localStorage persistence +- `sessionDuration` + interval +- `timerPaused` + localStorage persistence +- `pausedDuration` + localStorage persistence +- `formatSessionTime()` function +- `toggleTimerPause()` function +- `resetTimer()` function + +Remove from UI: +- `.focus-timer` div in breadcrumb bar (⏸/▶ + ↺ controls) + +Remove localStorage keys: +- `sessionStart` +- `timerPaused` +- `pausedDuration` +- `pauseStart` + +### Step 4: Update Tests + +- `StatsPanel.test.tsx` — remove sessionStartTime prop, add Pomodoro count assertions +- `EditorOrchestrator.test.tsx` — remove sessionStartTime from mock props +- Any WritingProgress tests — remove sessionStartTime + +## Files Affected + +| File | Action | ~Lines | +|------|--------|--------| +| `App.tsx` | Remove state, effects, UI | -80 | +| `WritingProgress.tsx` | Remove session timer section | -25 | +| `StatsPanel.tsx` | Replace Duration with Pomodoro | ±15 | +| `HybridEditor.tsx` | Remove prop pass-through | -5 | +| `EditorOrchestrator.tsx` | Remove prop pass-through | -5 | +| `StatsPanel.test.tsx` | Update props + assertions | ±10 | +| `EditorOrchestrator.test.tsx` | Update mock props | -2 | + +**Net:** ~-110 lines removed, ~+15 added = **~-95 lines** diff --git a/docs/guide/features.md b/docs/guide/features.md index a4b58fd0..49b0737b 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -166,16 +166,16 @@ Configure in Settings → Writing → Daily Note Template. **ADHD-optimized settings system** with fuzzy search, visual theme gallery, and Quick Actions customization. -### 5 Settings Categories +### 6 Settings Categories | Category | Contents | |----------|----------| -| **General** | Focus Timer (Pomodoro), auto-save, startup behavior | -| **Editor** | Font, spacing, line height, ligatures, focus mode | -| **Themes** | Visual theme gallery (8 themes with previews) | -| **AI & Workflow** | Quick Actions, chat history, @ references | -| **Projects** | Project templates, defaults, daily notes | -| **Advanced** | Performance, data management, export/import | +| **General** | Focus Timer (Pomodoro), startup behavior, streak display, ADHD features | +| **Editor** | Font, spacing, readable line length, spellcheck | +| **Appearance** | UI style, dark/light theme galleries (10 themes) | +| **Files** | File management settings | +| **Academic** | Citations, Zotero, export formats | +| **Icon Bar** | Sidebar icon configuration | ### Fuzzy Search @@ -187,7 +187,7 @@ Search all settings instantly: ### Theme Gallery -**8 built-in themes** with visual previews: +**10 built-in themes** with visual previews: **Favorites:** Slate, Nord, Dracula **Dark:** Monokai, GitHub Dark diff --git a/docs/guide/shortcuts.md b/docs/guide/shortcuts.md index fe025dc1..6f69d77e 100644 --- a/docs/guide/shortcuts.md +++ b/docs/guide/shortcuts.md @@ -61,7 +61,7 @@ | Action | Shortcut | |--------|----------| | **New Note** | `⌘N` | -| **New Project** | `⌘⇧N` | +| **New Project** | `⌘⇧N` (in-app) | | **Daily Note** | `⌘D` | | **Search Notes** | `⌘F` | | **Quick Capture** | `⌘⇧C` | @@ -129,6 +129,46 @@ Press `⌘K` then type: --- +## Developer Notes + +All keyboard shortcuts are defined in a single registry file: + +**File:** `src/renderer/src/lib/shortcuts.ts` + +### SHORTCUTS Registry + +The `SHORTCUTS` object is the single source of truth for key bindings, +modifier keys, and display labels. There are currently **25 registered +shortcuts** covering notes, navigation, editor modes, sidebars, and +system actions. + +**Display labels:** Use `SHORTCUTS.xxx.label` to render the human-readable +shortcut string (e.g., `SHORTCUTS.focusMode.label` returns `"⌘⇧F"`). + +**Event matching:** Use `matchesShortcut(event, shortcutId)` in keyboard +event handlers instead of manual `event.metaKey && event.key === '...'` +checks. The function handles `cmd`, `shift`, and `alt` modifier +combinations automatically. + +```typescript +import { SHORTCUTS, matchesShortcut } from '../lib/shortcuts' + +// Display +{SHORTCUTS.focusMode.label} // "⌘⇧F" + +// Match +function handleKeyDown(e: KeyboardEvent) { + if (matchesShortcut(e, 'focusMode')) { + toggleFocusMode() + } +} +``` + +When a shortcut keybinding changes, update it in `shortcuts.ts` and both +UI labels and event matching update automatically. + +--- + ## Next Steps [Editor Guide :material-arrow-right:](editor.md){ .md-button .md-button--primary } diff --git a/docs/index.md b/docs/index.md index 0961d9ca..3605c68b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,9 +3,9 @@ > **ADHD-Friendly Distraction-Free Writer** ![Status](https://img.shields.io/badge/status-active-brightgreen) -![Version](https://img.shields.io/badge/version-1.19.0-blue) +![Version](https://img.shields.io/badge/version-1.20.0-blue) ![Progress](https://img.shields.io/badge/progress-100%25-brightgreen) -![Tests](https://img.shields.io/badge/tests-2255%20passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-2280%20passing-brightgreen) --- @@ -71,7 +71,7 @@ cd scribe && npm install && npm run dev --- - Fuzzy search settings (⌘,), visual theme gallery (8 themes), Quick Actions customization, project templates + Fuzzy search settings (⌘,), visual theme gallery (10 themes), Quick Actions customization, project templates - :material-folder-multiple:{ .lg .middle } **Icon-Centric Sidebar** @@ -101,7 +101,7 @@ cd scribe && npm install && npm run dev --- - Clean component extraction, 2,255 tests passing, 0 TypeScript errors in production + Clean component extraction, 2,280 tests passing, 0 TypeScript errors in production @@ -140,7 +140,7 @@ cd scribe && npm install && npm run dev ⌘W closes (auto-saves). ⌘Z always works. No confirmation dialogs. !!! tip "Visible Progress" - Word count always visible. Session timer. Streak indicators. + Word count always visible. Pomodoro timer. Streak indicators. !!! tip "Sensory-Friendly" Dark mode default. No distracting animations. Muted colors, high contrast text. @@ -155,7 +155,7 @@ cd scribe && npm install && npm run dev | **Settings** | ⌘, | | **Command Palette** | ⌘K | | **New Note** | ⌘N | -| **New Project** | ⌘⇧N | +| **New Project** | ⌘⇧N (in-app) | | **Daily Note** | ⌘D | | **Focus Mode** | ⌘⇧F | | **Toggle Preview** | ⌘E | diff --git a/docs/reference/CLAUDE.md b/docs/reference/CLAUDE.md index 3585ad3f..ddcb2f9d 100644 --- a/docs/reference/CLAUDE.md +++ b/docs/reference/CLAUDE.md @@ -167,21 +167,28 @@ scribe/ │ └── renderer/src/ # React frontend │ ├── components/ │ │ ├── MissionControl/ # Mission Control HUD sidebar -│ │ ├── Settings/ # Modular settings components [NEW] +│ │ ├── Settings/ # Modular settings components │ │ │ ├── GeneralSettingsTab.tsx │ │ │ ├── EditorSettingsTab.tsx +│ │ │ ├── SettingsToggle.tsx # Reusable toggle (role=switch) [v1.19.1] │ │ │ └── SettingsSection.tsx -│ │ ├── EditorOrchestrator.tsx # Editor rendering logic [NEW] -│ │ ├── KeyboardShortcutHandler.tsx # Global shortcuts [NEW] +│ │ ├── PomodoroTimer.tsx # Focus timer in status bar [v1.19.0] +│ │ ├── EditorOrchestrator.tsx # Editor rendering logic +│ │ ├── KeyboardShortcutHandler.tsx # Global shortcuts │ │ ├── Editor/ # BlockNote editor │ │ └── ... +│ ├── hooks/ # React hooks +│ │ └── usePreferences.ts # Cached prefs + event sync [v1.19.1] │ ├── lib/ # Core utilities │ │ ├── api.ts # API factory (Tauri/Browser) +│ │ ├── shortcuts.ts # 25-shortcut registry [v1.19.1] │ │ ├── platform.ts # Runtime detection (isTauri/isBrowser) │ │ ├── browser-api.ts # IndexedDB API (46 operations) │ │ ├── browser-db.ts # Dexie.js schema + seed data │ │ └── browser-dialogs.ts # Browser dialog fallbacks │ ├── store/ # Zustand state +│ │ ├── useAppViewStore.ts # Sidebar + UI state +│ │ └── usePomodoroStore.ts # Pomodoro timer state [v1.19.0] │ └── types/ # TypeScript types ``` @@ -240,21 +247,36 @@ scribe help --all # Full reference --- -## 🎯 Current Status: v1.19.0 - Pomodoro Focus Timer ✅ +## 🎯 Current Status: v1.20.0 - Settings & Timer Cleanup ✅ -**Released:** v1.19.0 (stable) +**Released:** v1.20.0 (stable) **Install:** `brew install --cask data-wise/tap/scribe` -**Install Stable:** `brew install --cask data-wise/tap/scribe` (v1.14.0) -**Tests:** 2,255 passing (73 files) +**Tests:** 2,280 passing (76 files) -### Latest Work: Pomodoro Focus Timer (PR #45) +### Latest Work: Session Timer Removal (PR #48) + +- ✅ Removed legacy session timer from breadcrumb bar (⏸/▶/↺ controls) +- ✅ Removed `sessionStartTime` prop chain from 5 components +- ✅ StatsPanel Duration card → Pomodoro count from `usePomodoroStore` +- ✅ Cleaned 4 localStorage keys and ~50 lines orphaned CSS +- ✅ Net: -95 lines, 2 session-duration tests removed (2,280 total) + +### Previous: Settings Infrastructure Improvements (PR #47) + +- ✅ `SettingsToggle` reusable component with accessibility (`role="switch"`, `aria-checked`, `aria-label`) +- ✅ `usePreferences` hook — cached preferences with event-based cross-component sync +- ✅ `SHORTCUTS` registry (25 shortcuts) with `matchesShortcut()` helper +- ✅ Migrated `SettingsModal.tsx` to `usePreferences` hook +- ✅ 27 new tests (2,282 total) + +### Previous: Pomodoro Focus Timer (PR #45) - ✅ Status bar countdown timer (start/pause click, right-click reset) - ✅ Zustand store with symmetric callbacks: `tick(onComplete, onBreakComplete)` - ✅ Auto-save on work completion, gentle break toasts - ✅ Focus Timer settings in General tab (5 new preferences) - ✅ Auto-pin new projects to sidebar -- ✅ 62 new tests (2,255 total) +- ✅ 62 new tests (2,282 total) ### Previous: Sidebar Vault Expansion Fix (PR #43) @@ -271,295 +293,47 @@ scribe help --all # Full reference - ✅ Fixed 70 TypeScript errors across 22 test files - ✅ Escaped `\$` handling for academic documents -### Previous: Phase 1 Technical Debt Remediation (2026-01-23) +### Previous: Tech Debt + Quarto Stabilization (v1.16.2) -**Phase 1.1: SettingsModal Refactoring** -- ✅ Extracted `GeneralSettingsTab`, `EditorSettingsTab`, `SettingsSection` -- ✅ Reduced `SettingsModal.tsx` by **26%** (614 lines) -- ✅ Added 13 new unit tests - -**Phase 1.2: App.tsx Refactoring** -- ✅ Extracted `KeyboardShortcutHandler` (25+ shortcuts, Tauri menus) -- ✅ Extracted `EditorOrchestrator` (Focus/Normal mode rendering) -- ✅ Reduced `App.tsx` by **13%** (267 lines) -- ✅ Added 19 new unit tests - -**Overall Metrics:** -- **-881 lines** from monolithic controllers -- **+4 new components** (well-organized, tested) -- **+32 new tests** (2,161/2,195 passing, 98.5%) -- **0 breaking changes** - -**Phase 1.3: Quarto Autocomplete Stabilization (v1.16.2)** -- ✅ Fixed erratic code block behavior (suppressed non-code completions) -- ✅ Implemented context-aware LaTeX completions (math mode only) -- ✅ Added syntax highlighting for embedded languages (R, Python, etc.) -- ✅ Polished code block styling with distinct background -- ✅ Fixed backtick autocomplete triggers +Extracted `KeyboardShortcutHandler`, `EditorOrchestrator`, `GeneralSettingsTab`, `EditorSettingsTab` from monolithic App.tsx/SettingsModal.tsx (-881 lines, +4 components, +32 tests). Context-aware LaTeX completions (math-only scoping, suppressed in code blocks). --- -### Previous: Icon-Centric Sidebar Expansion (v1.16.0) - -**Sidebar Architecture Refactor - Complete ✅** - -Transitioned from global `sidebarMode` to per-icon expansion where each icon (Inbox, Smart Folders, Pinned Projects) independently expands with its own preferred view mode (compact or card). - -**Key Changes:** -- ✅ **Icon-Centric Expansion** - Icon bar always visible (48px), icons control expansion -- ✅ **Per-Icon Mode Preferences** - Each icon remembers compact/card preference -- ✅ **Accordion Pattern** - Only one icon expanded at a time -- ✅ **Global Width Management** - Shared compact/card widths across all icons -- ✅ **Removed Shortcuts** - Deleted ⌘B (toggle sidebar) shortcut, no global mode state -- ✅ **Smooth Animations** - 200ms cubic-bezier transitions, slide-in panels, expanded indicators - -**Architecture:** - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Icon-Centric Mode (v1.16.0) │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────┐ ┌──────────────────────────────────────────┐ │ -│ │ I │ │ Expanded Icon Panel │ │ -│ │ N │ │ ┌──────────────────────────────────────┐ │ │ -│ │ B │ │ │ Panel Header (Title + Mode Toggle) │ │ │ -│ │ O │ │ └──────────────────────────────────────┘ │ │ -│ │ X │ │ │ │ -│ └─────┘ │ ┌──────────────────────────────────────┐ │ │ -│ │ │ │ │ │ -│ ┌─────┐ │ │ CompactListView │ │ │ -│ │ R │ │ │ OR │ │ │ -│ │ E │ │ │ CardGridView │ │ │ -│ │ S │ │ │ │ │ │ -│ └─────┘ │ │ (mode determined by icon's │ │ │ -│ │ │ preferredMode setting) │ │ │ -│ ┌─────┐ │ │ │ │ │ -│ │ ... │ │ └──────────────────────────────────────┘ │ │ -│ └─────┘ └──────────────────────────────────────────┘ │ -│ Icon Bar (48px) Expanded Panel (conditional) │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -**Component Hierarchy:** - -``` -MissionSidebar.tsx (icon-centric-mode) -├── IconBar.tsx (48px fixed width, always visible) -│ ├── InboxButton -│ ├── SmartIconButton (Research, Teaching, R Package, R Dev, Generic) -│ ├── VaultIconButton (Pinned Projects) -│ ├── Spacer -│ └── ActivityBar -│ -└── ExpandedIconPanel.tsx (conditional, width = sidebarWidth - 48) - ├── PanelHeader - │ ├── Icon Label - │ ├── Mode Toggle Button (compact ⇄ card) - │ └── Close Button - │ - └── Content (based on expandedIcon type + mode) - ├── CompactListView.tsx (if mode === 'compact') - │ ├── ProjectList (for smart icons) - │ └── NoteList (for vault icons) - │ - └── CardGridView.tsx (if mode === 'card') - ├── ProjectCards (for smart icons) - └── NoteCards (for vault icons) -``` - -**State Management (useAppViewStore.ts):** - -```typescript -// Removed (v1.15.0 - Global Mode System) -sidebarMode: 'icon' | 'compact' | 'card' // ❌ REMOVED -lastExpandedMode: 'compact' | 'card' | null // ❌ REMOVED -lastModeChangeTimestamp: number // ❌ REMOVED -setSidebarMode(mode) // ❌ REMOVED -cycleSidebarMode() // ❌ REMOVED -toggleSidebarCollapsed() // ❌ REMOVED - -// Added (v1.16.0 - Icon-Centric System) -expandedIcon: ExpandedIconType | null // ✅ Which icon is expanded - where ExpandedIconType = { type: 'vault', id: string } | { type: 'smart', id: SmartIconId } - -// Per-icon mode preferences stored in icon objects: -PinnedVault.preferredMode: 'compact' | 'card' // ✅ Each vault remembers mode -SmartIcon.preferredMode: 'compact' | 'card' // ✅ Each smart icon remembers mode - -// New Actions: -expandVault(vaultId: string) // ✅ Expand vault icon, set width from preferredMode -expandSmartIcon(iconId: SmartIconId) // ✅ Expand smart icon, set width -collapseAll() // ✅ Collapse to icon-only mode (48px width) -toggleIcon(type: 'vault'|'smart', id: string) // ✅ Accordion toggle -setIconMode(type, id, mode: 'compact'|'card') // ✅ Set icon's preferred mode - -// Global Width Settings (shared across all icons): -compactModeWidth: number // Default 240px - applied when icon uses compact mode -cardModeWidth: number // Default 320px - applied when icon uses card mode -``` - -**Accordion Pattern Implementation:** - -```typescript -toggleIcon: (type, id) => { - const { expandedIcon, expandVault, expandSmartIcon, collapseAll } = get() - - // If clicking already expanded icon, collapse it - if (expandedIcon?.type === type && expandedIcon?.id === id) { - collapseAll() - return - } - - // Otherwise expand this icon (auto-collapses others) - if (type === 'vault') { - expandVault(id) - } else { - expandSmartIcon(id as SmartIconId) - } -} -``` - -**CSS Structure (index.css):** - -```css -/* Icon-Centric Mode Container */ -.mission-sidebar.icon-centric-mode { - display: flex; - flex-direction: row; - transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1); -} - -/* Icon Bar (Always Visible) */ -.icon-bar { - width: 48px; - flex-shrink: 0; - background: var(--nexus-bg-primary); - border-right: 1px solid rgba(255, 255, 255, 0.05); -} - -/* Expanded Icon Panel (Conditional) */ -.expanded-icon-panel { - flex: 1; - display: flex; - flex-direction: column; - background: var(--nexus-bg-secondary); - border-left: 1px solid rgba(255, 255, 255, 0.05); - animation: slideInFromLeft 200ms cubic-bezier(0.4, 0, 0.2, 1); -} - -@keyframes slideInFromLeft { - from { - opacity: 0; - transform: translateX(-10px); - } - to { - opacity: 1; - transform: translateX(0); - } -} +### Previous: Icon-Centric Sidebar (v1.16.0) -/* Expanded Icon Indicator (3px accent bar) */ -.icon-btn.expanded::before, -.smart-icon-btn.expanded::before { - content: ''; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 20px; - background: var(--nexus-accent); - border-radius: 0 2px 2px 0; - animation: indicatorFadeIn 150ms ease; -} - -@keyframes indicatorFadeIn { - from { - opacity: 0; - width: 0; - } - to { - opacity: 1; - width: 3px; - } -} -``` - -**Implementation Phases:** -- Phase 1: ✅ State refactor (types, store migration) -- Phase 2: ✅ Component cleanup (removed 5,724 lines deprecated code) -- Phase 3: ✅ Remove deprecated shortcuts (⌘B) -- Phase 4: ✅ Test updates (64 tests passing) -- Phase 5: ✅ CSS transitions + documentation - -**Testing:** -- ✅ 64 icon-centric tests passing (25 core + 23 edge cases + 16 E2E) -- ✅ 100% Phase 1/2 state management coverage -- ✅ TypeScript: 0 errors -- ✅ All production code compiles cleanly - -**Migration:** -- Automatic v1.15.0 → v1.16.0 localStorage migration -- Old keys cleaned: `sidebarMode`, `lastExpandedMode`, `lastModeChangeTimestamp` -- Preserves user's last expanded smart icon as `expandedIcon` -- Defaults all icons to compact mode on first launch - -**Keyboard Shortcuts Removed:** -- ⌘B - Toggle Left Sidebar (no longer needed, click icons instead) -- ⌘0 - Collapse Sidebar (no longer needed, click expanded icon to collapse) +Per-icon expansion with accordion pattern. `IconBar.tsx` (48px) + `ExpandedIconPanel.tsx` with compact/card modes per icon. State in `useAppViewStore.ts`: `expandedIcon`, `toggleIcon()`, per-icon `preferredMode`. Removed global `sidebarMode` and ⌘B shortcut. 64 tests, auto-migration from v1.15.0 localStorage keys. --- ### Previous Releases -**Sprint 30 Phase 2: WikiLink Navigation (v1.14.0)** -- ✅ Single-click WikiLink Navigation - Click to navigate in Live/Reading modes -- ✅ Cmd+Click in Source Mode - Navigate WikiLinks with ⌘+Click -- ✅ Mode Preservation - Backlinks panel preserves editor mode -- ✅ 1984 tests passing (30 WikiLink E2E tests) -- Release: - -**Sprint 28: Live Editor Enhancements (v1.10.0)** - -- ✅ CodeMirror 6 Live Preview - Obsidian-style syntax hiding -- ✅ KaTeX Math Rendering - Inline `$...$` and display `$$...$$` -- ✅ Three Editor Modes - Source (⌘1), Live (⌘2), Reading (⌘3), cycle with ⌘E - -**Sprint 27: Backend Foundation + Settings (v1.7.0 → v1.9.0)** - -**v1.9.0 Features (2025-12-31):** -- ✅ Settings Enhancement - ⌘, fuzzy search, theme gallery, project templates -- ✅ Quick Actions Customization - Drag-to-reorder, edit prompts, shortcuts -- ✅ 1033 tests passing (930 unit + 103 E2E) - -**v1.7.0 Features (2025-12-31):** -- ✅ Chat History Persistence - Migration 009, auto-save/load per note -- ✅ Quick Actions - 5 one-click AI prompts (Improve, Expand, Summarize, Explain, Research) -- ✅ @ References - Autocomplete note inclusion -- ✅ 911 tests passing (829 unit + 82 E2E) +| Version | Highlight | +|---------|-----------| +| v1.18.0 | Sidebar vault expansion fix + DexieError2 race condition | +| v1.16.x | Icon-centric sidebar, tech debt remediation, Quarto autocomplete | +| v1.14.0 | WikiLink single-click navigation | +| v1.10.0 | CodeMirror 6 Live Preview, KaTeX math, three editor modes | +| v1.9.0 | Settings enhancement (⌘, fuzzy search, theme gallery) | +| v1.7.0 | Quick Actions, chat history, @ references | -**Sprint 26 Features (2025-12-30):** -- ✅ Terminal PTY shell (portable-pty + xterm.js) -- ✅ Mission Control sidebar (Icon/Compact/Card modes) -- ✅ Browser mode with IndexedDB persistence +See [CHANGELOG](CHANGELOG.md) for full details. --- ## ✅ Feature Tiers -### Tier 1-3: Build Now (v1.0) +### Tier 1-3: Core (Shipped) -- BlockNote editor +- BlockNote editor → HybridEditor++ (CodeMirror 6) - Focus mode - Global hotkey - Claude/Gemini CLI +- Pomodoro focus timer (v1.19.0) - Zotero citations - LaTeX/PDF/Word export - Quarto render -### Tier 4: Build Now (v1.0) +### Tier 4: Core (Shipped) - Project system (5 types) - Daily notes @@ -623,29 +397,7 @@ toggleIcon: (type, id) => { └─────────────────────────┘ └─────────────────────────┘ ``` -**Key files:** -- `platform.ts` - `isTauri()`, `isBrowser()` detection -- `browser-db.ts` - Dexie.js schema, `seedDemoData()` -- `browser-api.ts` - Full 46-operation API for browser -- `browser-dialogs.ts` - `confirm()`, `alert()` fallbacks - -### AI Integration (CLI Only) - -```typescript -// Uses installed CLI tools, no API keys -async function askClaude(prompt: string, context: string): Promise { - const result = await execAsync( - `echo "${escape(context)}" | claude --print "${escape(prompt)}"` - ); - return result.stdout; -} -``` - -### Daily Notes - -- Shortcut: ⌘D -- Auto-create with template -- Per-project configuration +Key files: `platform.ts` (runtime detection), `browser-db.ts` (Dexie schema), `browser-api.ts` (46 operations), `browser-dialogs.ts` (fallbacks). ### Tauri API Serialization (Critical Pattern) diff --git a/docs/reference/PROJECT-DEFINITION.md b/docs/reference/PROJECT-DEFINITION.md index 29f3636d..20ace1ae 100644 --- a/docs/reference/PROJECT-DEFINITION.md +++ b/docs/reference/PROJECT-DEFINITION.md @@ -52,7 +52,7 @@ No dialogs. No choices. Just write. ### 4. Visible Progress - Word count (always visible) -- Session timer +- Pomodoro timer (work/break cycles) - Streak indicator (optional) ### 5. Sensory-Friendly @@ -250,7 +250,7 @@ Working on [[Sensitivity Analysis]] section... | Ecosystem Panel | 9 | | Command Palette (⌘K) | 10 | | Obsidian Sync | 11 | -| Session Timer | 9 | +| Pomodoro Timer | 9 | ### Tier 3: Academic Features diff --git a/docs/reference/REFCARD-SETTINGS.md b/docs/reference/REFCARD-SETTINGS.md index f95f37f1..ddccfb1b 100644 --- a/docs/reference/REFCARD-SETTINGS.md +++ b/docs/reference/REFCARD-SETTINGS.md @@ -20,13 +20,40 @@ | Category | Icon | Contents | |----------|------|----------| -| **Editor** | 📝 | Font, spacing, ligatures, focus mode | -| **Themes** | 🎨 | Visual theme gallery (8 themes) | -| **AI & Workflow** | ⚡ | Quick Actions, chat, @ references | -| **Projects** | 📁 | Templates, defaults, daily notes | -| **Advanced** | ⚙️ | Performance, data, export/import | +| **General** | ⚙️ | Open last page, streak display, Focus Timer (Pomodoro), ADHD features | +| **Editor** | 📝 | Font, spacing, readable line length, spellcheck | +| **Appearance** | 🎨 | UI style, dark/light theme galleries (10 themes) | +| **Files** | 📁 | File management settings | +| **Academic** | 📚 | Citations, Zotero, export formats | +| **Icon Bar** | 🔧 | Sidebar icon configuration | -**Badge:** AI category shows "3" badge (3 new features in v1.9.0) +--- + +## Preference Toggles + +The following boolean preferences are managed by the `usePreferences()` hook +and persist to `localStorage` via `SettingsToggle` components. + +| Preference | Default | Tab | Description | +|------------|---------|-----|-------------| +| **Open Last Page** | ON | General | Restore the last open note on startup | +| **Streak Display** | ON | General | Show writing streak indicator | +| **Pomodoro Enabled** | ON | General | Show Pomodoro timer in status bar | +| **Readable Line Length** | ON | Editor | Limit editor line width for comfortable reading | +| **Spellcheck** | OFF | Editor | Enable browser-native spellcheck in the editor | + +**Pomodoro Duration Settings** (numeric, in General > Focus Timer): + +| Preference | Default | Description | +|------------|---------|-------------| +| **Work Minutes** | 25 | Length of a focus session | +| **Short Break Minutes** | 5 | Break after each session | +| **Long Break Minutes** | 15 | Break after N sessions | +| **Long Break Interval** | 4 | Sessions before a long break | + +All toggles write-through to `localStorage` immediately (no Save button). +Changes propagate to other components via the `preferences-changed` event. +Pomodoro preferences also sync to the `usePomodoroStore` Zustand store. --- @@ -295,6 +322,6 @@ --- -**Version:** v1.9.0+ -**Last Updated:** 2025-12-31 -**Changelog:** [v1.9.0 Release Notes](https://github.com/Data-Wise/scribe/releases/tag/v1.9.0) +**Version:** v1.19.1+ +**Last Updated:** 2026-02-24 +**Changelog:** [v1.19.0 Release Notes](https://github.com/Data-Wise/scribe/releases/tag/v1.19.0) diff --git a/docs/reference/TESTS_SUMMARY.md b/docs/reference/TESTS_SUMMARY.md index c187b592..fbc2944b 100644 --- a/docs/reference/TESTS_SUMMARY.md +++ b/docs/reference/TESTS_SUMMARY.md @@ -1,7 +1,7 @@ # Test Coverage Summary - Scribe Editor -**Generated:** 2026-01-24 -**Total Tests:** 2,255 passing (73 test files) +**Generated:** 2026-02-24 +**Total Tests:** 2,280 passing (76 test files) **Test Framework:** Vitest + Testing Library + happy-dom **TypeScript:** 0 production errors, 67 test file warnings (documented) @@ -361,10 +361,10 @@ | Metric | Value | |--------|-------| -| **Total Tests** | 407 | +| **Total Tests** | 2,280 | | **Pass Rate** | 100% | -| **Test Files** | 11 | -| **Test Duration** | ~1.4s | +| **Test Files** | 76 | +| **Test Duration** | ~3s | | **Skipped** | 7 (WikiLinks legacy) | --- @@ -376,7 +376,7 @@ | Theme System | ✅ | 72 | | Font System | ✅ | 29 | | Component Rendering | ✅ | 80+ | -| State Management | ✅ | 13 | +| State Management | ✅ | 48+ | | Tag System | ✅ | 52 | | Wiki-Links | ✅ | 16 | | Regex Validation | ✅ | 20+ | @@ -384,8 +384,10 @@ | Security | ✅ | 4 | | Performance | ✅ | 8 | | Integration | ✅ | 32 | -| Accessibility | ✅ | 10+ | +| Accessibility | ✅ | 12+ | | ADHD Design | ✅ | 6 | +| Pomodoro Timer | ✅ | 62 | +| Settings Infrastructure | ✅ | 27 | --- diff --git a/docs/specs/SPEC-code-chunk-ux.md b/docs/specs/SPEC-code-chunk-ux.md new file mode 100644 index 00000000..de98a641 --- /dev/null +++ b/docs/specs/SPEC-code-chunk-ux.md @@ -0,0 +1,541 @@ +# UX Design Spec: Code Chunk Visual Treatment + +**Date:** 2026-02-24 +**Status:** Draft +**Scope:** CSS + optional ViewPlugin decoration for code chunks in CodeMirror editor + +--- + +## 1. Problem Statement + +Code chunks in Scribe are nearly invisible. The current `rgba(0,0,0,0.04)` background (4% opacity) fails to create meaningful visual separation between prose and code. For academics with ADHD, this lack of distinction causes: + +- **Context-switching friction** -- the eye cannot quickly locate "where am I, prose or code?" +- **Lost place** when scrolling through mixed Quarto documents +- **Missed chunk boundaries** leading to accidental edits in the wrong region + +The goal is not VS Code fidelity. It is calm, unmistakable distinction. + +--- + +## 2. Current State (Before) + +``` ++------------------------------------------------------------------+ +| | +| This is prose text in the document. The quick brown fox jumps | +| over the lazy dog. Here is some academic writing about methods. | +| | +| ```{r} <- barely seen | +| |#| label: fig-scatter | +| |#| fig-cap: "Relationship between X and Y" | +| |library(ggplot2) | +| |ggplot(data, aes(x, y)) + geom_point() | +| ``` | +| | +| The results in @fig-scatter show a clear correlation between | +| the two variables, consistent with prior findings. | +| | ++------------------------------------------------------------------+ + +Legend: | = thin 2px accent border (only visual cue) + Background is rgba(0,0,0,0.04) -- essentially invisible +``` + +**Problems with current state:** +- 4% opacity background is imperceptible on most light themes +- On dark themes, `rgba(255,255,255,0.04)` is equally invisible +- The thin left border alone is not enough to establish a region +- Fence lines (```` ```{r} ````) look like regular text +- Chunk options (`#|`) are indistinguishable from comments in prose +- No top/bottom boundary makes chunks bleed into surrounding text + +--- + +## 3. Proposed Design (After) + +``` ++------------------------------------------------------------------+ +| | +| This is prose text in the document. The quick brown fox jumps | +| over the lazy dog. Here is some academic writing about methods. | +| | +| .----------- r --------------------------------------------------.| +| | ```{r} || +| | #| label: fig-scatter (dimmed, 85%) || +| | #| fig-cap: "Relationship between X and Y" (dimmed, 85%) || +| | library(ggplot2) || +| | ggplot(data, aes(x, y)) + geom_point() || +| | ``` || +| '----------------------------------------------------------------'| +| | +| The results in @fig-scatter show a clear correlation between | +| the two variables, consistent with prior findings. | +| | ++------------------------------------------------------------------+ + +Legend: + .---. = subtle rounded border (1px, semi-transparent) + | = left accent border (3px, theme accent at 40% opacity) + r = language badge (small, muted, top-right corner) + Background = var(--code-chunk-bg), theme-derived, ~8-12% opacity +``` + +### 3.1 Anatomy of a Code Chunk + +``` + language badge (optional, on hover or always -- see Section 7) + | + .---------v--- r -. + | ```{r} | <-- fence line: slightly darker bg, monospace + | #| label: fig-1 | <-- chunk options: dimmed text, smaller font + | #| echo: false | + | | <-- code body: monospace, standard code bg + | x <- rnorm(100) | + | plot(x) | + | ``` | <-- closing fence: matches opening style + '-------------------' + ^ + | + left accent border (3px, accent color at 40%) +``` + +--- + +## 4. Color Recommendations + +### 4.1 Strategy: Derive from Theme, Not Hardcode + +The current approach uses hardcoded `rgba(0,0,0,0.04)` and `rgba(255,255,255,0.04)`. This is too subtle and ignores the theme's actual background color. Instead, derive the code chunk background from `--nexus-bg-tertiary`, which every theme already defines as "the third-level surface." + +### 4.2 New CSS Custom Properties + +```css +/* Add to applyTheme() output or compute in CSS */ +--code-chunk-bg: /* bgTertiary at ~50% opacity over bgPrimary */ +--code-chunk-bg-fence: /* bgTertiary at ~70% opacity over bgPrimary */ +--code-chunk-border: /* accent at 30-40% opacity */ +--code-chunk-option: /* textMuted at 80% */ +``` + +### 4.3 Per-Theme Values (Computed) + +Using `bgTertiary` mixed toward `bgPrimary`: + +| Theme | Type | bgPrimary | bgTertiary | Recommended chunk bg | +|------------------|-------|-----------|------------|-------------------------------| +| Oxford Dark | dark | #0a0c10 | #1a1f26 | rgba(26,31,38, 0.55) | +| Forest Night | dark | #0d1210 | #1c2922 | rgba(28,41,34, 0.55) | +| Warm Cocoa | dark | #121010 | #262220 | rgba(38,34,32, 0.55) | +| Midnight Purple | dark | #0e0c12 | #201c28 | rgba(32,28,40, 0.55) | +| Deep Ocean | dark | #0a0e14 | #182028 | rgba(24,32,40, 0.55) | +| Soft Paper | light | #faf8f5 | #f5f2ed | rgba(235,228,218, 0.40) | +| Morning Fog | light | #f4f6f8 | #e8eaed | rgba(218,222,228, 0.40) | +| Sage Garden | light | #f5f8f5 | #e8f0e8 | rgba(218,232,218, 0.40) | +| Lavender Mist | light | #f8f6fa | #f0ecf4 | rgba(228,222,236, 0.40) | +| Sand Dune | light | #f9f7f4 | #f0ebe4 | rgba(230,222,212, 0.40) | + +### 4.4 Simplified Implementation (Theme-Agnostic) + +Rather than computing per-theme, use a single approach that works everywhere: + +```css +/* Light themes */ +.codemirror-editor-wrapper .cm-line.cm-codeblock-line { + background-color: color-mix(in srgb, var(--nexus-bg-tertiary) 60%, var(--nexus-bg-primary) 40%); +} + +/* Fence lines slightly more prominent */ +.codemirror-editor-wrapper .cm-line.cm-codeblock-fence { + background-color: color-mix(in srgb, var(--nexus-bg-tertiary) 80%, var(--nexus-bg-primary) 20%); +} + +/* Dark themes -- same logic, CSS handles it */ +.dark .codemirror-editor-wrapper .cm-line.cm-codeblock-line { + background-color: color-mix(in srgb, var(--nexus-bg-tertiary) 65%, var(--nexus-bg-primary) 35%); +} +``` + +**Why `color-mix`?** It uses the theme's own colors, automatically adapting to every theme (including custom themes) without any JS computation. Fallback for older browsers: use `bgTertiary` directly. + +### 4.5 Contrast Verification + +The code chunk background must NOT reduce text contrast below WCAG AA (4.5:1). Since we are mixing between two of the theme's own surface colors (both designed to carry text), contrast is preserved by construction. The text color does not change inside code chunks. + +--- + +## 5. Typography + +### 5.1 Font Size Relationship + +``` +Prose body: var(--editor-font-size) (default 15px) +Code body: calc(var(--editor-font-size) - 1px) (14px -- 93%) +Chunk options: calc(var(--editor-font-size) - 2px) (13px -- 87%) +Fence markers: calc(var(--editor-font-size) - 1px) (14px -- 93%) +Language badge: 11px fixed (decorative) +``` + +**Rationale:** Code at 93% of prose size is the sweet spot. Smaller and it strains the eyes. Larger and it competes with prose for attention. The 1px reduction (not a percentage) ensures the difference is subtle but consistent across font size preferences. + +### 5.2 Font Family + +```css +--code-font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; +``` + +Keep the existing JetBrains Mono stack. It is already established in the codebase and is an excellent choice for academic code (clear distinction between O/0, I/l/1, and good support for R/Python statistical operators). + +**Do NOT use the prose font inside code chunks.** The font switch is the single strongest signal that "this is code, not prose." + +### 5.3 Line Height + +``` +Prose: var(--editor-line-height) (default 1.8) +Code: 1.5 (tighter, like a terminal) +``` + +Tighter line height in code chunks reinforces the visual distinction and is more natural for reading code. Academic prose needs generous line height for readability; code does not. + +--- + +## 6. Spacing and Boundaries + +### 6.1 Chunk Margins + +```css +/* Top margin before opening fence */ +.cm-codeblock-fence-open { + margin-top: 12px; /* breathing room from prose above */ + border-radius: 6px 6px 0 0; + padding-top: 4px; +} + +/* Bottom margin after closing fence */ +.cm-codeblock-fence-close { + margin-bottom: 12px; + border-radius: 0 0 6px 6px; + padding-bottom: 4px; +} +``` + +### 6.2 Left Border (Accent Gutter) + +```css +.cm-codeblock-line { + border-left: 3px solid color-mix(in srgb, var(--nexus-accent) 40%, transparent); + padding-left: 16px; +} +``` + +**Change from current:** Increase from 2px to 3px. Increase opacity from whatever the accent implies to an explicit 40%. The border should be noticeable but not a bright stripe. 40% accent is visible without screaming. + +### 6.3 Right Boundary + +No right border. The background fill and rounded corners on fence lines are sufficient. A right border would add visual noise for no information gain. + +### 6.4 Full-Width Background + +The background should extend to the full width of the editor content area, not just the text width. This creates the "panel" feeling that VS Code achieves with notebook cells. + +```css +.cm-codeblock-line { + /* Extend background to fill editor width */ + margin-left: -20px; + margin-right: -20px; + padding-left: 23px; /* 20px margin reclaim + 3px border */ + padding-right: 20px; +} +``` + +--- + +## 7. Language Badge + +### 7.1 Appearance + +``` + .--------------------------------------------. + | ```{r} [ R ] | + | ... | +``` + +A small pill-shaped badge in the top-right of the opening fence line. + +```css +.cm-codeblock-lang-badge { + display: inline-block; + font-family: var(--code-font-family); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--nexus-text-muted); + background: color-mix(in srgb, var(--nexus-bg-tertiary) 80%, transparent); + padding: 1px 8px; + border-radius: 3px; + opacity: 0.7; +} +``` + +### 7.2 Visibility Behavior + +**Always visible, low opacity.** Do NOT make it hover-only. ADHD users scan documents quickly; the language badge is a wayfinding anchor. Making it hover-only defeats its purpose. Keep it at 70% opacity so it is findable without being loud. + +### 7.3 Language Display Map + +``` +{r} -> R +{python} -> PY +{julia} -> JL +{bash} -> SH +{sql} -> SQL +{ojs} -> OJS +{mermaid} -> MERMAID +{dot} -> DOT +``` + +Short uppercase abbreviations. The badge is for quick identification, not a label. + +--- + +## 8. Chunk Option Styling (#| lines) + +### 8.1 Design + +Chunk options are metadata, not code. They should be visually de-emphasized. + +```css +.cm-codeblock-option { + color: var(--nexus-text-muted); + font-size: calc(var(--editor-font-size) - 2px); + opacity: 0.85; + font-style: italic; +} + +/* The #| prefix */ +.cm-codeblock-option-marker { + color: var(--nexus-accent); + opacity: 0.5; + font-style: normal; +} +``` + +### 8.2 Rationale + +- **Italic** distinguishes options from code at a glance +- **Smaller font** creates hierarchy: prose > code > metadata +- **Dimmed opacity** pushes options into the background +- **Accent-colored `#|` marker** provides a subtle visual anchor + +--- + +## 9. Fence Line Treatment + +### 9.1 Opening Fence (` ```{r} `) + +```css +.cm-codeblock-fence-open { + background-color: var(--code-chunk-bg-fence); + border-radius: 6px 6px 0 0; + margin-top: 12px; + padding-top: 4px; + font-size: calc(var(--editor-font-size) - 1px); + color: var(--nexus-text-muted); +} +``` + +The opening fence is the "header" of the chunk. It gets a slightly stronger background than the body lines. The backticks and language specifier are displayed in muted text -- they are structural, not content. + +### 9.2 Closing Fence (` ``` `) + +```css +.cm-codeblock-fence-close { + background-color: var(--code-chunk-bg-fence); + border-radius: 0 0 6px 6px; + margin-bottom: 12px; + padding-bottom: 4px; + color: var(--nexus-text-muted); +} +``` + +### 9.3 Future Consideration: Collapsed Fence Display + +Do NOT implement now, but design-compatible: a future "collapse chunk" feature could replace the body with a single line showing the language badge and option summary. The fence/badge architecture supports this without redesign. + +--- + +## 10. Interaction Patterns + +### 10.1 Cursor Inside Chunk + +When the cursor is inside a code chunk, the left accent border brightens from 40% to 70% opacity. This provides a focus indicator without changing layout. + +```css +.cm-codeblock-line.cm-codeblock-active { + border-left-color: color-mix(in srgb, var(--nexus-accent) 70%, transparent); +} +``` + +### 10.2 Hover State + +**No hover state on the chunk itself.** Hover effects on large regions are distracting and provide no information. The chunk is not a button. + +### 10.3 Focus Indicator (Keyboard Navigation) + +The standard CodeMirror cursor and `cm-activeLine` highlight remain unchanged inside code chunks. The chunk background is designed to not interfere with the active line highlight. + +### 10.4 Selection Inside Chunks + +Selection highlight should remain the default CodeMirror selection color. The chunk background is low enough contrast that selection remains clearly visible on top of it. + +--- + +## 11. What NOT to Include + +These VS Code Quarto features are deliberately excluded from Scribe: + +| VS Code Feature | Why Not in Scribe | +|------------------------------|----------------------------------------------------------------| +| "Run Cell" CodeLens button | Scribe is a writing tool, not an IDE. Running code breaks flow | +| "Run Above" button | Same -- execution is not the writing workflow | +| Cell toolbar (collapse, etc) | Toolbar chrome is visual noise in a prose-first editor | +| Output cells below chunks | Output belongs in the rendered preview, not the source editor | +| Cell status indicators | No execution = no status to show | +| Drag-to-reorder cells | Prose interleaves with code; reordering is a text operation | +| Add Cell (+) buttons | Chunk insertion is via typing or completion, not UI buttons | +| Gutter line numbers in code | Distracting in a prose editor; not useful without execution | +| Syntax highlighting overhaul | Keep existing CodeMirror highlighting; don't reinvent | + +**The guiding principle:** Scribe helps you *write* Quarto documents. VS Code helps you *run* them. Every feature should answer: "Does this help the writer, or does it turn the editor into an IDE?" + +--- + +## 12. Accessibility + +### 12.1 Color Contrast + +- Code text on chunk background: inherits theme text colors, which already meet WCAG AA (4.5:1) against `bgTertiary` +- Chunk options (dimmed): at 85% opacity of `textMuted`, verify each theme maintains 3:1 minimum for large text. All current themes pass. +- Language badge: decorative text, not critical information; 70% opacity of `textMuted` is acceptable under WCAG for non-essential content + +### 12.2 Not Relying on Color Alone + +Code chunks are distinguished by **four independent cues**: +1. Background color (visual region) +2. Font change (monospace vs prose) +3. Left border (spatial marker) +4. Font size reduction (typographic hierarchy) + +Any three of these four are sufficient for identification. This exceeds WCAG 1.4.1 (Use of Color). + +### 12.3 Screen Reader Considerations + +The CodeMirror ARIA tree already announces code blocks. No additional ARIA attributes are needed for the visual styling changes. The language badge, if implemented as a widget, should have `aria-hidden="true"` since the language is already in the fence text. + +### 12.4 Motion and Animation + +No animations. No transitions on background color. Chunk appearance is static. The only dynamic element is the border opacity change on focus, which uses an instant CSS change (no `transition` property). + +--- + +## 13. Implementation Approach + +### 13.1 Phase 1: CSS-Only (Immediate) + +Update the existing CSS in `/Users/dt/projects/dev-tools/scribe/src/renderer/src/index.css` (lines 6231-6276): + +- Replace `rgba(0,0,0,0.04)` with `color-mix()` expressions using theme variables +- Increase left border from 2px to 3px +- Add border-radius to fence lines +- Add margin-top/margin-bottom spacing +- Adjust font sizes to use `calc()` relative to `--editor-font-size` + +This works with the existing `:has(.tok-codeFence)` and `:has(.tok-monospace)` selectors. No JS changes needed. + +### 13.2 Phase 2: ViewPlugin Decoration (Follow-up) + +The current CSS relies on `:has()` selectors matching token classes. This works but has limitations: +- Cannot distinguish opening vs closing fence +- Cannot apply full-width backgrounds reliably +- Cannot add the language badge + +A ViewPlugin that walks the syntax tree and applies `Decoration.line()` classes would enable: +- `cm-codeblock-fence-open` vs `cm-codeblock-fence-close` +- `cm-codeblock-option` for `#|` lines +- `cm-codeblock-active` for lines under cursor +- Language badge via `Decoration.widget()` + +**Note:** The CSS class `cm-codeblock-line` already exists in `index.css` but is never applied by any decoration in `CodeMirrorEditor.tsx`. Phase 2 would activate it. + +### 13.3 Phase 3: Language Badge Widget (Future) + +A small `WidgetType` subclass that renders the language abbreviation pill. Positioned via `Decoration.widget()` at the end of the opening fence line. + +--- + +## 14. Visual Summary: Before and After + +### Light Theme (Sage Garden) + +**Before:** +``` + The results suggest that... | #f5f8f5 bg (prose) + | + ```{r} | #f5f8f5 bg (barely different) + #| label: fig-results | same + library(ggplot2) | same + ggplot(df, aes(x, y)) + geom_point() | same + ``` | same + | + As shown in @fig-results, the... | #f5f8f5 bg (prose) +``` + +**After:** +``` + The results suggest that... | #f5f8f5 bg (prose) + | + .-- [R] ---------------------------. | + | ```{r} | | #e0eae0 bg (clearly different) + | #| label: fig-results | | #e0eae0, italic, smaller + | library(ggplot2) | | #e4ece4 bg + | ggplot(df, aes(x,y))+geom_point()| | #e4ece4 bg + | ``` | | #e0eae0 bg + '-----------------------------------' | + | + As shown in @fig-results, the... | #f5f8f5 bg (prose) +``` + +### Dark Theme (Oxford Dark) + +**Before:** +``` + The results suggest that... | #0a0c10 bg + | + ```{r} | #0a0c10 bg (invisible difference) + x <- 42 | same + ``` | same + | + The variable x represents... | #0a0c10 bg +``` + +**After:** +``` + The results suggest that... | #0a0c10 bg + | + .-- [R] ---------------------------. | + | ```{r} | | #141a22 bg (visible tint) + | x <- 42 | | #121720 bg + | ``` | | #141a22 bg + '-----------------------------------' | + | + The variable x represents... | #0a0c10 bg +``` + +--- + +## 15. Open Questions + +1. **Language badge: always visible or cursor-in-chunk only?** Current recommendation is always visible at 70% opacity. Could poll users. +2. **Should chunk options collapse when cursor is outside?** Similar to how heading markers hide. Would reduce visual noise but adds complexity. +3. **Code font as a separate user preference?** Currently hardcoded to JetBrains Mono. Could add a `--code-font-family` setting alongside the prose font picker. diff --git a/docs/specs/SPEC-quarto-code-chunks-2026-02-24.md b/docs/specs/SPEC-quarto-code-chunks-2026-02-24.md new file mode 100644 index 00000000..0f177504 --- /dev/null +++ b/docs/specs/SPEC-quarto-code-chunks-2026-02-24.md @@ -0,0 +1,297 @@ +# SPEC: VS Code-Style Quarto Code Chunks + +> **Status:** approved +> **Created:** 2026-02-24 +> **Reviewed:** 2026-02-24 +> **From Brainstorm:** `BRAINSTORM-quarto-code-chunks-2026-02-24.md` + +## Overview + +Add distinct visual treatment to Quarto code chunks in Scribe's editor, inspired by VS Code's Quarto extension. Code chunks will have a clearly visible background color, separate monospace font, language badge, and proper theme integration across all 10 built-in themes. The goal is to make code regions instantly scannable in long `.qmd` documents without adding IDE complexity that would distract ADHD-focused writers. + +## Primary User Story + +**As an** academic writer using Scribe for Quarto documents, +**I want** code chunks to be visually distinct from surrounding prose, +**So that** I can quickly scan between text and code sections while writing, without losing focus. + +### Acceptance Criteria + +- [ ] Code chunks have a clearly visible background color that differs from prose +- [ ] Code chunks use a monospace font distinct from the prose font +- [ ] Background and font adapt automatically to all 10 themes (5 dark + 5 light) +- [ ] Language badge (e.g., `r`, `python`) appears on the opening fence line, always visible +- [ ] Chunk option lines (`#|`) have distinct but subtle styling (italic + smaller font, no folding) +- [ ] A "Code Font" preference exists in Settings > Editor +- [ ] No run buttons, toolbars, or other IDE chrome is added +- [ ] Empty code chunks (fence-open immediately followed by fence-close) render correctly +- [ ] All existing tests pass +- [ ] Performance is not degraded for large documents + +## Secondary User Stories + +**As a** writer who uses custom themes, +**I want** code chunk backgrounds to auto-derive from my theme colors, +**So that** I don't need to manually configure code block styling. + +**As a** writer with visual processing preferences, +**I want** to choose my own code font independently from my prose font, +**So that** I can optimize readability for both writing and code. + +## Architecture + +### Component Diagram + +``` +Settings (EditorSettingsTab.tsx) + | + v +FontSettings { family, size, lineHeight, codeFamily, codeSize } + | + v +applyFontSettings() --- sets --> --code-font-family, --code-font-size-ratio +applyTheme() --- sets --> --nexus-code-bg, --nexus-code-border + | + v +CodeMirrorEditor.tsx + |-- createEditorTheme() --- reads CSS vars --> .cm-quarto-* class styles + |-- CodeChunkDecorationPlugin (ViewPlugin) + | |-- iterates syntaxTree for FencedCode nodes + | |-- emits Decoration.line() per code chunk line + | |-- emits Decoration.widget() for LanguageBadgeWidget + | + v +index.css (fallback only for plain ```lang fences) +``` + +### Key Design Decisions + +1. **ViewPlugin for Quarto, CSS fallback for plain fences** — The ViewPlugin follows the established callout decoration pattern (CodeMirrorEditor.tsx line 554-597). CSS `:has()` remains only for non-Quarto code fences. + +2. **Derived CSS variables, not per-theme config** — `--nexus-code-bg` is computed from existing `bgTertiary` in `applyTheme()`. No new fields on the `Theme` type. + +3. **Separate code font from prose font** — New `codeFamily`/`codeSize` fields on `FontSettings`. Default: Fira Code at 88% of editor font size. One `codeFamily` setting controls all code-area fonts (code lines, fence lines, and `#|` option lines). + +4. **No execution features** — Scribe is a writing tool. Run buttons, output cells, and execution status belong in VS Code. + +### Resolved Decisions + +| Question | Decision | Rationale | +|----------|----------|-----------| +| `color-mix()` vs JS `rgba()` | **JS `rgba()` in `applyTheme()`** | Guaranteed compatibility. Uses existing `hexToRgb()` helper (line 585, same file). | +| Language badge visibility | **Always visible** | Simpler implementation. ADHD wayfinding anchor — always answers "what kind?" at a glance. | +| Chunk option folding | **No folding — style only** | Italic + smaller font for visual hierarchy. Folding adds ViewPlugin state complexity. | +| Inline executable code | **Skip for v1** | Inline code already has mono styling. Minimal gain vs regex complexity. Can revisit later. | +| Theme reactivity | **Match existing behavior** | `createEditorTheme()` reads colors once at construction (same as callouts). Editor reconfigure on theme switch handles updates. | +| Chunk option font size | **Hardcoded 0.82 ratio** | Options always slightly smaller than code content. No extra setting needed. | +| Badge text format | **Full lowercase** (`r`, `python`, `julia`) | Matches what user typed in the fence. More explicit than abbreviations. | +| Adjacent chunks | **Visible 8px gap** | Each chunk is a distinct visual unit with its own badge. fence-close bottom-margin + fence-open top-margin create separation. | + +## API Design + +N/A - No API changes. This is a UI-only feature within the editor component. + +## Data Models + +### Modified: `FontSettings` (themes.ts) + +```typescript +export interface FontSettings { + family: string // Prose font family key + size: number // Prose font size (px) + lineHeight: number // Prose line height + codeFamily: string // NEW: Code font family key (mono category) + codeSize: number // NEW: Code font size as ratio of editor size (0.75-1.0) +} + +export const DEFAULT_FONT_SETTINGS: FontSettings = { + family: 'system', + size: 15, + lineHeight: 1.8, + codeFamily: 'fira-code', // NEW + codeSize: 0.88, // NEW +} +``` + +### New entry in `FONT_FAMILIES` registry + +```typescript +'jetbrains-mono': { + name: 'JetBrains Mono', + value: '"JetBrains Mono", "Fira Code", ui-monospace, monospace', + description: 'JetBrains coding font - clean & precise', + category: 'mono' +}, +``` + +**Note:** `fira-code` already exists in the registry (line 948). JetBrains Mono is added as an additional option. Default `codeFamily` is `'fira-code'`. + +### New CSS Variables + +| Variable | Set By | Example Value (Oxford Dark) | +|----------|--------|-----------------------------| +| `--nexus-code-bg` | `applyTheme()` | `rgba(26, 31, 38, 0.45)` | +| `--nexus-code-border` | `applyTheme()` | `rgba(56, 189, 248, 0.25)` | +| `--code-font-family` | `applyFontSettings()` | `"JetBrains Mono", monospace` | +| `--code-font-size-ratio` | `applyFontSettings()` | `0.88` | + +### New ViewPlugin Classes + +| CSS Class | Applied To | Styling | +|-----------|-----------|---------| +| `cm-quarto-fence-line` | Opening + closing fence lines | Code bg, code font, left border | +| `cm-quarto-fence-open` | Opening fence line (additional) | Top border-radius, top margin | +| `cm-quarto-fence-close` | Closing fence line (additional) | Bottom border-radius, bottom margin | +| `cm-quarto-code-line` | Interior code content lines | Code bg, code font, left border | +| `cm-quarto-chunk-option` | `#\|` option lines | Code bg, smaller font, slight opacity, italic | +| `cm-quarto-lang-badge` | Language badge widget | Accent-tinted pill, 0.7em, full lowercase | + +## Dependencies + +- No new dependencies. Uses existing CodeMirror 6 APIs (`ViewPlugin`, `Decoration`, `WidgetType`). +- `JetBrains Mono` is already referenced in CSS; needs adding to `FONT_FAMILIES` registry. + +## UI/UX Specifications + +### Before vs After + +``` +BEFORE: +┌──────────────────────────────────────────┐ +│ This is prose text in the document. │ +│ │ +│ │```{r} │ <- barely visible border +│ │library(ggplot2) │ +│ │ggplot(mtcars, aes(x=mpg)) + │ +│ │ geom_histogram() │ +│ │``` │ +│ │ +│ More prose continues here. │ +└──────────────────────────────────────────┘ + +AFTER: +┌──────────────────────────────────────────┐ +│ This is prose text in the document. │ +│ │ +│ ┌╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶┐ │ +│ ┃ ```{r} r ┃ │ <- tinted background +│ ┃ library(ggplot2) ┃ │ monospace font +│ ┃ ggplot(mtcars, aes(x=mpg)) + ┃ │ language badge +│ ┃ geom_histogram() ┃ │ 3px accent border +│ ┃ ``` ┃ │ +│ └╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶┘ │ +│ │ +│ More prose continues here. │ +└──────────────────────────────────────────┘ +``` + +### Color Recommendations + +| Theme Type | Background | Left Border | +|-----------|-----------|-------------| +| Dark themes | `rgba(bgTertiary, 0.45)` — subtle lift from dark bg | `accent` at 25% opacity | +| Light themes | `rgba(bgTertiary, 0.50)` — gentle tint on light bg | `accent` at 25% opacity | + +### Font Sizing + +| Element | Size | Line Height | +|---------|------|-------------| +| Prose | `var(--editor-font-size)` (15px default) | 1.8 | +| Code content | `calc(editor-size * 0.88)` (~13px) | 1.5 | +| Chunk options | `calc(editor-size * 0.82)` (~12px) | 1.5 | +| Language badge | `0.7em` (~9px) | inline | + +### Accessibility Checklist + +- [x] Background contrast ratio > 1.1:1 against prose background (subtle but perceptible) +- [x] Code font respects user's font size preference (ratio-based) +- [x] `prefers-reduced-motion` — no animations on chunk styling +- [x] Language badge uses `aria-hidden="true"` (decorative) +- [x] Badge text matches fence language (lowercase, e.g., `r`, `python`) +- [x] Focus/cursor on fence line shows raw syntax (no hidden content) + +### Settings UI Mockup + +``` +Editor Settings +├── Font Family: [System Default v] +├── Font Size: [15] +├── Line Height: [1.8] +├── ───────────────────────────────── +├── Code Font +│ ├── Font Family: [Fira Code v] <- filtered to mono fonts only +│ └── Size Ratio: [0.88] ========●== (0.75 - 1.00) +``` + +## Open Questions + +~~All resolved during review (2026-02-24).~~ + +## Review Checklist + +- [x] Spec reviewed by user +- [x] Architecture aligns with existing patterns (callout decorations, theme system) +- [x] No new dependencies introduced +- [x] Backward compatible (existing preferences unaffected) +- [ ] All 10 themes tested +- [ ] Performance validated with large `.qmd` files + +## Implementation Notes + +### Critical Implementation Details + +1. **Decoration sort order** — `Decoration.set()` requires decorations sorted by `.from`. Copy the sort pattern from `RichMarkdownPlugin` line 654. + +2. **`Decoration.line()` takes single point** — `range(line.from)` not `range(line.from, line.to)`. Using a range causes runtime error. + +3. **`--nexus-code-bg` must be complete value** — CSS can't do `rgba(var(--hex), 0.7)`. Compute full `rgba(r,g,b,a)` string in `applyTheme()` using existing `hexToRgb()` helper (line 585, private function in same file). + +4. **Quarto detection regex** — Opening fences matching `/^```\{(\w+)/` are Quarto chunks. Plain `` ```js `` fences are NOT Quarto and should use CSS fallback only. + +5. **Font migration** — Existing `FontSettings` in localStorage won't have `codeFamily`/`codeSize`. Use defaults via `{ ...DEFAULT_FONT_SETTINGS, ...stored }` spread pattern (already used in `loadFontSettings()` at line 985). + +6. **Badge color** — Use `accent` (not `textSecondary` which doesn't exist on `ThemeColors`). Badge background: `accent` at 20% opacity. Badge text: `accent` color. Available theme colors: `textPrimary`, `textMuted`, `accent`, `accentHover`. + +7. **Empty code chunks** — A chunk with no content lines (fence-open immediately followed by fence-close) must render correctly. The ViewPlugin should handle this by emitting fence-open and fence-close on adjacent lines with no code-line decorations between them. + +8. **Settings test impact** — `EditorSettingsTab.test.tsx` may query by "Font Family" label text. With Code Font added, this label appears twice. Use role-based selectors or `within()` scoping to distinguish prose vs code font controls. + +9. **Adjacent code chunks** — When two chunks appear back-to-back with no prose between them, the 8px gap (fence-close bottom-margin + fence-open top-margin) keeps them visually distinct. Each chunk gets its own badge. + +10. **Badge text** — Use the language string as-is from the fence (lowercase): `r`, `python`, `julia`, `bash`, `javascript`. Do NOT uppercase or abbreviate. The `LanguageBadgeWidget` receives the raw language from the regex match. + +11. **Chunk option size is fixed** — `#|` lines always use `calc(editor-size * 0.82)` regardless of the user's `codeSize` setting. This keeps options visually subordinate to code content at any code font size. + +12. **`getThemeColors()` reads `--nexus-text-secondary`** but `applyTheme()` never sets it — it falls back to `#94a3b8`. This is a pre-existing inconsistency. For badge styling, use `colors.accent` (which is reliably set) rather than `colors.textSecondary`. + +### Phase Breakdown + +| Phase | Scope | Est. Time | +|-------|-------|-----------| +| 1 | CSS variables in `themes.ts` | 30 min | +| 2 | ViewPlugin + LanguageBadgeWidget | 1-2 hours | +| 3 | Theme styles in `createEditorTheme()` | 30 min | +| 4 | CSS cleanup in `index.css` | 15 min | +| 5 | Settings UI (Code Font picker) | 45 min | +| 6 | Testing + validation | 30 min | + +### Explicitly Out of Scope + +| Feature | Reason | +|---------|--------| +| Run Cell / CodeLens buttons | Scribe is for writing, not execution | +| Output cells | Quarto renders outputs separately | +| Cell toolbar / drag handles | Adds IDE complexity | +| Chunk option folding | Future enhancement (complexity) | +| Inline executable code styling | Future enhancement (minimal gain) | +| Gutter line numbers in chunks | Future opt-in feature | +| Badge cursor-reveal hiding | Unnecessary complexity; always-visible is better UX | + +## History + +| Date | Change | +|------|--------| +| 2026-02-24 | Initial spec from max brainstorm with 2 agents (frontend architect + UX designer) | +| 2026-02-24 | Review round 1: resolved 4 open questions, added edge cases (empty chunks, test impact, badge color fix) | +| 2026-02-24 | Review round 2: resolved 4 more (reactivity, option size, badge text format, adjacent chunks) | +| 2026-02-24 | Review round 3: default code font changed to Fira Code, options use same codeFamily setting, status → approved | diff --git a/docs/specs/SPEC-settings-improvements-2026-02-23.md b/docs/specs/SPEC-settings-improvements-2026-02-23.md new file mode 100644 index 00000000..eb99846d --- /dev/null +++ b/docs/specs/SPEC-settings-improvements-2026-02-23.md @@ -0,0 +1,168 @@ +# SPEC: Settings Infrastructure Improvements + +> **Date:** 2026-02-23 +> **Branch:** `feature/settings-improvements` +> **Status:** Shipped (PR #47, merged 2026-02-24) +> **Origin:** PR #45 (Pomodoro Timer) code review findings + +## Summary + +Standardize settings patterns discovered during the Pomodoro PR review: extract reusable toggle components, centralize shortcut labels, and create a `usePreferences()` hook that eliminates per-render localStorage reads. Also add a pre-commit guard to prevent ORCHESTRATE files from leaking to dev/main. + +## Problem Statement + +The PR #45 review revealed several patterns that, while fixed locally for the Pomodoro feature, exist across the settings codebase: + +1. **Duplicated toggle markup** — 8+ lines of identical toggle switch JSX copy-pasted per setting +2. **Shortcut label drift** — Labels like `⌘⇧P` vs `⌘⇧N` hardcoded in 3+ locations, easily desynchronized +3. **Per-render localStorage reads** — `loadPreferences()` called in render paths, each call parsing JSON from localStorage +4. **No ORCHESTRATE cleanup guard** — Working artifacts can accidentally ship to dev/main + +## Requirements + +### Must Have (P0) +- Reusable `SettingsToggle` component with consistent markup and accessibility +- `SHORTCUTS` registry constant used by all UI labels and tests +- `usePreferences()` hook that caches preferences in React state +- Pre-commit hook blocking ORCHESTRATE files on dev/main + +### Should Have (P1) +- All existing settings tabs migrated away from direct `loadPreferences()` calls +- Test suite for `usePreferences()` hook (read, update, toggle, zustand sync) +- Test validating shortcut registry completeness + +### Won't Have (This Phase) +- Full `usePreferencesStore` Zustand store (future work — see long-term backlog) +- Reactivity across browser tabs (storage events) +- Migration of non-settings code that reads preferences + +## Design + +### SettingsToggle Component + +```tsx +// components/Settings/SettingsToggle.tsx +export function SettingsToggle({ + label, description, checked, onChange, testId +}: SettingsToggleProps) { + return ( +
+
+
{label}
+
{description}
+
+ +
+ ) +} +``` + +**Key improvements over current pattern:** +- `role="switch"` and `aria-checked` for accessibility +- Consistent `data-testid` support +- Single source of truth for toggle styling + +### Shortcut Registry + +```tsx +// lib/shortcuts.ts +export const SHORTCUTS = { + newNote: { key: 'n', mod: 'cmd', label: '⌘N', description: 'New note' }, + newProject: { key: 'n', mod: 'cmd+shift', label: '⌘⇧N', description: 'New project' }, + search: { key: 'k', mod: 'cmd', label: '⌘K', description: 'Search' }, + settings: { key: ',', mod: 'cmd', label: '⌘,', description: 'Settings' }, + pomodoro: { key: 'p', mod: 'cmd+shift', label: '⌘⇧P', description: 'Focus timer' }, + save: { key: 's', mod: 'cmd', label: '⌘S', description: 'Save' }, + bold: { key: 'b', mod: 'cmd', label: '⌘B', description: 'Bold' }, + italic: { key: 'i', mod: 'cmd', label: '⌘I', description: 'Italic' }, +} as const + +export type ShortcutId = keyof typeof SHORTCUTS +``` + +Usage in components: `SHORTCUTS.newProject.label` instead of `'⌘⇧N'`. + +### usePreferences Hook + +```tsx +// hooks/usePreferences.ts +export function usePreferences() { + const [prefs, setPrefs] = useState(() => loadPreferences()) + + const updatePref = useCallback(( + key: K, value: Preferences[K] + ) => { + updatePreferences({ [key]: value }) + const newPrefs = loadPreferences() + setPrefs(newPrefs) + // Auto-sync zustand stores + if (String(key).startsWith('pomodoro')) { + usePomodoroStore.getState().syncPreferences() + } + }, []) + + const togglePref = useCallback((key: keyof Preferences) => { + updatePref(key, !prefs[key] as any) + }, [prefs, updatePref]) + + return { prefs, updatePref, togglePref } +} +``` + +### Pre-commit Guard + +Added to existing `.husky/pre-commit` (or created if absent): + +```bash +# Block ORCHESTRATE files on protected branches +staged_orchestrate=$(git diff --cached --name-only | grep '^ORCHESTRATE-' || true) +branch=$(git branch --show-current) +if [ -n "$staged_orchestrate" ] && { [ "$branch" = "dev" ] || [ "$branch" = "main" ]; }; then + echo "❌ ORCHESTRATE files cannot be committed to $branch" + echo " Files: $staged_orchestrate" + echo " Fix: git rm ORCHESTRATE-*.md" + exit 1 +fi +``` + +## Implementation Plan + +| Step | Description | Files | Effort | +| ---- | ----------- | ----- | ------ | +| 1 | Extract SettingsToggle component + tests | 3 files | 30 min | +| 2 | Create SHORTCUTS registry, update labels | 6 files | 20 min | +| 3 | Add pre-commit guard | 1 file | 15 min | +| 4 | Create usePreferences hook + tests | 3 files | 1.5h | +| 5 | Audit + migrate all settings tabs | 5-6 files | 1h | + +**Total estimated:** ~3.5 hours + +## Testing Strategy + +- **SettingsToggle:** render tests (checked/unchecked, click handler, aria attributes) +- **SHORTCUTS:** snapshot test ensuring all keys have label + description +- **usePreferences:** unit test with `renderHook` (read, update, toggle, sync) +- **Pre-commit:** manual test on dev branch with staged ORCHESTRATE file +- **Audit:** existing settings tests must continue passing after migration + +## Risks + +- **Phase 4/5 scope creep:** Other settings tabs may have additional patterns beyond `loadPreferences()`. Limit scope to preference caching only. +- **Shortcut registry completeness:** May miss shortcuts in CodeMirror keymaps (those are editor-level, not app-level). Registry covers app shortcuts only. + +## Future Work (Not This Branch) + +- **`usePreferencesStore` Zustand store** — Full reactive preferences with `storage` event listener for cross-tab sync. Replaces localStorage entirely. See long-term backlog. diff --git a/package-lock.json b/package-lock.json index f63811c2..72256273 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "autoprefixer": "^10.4.20", "fake-indexeddb": "^6.2.5", "happy-dom": "^20.0.11", + "husky": "^9.1.7", "jsdom": "^27.3.0", "postcss": "^8.4.40", "tailwindcss": "^3.4.7", @@ -7895,6 +7896,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", diff --git a/package.json b/package.json index f1539b7e..8db98ae9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scribe", - "version": "1.19.0", + "version": "1.20.0", "description": "Scribe - ADHD-friendly distraction-free writer", "main": "dist-electron/main/index.js", "author": "Stat-Wise", @@ -20,7 +20,8 @@ "test:coverage": "vitest run --coverage", "test:e2e": "playwright test --config=e2e/playwright.config.ts", "test:e2e:ui": "playwright test --config=e2e/playwright.config.ts --ui", - "test:e2e:headed": "playwright test --config=e2e/playwright.config.ts --headed" + "test:e2e:headed": "playwright test --config=e2e/playwright.config.ts --headed", + "prepare": "husky" }, "dependencies": { "@codemirror/lang-markdown": "^6.5.0", @@ -69,6 +70,7 @@ "autoprefixer": "^10.4.20", "fake-indexeddb": "^6.2.5", "happy-dom": "^20.0.11", + "husky": "^9.1.7", "jsdom": "^27.3.0", "postcss": "^8.4.40", "tailwindcss": "^3.4.7", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7701181e..1a7b019b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1808,9 +1808,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "js-sys" -version = "0.3.88" +version = "0.3.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d" +checksum = "f4eacb0641a310445a4c513f2a5e23e19952e269c6a38887254d5f837a305506" dependencies = [ "once_cell", "wasm-bindgen", @@ -2990,9 +2990,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rend" @@ -3216,7 +3216,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scribe" -version = "1.19.0" +version = "1.20.0" dependencies = [ "chrono", "dirs 5.0.1", @@ -4075,9 +4075,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.25.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", "getrandom 0.4.1", @@ -4640,9 +4640,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.111" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac" +checksum = "05d7d0fce354c88b7982aec4400b3e7fcf723c32737cef571bd165f7613557ee" dependencies = [ "cfg-if", "once_cell", @@ -4653,9 +4653,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.61" +version = "0.4.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe88540d1c934c4ec8e6db0afa536876c5441289d7f9f9123d4f065ac1250a6b" +checksum = "ee85afca410ac4abba5b584b12e77ea225db6ee5471d0aebaae0861166f9378a" dependencies = [ "cfg-if", "futures-util", @@ -4667,9 +4667,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.111" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1" +checksum = "55839b71ba921e4f75b674cb16f843f4b1f3b26ddfcb3454de1cf65cc021ec0f" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4677,9 +4677,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.111" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af" +checksum = "caf2e969c2d60ff52e7e98b7392ff1588bffdd1ccd4769eba27222fd3d621571" dependencies = [ "bumpalo", "proc-macro2", @@ -4690,9 +4690,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.111" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41" +checksum = "0861f0dcdf46ea819407495634953cdcc8a8c7215ab799a7a7ce366be71c7b30" dependencies = [ "unicode-ident", ] @@ -4746,9 +4746,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.88" +version = "0.3.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d6bb20ed2d9572df8584f6dc81d68a41a625cadc6f15999d649a70ce7e3597a" +checksum = "10053fbf9a374174094915bbce141e87a6bf32ecd9a002980db4b638405e8962" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c18284b3..cd1acb90 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "scribe" -version = "1.19.0" +version = "1.20.0" description = "Scribe - ADHD-friendly distraction-free writer" authors = ["Stat-Wise"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f233b72f..9826a01c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Scribe", - "version": "1.19.0", + "version": "1.20.0", "identifier": "com.scribe.app", "build": { "frontendDist": "../dist", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fc299115..f14f5cf1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -6,6 +6,7 @@ import { useSettingsStore } from './store/useSettingsStore' import { EditorTabs } from './components/EditorTabs' import { useForestTheme } from './hooks/useForestTheme' import { useIconGlowEffect } from './hooks/useIconGlowEffect' +import { usePreferences } from './hooks/usePreferences' import { BacklinksPanel } from './components/BacklinksPanel' import { TagFilter } from './components/TagFilter' import { PropertiesPanel } from './components/PropertiesPanel' @@ -51,11 +52,9 @@ import { loadThemeShortcuts, } from './lib/themes' import { - loadPreferences, updatePreferences, updateStreak, getStreakInfo, - UserPreferences, EditorMode, SidebarTabId, } from './lib/preferences' @@ -131,8 +130,8 @@ function App() { // Editor container ref for auto-collapse functionality const editorContainerRef = useRef(null) - // User preferences with persistence - const [preferences, setPreferences] = useState(() => loadPreferences()) + // User preferences with persistence (auto-syncs via preferences-changed events) + const { prefs: preferences, updatePref } = usePreferences() const [streakInfo, setStreakInfo] = useState(() => getStreakInfo()) // Focus mode state (persisted) @@ -141,8 +140,7 @@ function App() { // Persist focus mode changes const handleFocusModeChange = (enabled: boolean) => { setFocusMode(enabled) - const updated = updatePreferences({ focusModeEnabled: enabled }) - setPreferences(updated) + updatePref('focusModeEnabled', enabled) } // Sidebar collapse state (leftSidebarCollapsed now handled by DashboardShell) @@ -159,74 +157,6 @@ function App() { }) const [isResizingRight, setIsResizingRight] = useState(false) - // Session timer for focus tracking (ADHD feature) - persisted to localStorage - const [sessionStart, setSessionStart] = useState(() => { - const saved = localStorage.getItem('sessionStart') - return saved ? parseInt(saved) : Date.now() - }) - const [sessionDuration, setSessionDuration] = useState(0) - const [timerPaused, setTimerPaused] = useState(() => { - return localStorage.getItem('timerPaused') === 'true' - }) - const [pausedDuration, setPausedDuration] = useState(() => { - const saved = localStorage.getItem('pausedDuration') - return saved ? parseInt(saved) : 0 - }) - - // Persist session start to localStorage - useEffect(() => { - localStorage.setItem('sessionStart', sessionStart.toString()) - }, [sessionStart]) - - // Persist paused state - useEffect(() => { - localStorage.setItem('timerPaused', timerPaused.toString()) - }, [timerPaused]) - - // Persist paused duration - useEffect(() => { - localStorage.setItem('pausedDuration', pausedDuration.toString()) - }, [pausedDuration]) - - useEffect(() => { - if (timerPaused) return - const timer = setInterval(() => { - setSessionDuration(Math.floor((Date.now() - sessionStart) / 1000) - pausedDuration) - }, 1000) - return () => clearInterval(timer) - }, [sessionStart, timerPaused, pausedDuration]) - - const formatSessionTime = (seconds: number) => { - const mins = Math.floor(seconds / 60) - const secs = seconds % 60 - return `${mins}:${secs.toString().padStart(2, '0')}` - } - - const toggleTimerPause = () => { - if (timerPaused) { - // Resuming - calculate how long we were paused and add to pausedDuration - const pauseStart = parseInt(localStorage.getItem('pauseStart') || '0') - if (pauseStart) { - const additionalPause = Math.floor((Date.now() - pauseStart) / 1000) - setPausedDuration(prev => prev + additionalPause) - } - localStorage.removeItem('pauseStart') - } else { - // Pausing - record when we paused - localStorage.setItem('pauseStart', Date.now().toString()) - } - setTimerPaused(!timerPaused) - } - - const resetTimer = () => { - const newStart = Date.now() - setSessionStart(newStart) - setSessionDuration(0) - setPausedDuration(0) - setTimerPaused(false) - localStorage.removeItem('pauseStart') - } - // Tab state (leftActiveTab removed - notes list is in DashboardShell now) const [rightActiveTab, setRightActiveTab] = useState<'properties' | 'backlinks' | 'tags' | 'stats' | 'claude' | 'terminal'>('properties') @@ -255,14 +185,11 @@ function App() { const sidebarTabSize = (settings['appearance.sidebarTabSize'] || 'compact') as 'compact' | 'full' // const showSidebarIcons = settings['appearance.showSidebarIcons'] ?? true // TODO: Implement icon visibility - const [sidebarTabSettings, setSidebarTabSettings] = useState(() => { - const prefs = loadPreferences() - return { - tabSize: sidebarTabSize, // Now from Settings store - tabOrder: prefs.sidebarTabOrder, // Still from preferences (not in Settings store yet) - hiddenTabs: prefs.sidebarHiddenTabs // Still from preferences (not in Settings store yet) - } - }) + const [sidebarTabSettings, setSidebarTabSettings] = useState(() => ({ + tabSize: sidebarTabSize, // Now from Settings store + tabOrder: preferences.sidebarTabOrder, // Still from preferences (not in Settings store yet) + hiddenTabs: preferences.sidebarHiddenTabs // Still from preferences (not in Settings store yet) + })) // Sidebar tab drag state (v1.8) const [draggedSidebarTab, setDraggedSidebarTab] = useState(null) @@ -292,17 +219,11 @@ function App() { // Tag filtering - filteredNotes now handled by MissionSidebar - // Session tracking - time when first keystroke occurred - const [sessionStartTime, setSessionStartTime] = useState(null) - // Backlinks refresh key - increment to force BacklinksPanel refresh const [backlinksRefreshKey, setBacklinksRefreshKey] = useState(0) // Editor mode - source, live-preview, or reading (persisted in preferences) - const [editorMode, setEditorMode] = useState(() => { - const prefs = loadPreferences() - return prefs.editorMode || 'source' - }) + const [editorMode, setEditorMode] = useState(() => preferences.editorMode || 'source') // Tags for current note (for PropertiesPanel display) const [currentNoteTags, setCurrentNoteTags] = useState([]) @@ -536,11 +457,6 @@ function App() { const handleContentChange = async (content: string) => { if (selectedNote) { - // Start session timer on first keystroke - if (!sessionStartTime && content.length > 0) { - setSessionStartTime(Date.now()) - } - // Extract tags from content and sync to YAML properties const extractedTags = extractTagsFromContent(content) const currentProperties = selectedNote.properties || {} @@ -742,26 +658,21 @@ function App() { localStorage.setItem('rightSidebarCollapsed', rightSidebarCollapsed.toString()) }, [rightSidebarCollapsed]) - // Listen for sidebar preference changes (from Settings modal) + // Sync sidebar tab settings when preferences change (hook auto-syncs via preferences-changed) useEffect(() => { - const handlePrefsChanged = () => { - const prefs = loadPreferences() - setSidebarTabSettings({ - tabSize: prefs.sidebarTabSize, - tabOrder: prefs.sidebarTabOrder, - hiddenTabs: prefs.sidebarHiddenTabs - }) - // If the active tab is now hidden, switch to the first visible tab - if (prefs.sidebarHiddenTabs.includes(rightActiveTab)) { - const firstVisible = prefs.sidebarTabOrder.find( - (t: SidebarTabId) => !prefs.sidebarHiddenTabs.includes(t) - ) - if (firstVisible) setRightActiveTab(firstVisible) - } + setSidebarTabSettings({ + tabSize: preferences.sidebarTabSize, + tabOrder: preferences.sidebarTabOrder, + hiddenTabs: preferences.sidebarHiddenTabs + }) + // If the active tab is now hidden, switch to the first visible tab + if (preferences.sidebarHiddenTabs.includes(rightActiveTab)) { + const firstVisible = preferences.sidebarTabOrder.find( + (t: SidebarTabId) => !preferences.sidebarHiddenTabs.includes(t) + ) + if (firstVisible) setRightActiveTab(firstVisible) } - window.addEventListener('preferences-changed', handlePrefsChanged) - return () => window.removeEventListener('preferences-changed', handlePrefsChanged) - }, [rightActiveTab]) + }, [preferences.sidebarTabSize, preferences.sidebarTabOrder, preferences.sidebarHiddenTabs, rightActiveTab]) // Auto-collapse sidebar when editor is focused useEffect(() => { @@ -1043,7 +954,6 @@ function App() { wordCount={wordCount} sessionStartWords={sessionStartWords} streakInfo={streakInfo} - sessionStartTime={sessionStartTime} preferences={preferences} onToggleTerminal={() => { if (rightActiveTab === 'terminal' && !rightSidebarCollapsed) { @@ -1287,26 +1197,6 @@ function App() { )} - {/* Focus timer with controls */} -
- - - {formatSessionTime(sessionDuration)} - - -
{/* Editor tabs bar */} @@ -1353,7 +1243,6 @@ function App() { wordCount={wordCount} sessionStartWords={sessionStartWords} streakInfo={streakInfo} - sessionStartTime={sessionStartTime} preferences={preferences} onToggleTerminal={() => { if (rightActiveTab === 'terminal' && !rightSidebarCollapsed) { @@ -1536,7 +1425,7 @@ function App() { currentProjectId={currentProjectId} wordCount={wordCount} wordGoal={selectedNote.properties?.word_goal ? Number(selectedNote.properties.word_goal.value) : preferences.defaultWordGoal} - sessionStartTime={sessionStartTime || undefined} + onSelectProject={setCurrentProject} onSelectNote={(noteId) => { const note = notes.find(n => n.id === noteId) diff --git a/src/renderer/src/__tests__/EditorOrchestrator.test.tsx b/src/renderer/src/__tests__/EditorOrchestrator.test.tsx index 6de91d96..dedcb0f6 100644 --- a/src/renderer/src/__tests__/EditorOrchestrator.test.tsx +++ b/src/renderer/src/__tests__/EditorOrchestrator.test.tsx @@ -37,7 +37,6 @@ describe('EditorOrchestrator', () => { wordCount: 100, sessionStartWords: {}, streakInfo: { streak: 5, isActiveToday: true }, - sessionStartTime: Date.now(), preferences: createMockPreferences(), onToggleTerminal: vi.fn(), focusMode: false, diff --git a/src/renderer/src/__tests__/SettingsToggle.test.tsx b/src/renderer/src/__tests__/SettingsToggle.test.tsx new file mode 100644 index 00000000..00312c78 --- /dev/null +++ b/src/renderer/src/__tests__/SettingsToggle.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { SettingsToggle } from '../components/Settings/SettingsToggle' + +describe('SettingsToggle', () => { + it('renders label and description', () => { + render( + {}} + /> + ) + + expect(screen.getByText('My Label')).toBeInTheDocument() + expect(screen.getByText('My Description')).toBeInTheDocument() + }) + + it('shows checked state with accent color', () => { + render( + {}} + testId="my-toggle" + /> + ) + + const button = screen.getByTestId('my-toggle') + expect(button.className).toContain('bg-nexus-accent') + expect(button.className).not.toContain('bg-white/10') + }) + + it('shows unchecked state with white/10 color', () => { + render( + {}} + testId="my-toggle" + /> + ) + + const button = screen.getByTestId('my-toggle') + expect(button.className).toContain('bg-white/10') + expect(button.className).not.toContain('bg-nexus-accent') + }) + + it('calls onChange on click', () => { + const handleChange = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByTestId('my-toggle')) + expect(handleChange).toHaveBeenCalledTimes(1) + }) + + it('uses testId prop when provided', () => { + render( + {}} + testId="custom-test-id" + /> + ) + + expect(screen.getByTestId('custom-test-id')).toBeInTheDocument() + }) + + it('has correct accessibility attributes', () => { + render( + {}} + testId="a11y-toggle" + /> + ) + + const button = screen.getByTestId('a11y-toggle') + expect(button).toHaveAttribute('role', 'switch') + expect(button).toHaveAttribute('aria-checked', 'true') + expect(button).toHaveAttribute('aria-label', 'My Toggle') + }) + + it('sets aria-checked to false when unchecked', () => { + render( + {}} + testId="unchecked-toggle" + /> + ) + + const button = screen.getByTestId('unchecked-toggle') + expect(button).toHaveAttribute('aria-checked', 'false') + }) +}) diff --git a/src/renderer/src/__tests__/StatsPanel.test.tsx b/src/renderer/src/__tests__/StatsPanel.test.tsx index 1650aa0a..eb0b977d 100644 --- a/src/renderer/src/__tests__/StatsPanel.test.tsx +++ b/src/renderer/src/__tests__/StatsPanel.test.tsx @@ -3,6 +3,11 @@ import { render, screen, fireEvent } from '@testing-library/react' import { StatsPanel } from '../components/StatsPanel' import { Project, Note } from '../types' +// Mock usePomodoroStore +vi.mock('../store/usePomodoroStore', () => ({ + usePomodoroStore: (selector: (s: { completedToday: number }) => number) => selector({ completedToday: 5 }), +})) + // Mock data const mockProjects: Project[] = [ { @@ -74,7 +79,6 @@ describe('StatsPanel Component', () => { currentProjectId: 'proj-1', wordCount: 150, wordGoal: 500, - sessionStartTime: Date.now() - 1800000, // 30 minutes ago onSelectProject: vi.fn(), onSelectNote: vi.fn(), } @@ -89,11 +93,12 @@ describe('StatsPanel Component', () => { expect(screen.getByText('Session')).toBeInTheDocument() }) - it('displays session duration', () => { + it('displays Pomodoro count from store', () => { render() - expect(screen.getByText('Duration')).toBeInTheDocument() - // 30 minutes ago should show "30m" - expect(screen.getByText('30m')).toBeInTheDocument() + expect(screen.getByText('Pomodoros')).toBeInTheDocument() + // Pomodoro count is in the Session section + const sessionSection = screen.getByText('Session').closest('section') + expect(sessionSection).toHaveTextContent('5') }) it('displays word count', () => { @@ -101,17 +106,6 @@ describe('StatsPanel Component', () => { expect(screen.getByText('Words')).toBeInTheDocument() expect(screen.getByText('150')).toBeInTheDocument() }) - - it('shows -- when no session started', () => { - render() - expect(screen.getByText('--')).toBeInTheDocument() - }) - - it('shows hours and minutes for long sessions', () => { - const twoHoursAgo = Date.now() - 7500000 // 2h 5m - render() - expect(screen.getByText('2h 5m')).toBeInTheDocument() - }) }) describe('Daily Goal Progress', () => { @@ -174,7 +168,8 @@ describe('StatsPanel Component', () => { it('displays total note count', () => { render() expect(screen.getByText('Notes')).toBeInTheDocument() - expect(screen.getByText('3')).toBeInTheDocument() // 3 mock notes + const allNotesSection = screen.getByText('All Notes').closest('section') + expect(allNotesSection).toHaveTextContent('3') // 3 mock notes }) it('displays active project count', () => { @@ -193,7 +188,8 @@ describe('StatsPanel Component', () => { ] render() // Should still show 3, not 4 - expect(screen.getByText('3')).toBeInTheDocument() + const allNotesSection = screen.getByText('All Notes').closest('section') + expect(allNotesSection).toHaveTextContent('3') }) }) diff --git a/src/renderer/src/__tests__/Themes.test.ts b/src/renderer/src/__tests__/Themes.test.ts index ad3b99c5..436409fe 100644 --- a/src/renderer/src/__tests__/Themes.test.ts +++ b/src/renderer/src/__tests__/Themes.test.ts @@ -1024,7 +1024,7 @@ describe('Font Settings', () => { describe('DEFAULT_FONT_SETTINGS', () => { it('has sensible defaults', () => { expect(DEFAULT_FONT_SETTINGS.family).toBe('system') - expect(DEFAULT_FONT_SETTINGS.size).toBe(18) + expect(DEFAULT_FONT_SETTINGS.size).toBe(15) expect(DEFAULT_FONT_SETTINGS.lineHeight).toBe(1.8) }) diff --git a/src/renderer/src/__tests__/shortcuts.test.ts b/src/renderer/src/__tests__/shortcuts.test.ts new file mode 100644 index 00000000..6f33a52c --- /dev/null +++ b/src/renderer/src/__tests__/shortcuts.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import { SHORTCUTS, matchesShortcut } from '../lib/shortcuts' + +/** Helper to create a minimal KeyboardEvent-like object for testing */ +function makeKeyEvent(overrides: Partial = {}): KeyboardEvent { + return { + key: '', + code: '', + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + ...overrides, + } as KeyboardEvent +} + +describe('SHORTCUTS registry', () => { + const entries = Object.entries(SHORTCUTS) + + it('all entries have non-empty key, mod, and label', () => { + for (const [id, shortcut] of entries) { + expect(shortcut.key, `${id}.key should be non-empty`).toBeTruthy() + expect(shortcut.mod, `${id}.mod should be non-empty`).toBeTruthy() + expect(shortcut.label, `${id}.label should be non-empty`).toBeTruthy() + } + }) + + it('all labels start with the command symbol', () => { + for (const [id, shortcut] of entries) { + expect( + shortcut.label.startsWith('\u2318'), + `${id}.label ("${shortcut.label}") should start with \u2318` + ).toBe(true) + } + }) + + it('has no duplicate labels', () => { + const labels = entries.map(([, s]) => s.label) + const duplicates = labels.filter((l, i) => labels.indexOf(l) !== i) + expect( + duplicates, + `Duplicate labels found: ${duplicates.join(', ')}` + ).toHaveLength(0) + }) +}) + +describe('matchesShortcut', () => { + it('matches cmd-only shortcuts (e.g. newNote = ⌘N)', () => { + const event = makeKeyEvent({ key: 'n', metaKey: true }) + expect(matchesShortcut(event, 'newNote')).toBe(true) + }) + + it('matches cmd-only shortcuts with ctrlKey (cross-platform)', () => { + const event = makeKeyEvent({ key: 'n', ctrlKey: true }) + expect(matchesShortcut(event, 'newNote')).toBe(true) + }) + + it('matches cmd+shift shortcuts (e.g. focusMode = ⌘⇧F)', () => { + const event = makeKeyEvent({ key: 'F', metaKey: true, shiftKey: true }) + expect(matchesShortcut(event, 'focusMode')).toBe(true) + }) + + it('matches cmd+alt shortcuts (e.g. terminal = ⌘⌥T)', () => { + const event = makeKeyEvent({ key: 't', metaKey: true, altKey: true }) + expect(matchesShortcut(event, 'terminal')).toBe(true) + }) + + it('returns false when key does not match', () => { + const event = makeKeyEvent({ key: 'x', metaKey: true }) + expect(matchesShortcut(event, 'newNote')).toBe(false) + }) + + it('returns false when required cmd modifier is missing', () => { + const event = makeKeyEvent({ key: 'n' }) + expect(matchesShortcut(event, 'newNote')).toBe(false) + }) + + it('returns false when extra shift modifier is present but not expected', () => { + // newNote is cmd-only, so shift should cause a mismatch + const event = makeKeyEvent({ key: 'n', metaKey: true, shiftKey: true }) + expect(matchesShortcut(event, 'newNote')).toBe(false) + }) + + it('returns false when extra alt modifier is present but not expected', () => { + const event = makeKeyEvent({ key: 'F', metaKey: true, shiftKey: true, altKey: true }) + expect(matchesShortcut(event, 'focusMode')).toBe(false) + }) + + it('returns false when required shift is missing for cmd+shift shortcut', () => { + const event = makeKeyEvent({ key: 'F', metaKey: true }) + expect(matchesShortcut(event, 'focusMode')).toBe(false) + }) + + it('returns false when required alt is missing for cmd+alt shortcut', () => { + const event = makeKeyEvent({ key: 't', metaKey: true }) + expect(matchesShortcut(event, 'terminal')).toBe(false) + }) +}) diff --git a/src/renderer/src/__tests__/testUtils.ts b/src/renderer/src/__tests__/testUtils.ts index dd2d71b9..b21df7f1 100644 --- a/src/renderer/src/__tests__/testUtils.ts +++ b/src/renderer/src/__tests__/testUtils.ts @@ -131,9 +131,12 @@ export function createMockPreferences(overrides: Partial = {}): showWordGoalProgress: true, celebrateMilestones: true, streakDisplayOptIn: false, + openLastPage: true, editorMode: 'source', customCSS: '', customCSSEnabled: false, + readableLineLength: true, + spellcheck: false, hudMode: 'layered', hudSide: 'left', hudRibbonVisible: true, diff --git a/src/renderer/src/__tests__/usePreferences.test.ts b/src/renderer/src/__tests__/usePreferences.test.ts new file mode 100644 index 00000000..934924ab --- /dev/null +++ b/src/renderer/src/__tests__/usePreferences.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { usePreferences } from '../hooks/usePreferences' + +// Mock localStorage +const store: Record = {} +const localStorageMock = { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { store[key] = value }), + removeItem: vi.fn((key: string) => { delete store[key] }), + clear: vi.fn(() => { Object.keys(store).forEach(k => delete store[k]) }), + length: 0, + key: vi.fn(() => null), +} + +Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + +describe('usePreferences', () => { + beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + }) + + it('reads preferences on mount', () => { + const { result } = renderHook(() => usePreferences()) + expect(result.current.prefs).toBeDefined() + expect(result.current.prefs.defaultWordGoal).toBe(500) + }) + + it('returns cached snapshot without re-reading localStorage', () => { + const { result } = renderHook(() => usePreferences()) + // Access prefs twice — getItem should only have been called during mount + const callsBefore = localStorageMock.getItem.mock.calls.length + void result.current.prefs.editorMode + void result.current.prefs.focusModeEnabled + expect(localStorageMock.getItem.mock.calls.length).toBe(callsBefore) + }) + + it('updatePref writes to localStorage and syncs state', () => { + const { result } = renderHook(() => usePreferences()) + + act(() => { + result.current.updatePref('defaultWordGoal', 1000) + }) + + expect(result.current.prefs.defaultWordGoal).toBe(1000) + expect(localStorageMock.setItem).toHaveBeenCalled() + }) + + it('togglePref flips boolean preferences', () => { + const { result } = renderHook(() => usePreferences()) + const before = result.current.prefs.streakDisplayOptIn + + act(() => { + result.current.togglePref('streakDisplayOptIn') + }) + + expect(result.current.prefs.streakDisplayOptIn).toBe(!before) + }) + + it('togglePref ignores non-boolean preferences', () => { + const { result } = renderHook(() => usePreferences()) + + act(() => { + result.current.togglePref('defaultWordGoal') + }) + + // Should remain unchanged + expect(result.current.prefs.defaultWordGoal).toBe(500) + }) + + it('syncs when preferences-changed event is dispatched externally', () => { + const { result } = renderHook(() => usePreferences()) + + // Simulate external write + act(() => { + store['scribe-preferences'] = JSON.stringify({ + ...result.current.prefs, + celebrateMilestones: false, + }) + window.dispatchEvent(new CustomEvent('preferences-changed')) + }) + + expect(result.current.prefs.celebrateMilestones).toBe(false) + }) + + it('cleans up event listener on unmount', () => { + const spy = vi.spyOn(window, 'removeEventListener') + const { unmount } = renderHook(() => usePreferences()) + + unmount() + + expect(spy).toHaveBeenCalledWith('preferences-changed', expect.any(Function)) + spy.mockRestore() + }) +}) diff --git a/src/renderer/src/components/CommandPalette.tsx b/src/renderer/src/components/CommandPalette.tsx index f8760653..2aaea927 100644 --- a/src/renderer/src/components/CommandPalette.tsx +++ b/src/renderer/src/components/CommandPalette.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { SHORTCUTS } from '../lib/shortcuts' import { Command } from 'cmdk' import { Root as VisuallyHidden } from '@radix-ui/react-visually-hidden' import { Title as DialogTitle, Description as DialogDescription } from '@radix-ui/react-dialog' @@ -154,7 +155,7 @@ export function CommandPalette({ 1 Create New Page - ⌘N + {SHORTCUTS.newNote.label} { onDailyNote(); setOpen(false); }} @@ -164,7 +165,7 @@ export function CommandPalette({ 2 Open Today's Journal - ⌘D + {SHORTCUTS.dailyNote.label} { @@ -227,7 +228,7 @@ export function CommandPalette({ 6 Toggle Focus Mode - ⌘⇧F + {SHORTCUTS.focusMode.label} {onOpenGraph && ( 7 Open Knowledge Graph - ⌘⇧G + {SHORTCUTS.graphView.label} )} {hasSelectedNote && onExport && ( @@ -248,7 +249,7 @@ export function CommandPalette({ 8 Export Page (PDF/Word/LaTeX) - ⌘⇧E + {SHORTCUTS.exportNote.label} )} diff --git a/src/renderer/src/components/DashboardHeader.tsx b/src/renderer/src/components/DashboardHeader.tsx index 144b89ee..f23d6e9f 100644 --- a/src/renderer/src/components/DashboardHeader.tsx +++ b/src/renderer/src/components/DashboardHeader.tsx @@ -1,4 +1,5 @@ import { Calendar, FilePlus, Zap, Search, Settings, Minimize2, Target } from 'lucide-react' +import { SHORTCUTS } from '../lib/shortcuts' interface DashboardHeaderProps { totalNotes: number @@ -51,21 +52,21 @@ export function DashboardHeader({ @@ -77,28 +78,28 @@ export function DashboardHeader({ } label="Today" - shortcut="⌘D" + shortcut={SHORTCUTS.dailyNote.label} onClick={onDailyNote} color="blue" /> } label="New Page" - shortcut="⌘N" + shortcut={SHORTCUTS.newNote.label} onClick={onCreateNote} color="green" /> } label="Quick Capture" - shortcut="⌘⇧C" + shortcut={SHORTCUTS.quickCapture.label} onClick={onQuickCapture} color="yellow" /> } label="Search" - shortcut="⌘F" + shortcut={SHORTCUTS.search.label} onClick={onSearch} color="purple" /> diff --git a/src/renderer/src/components/DashboardShell.tsx b/src/renderer/src/components/DashboardShell.tsx index f2bd8a88..c4f14c26 100644 --- a/src/renderer/src/components/DashboardShell.tsx +++ b/src/renderer/src/components/DashboardShell.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { SHORTCUTS } from '../lib/shortcuts' import { Project, Note } from '../types' import { DashboardHeader } from './DashboardHeader' import { ProjectsPanel } from './ProjectsPanel' @@ -106,7 +107,7 @@ export function DashboardShell({ {/* Close Others */} diff --git a/src/renderer/src/components/EmptyState.tsx b/src/renderer/src/components/EmptyState.tsx index bf970dae..2c6346c2 100644 --- a/src/renderer/src/components/EmptyState.tsx +++ b/src/renderer/src/components/EmptyState.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { SHORTCUTS } from '../lib/shortcuts' interface EmptyStateProps { onCreateNote: () => void @@ -68,7 +69,7 @@ export function EmptyState({ onCreateNote, onOpenDaily, onOpenCommandPalette }: New Page - ⌘N + {SHORTCUTS.newNote.label} @@ -90,7 +91,7 @@ export function EmptyState({ onCreateNote, onOpenDaily, onOpenCommandPalette }: onClick={onOpenCommandPalette} className="mx-1.5 px-2 py-1 bg-nexus-bg-tertiary rounded border border-white/10 hover:bg-nexus-bg-secondary transition-colors" > - ⌘K + {SHORTCUTS.commandPalette.label} for all commands diff --git a/src/renderer/src/components/HybridEditor.tsx b/src/renderer/src/components/HybridEditor.tsx index 21590a95..3208cf13 100644 --- a/src/renderer/src/components/HybridEditor.tsx +++ b/src/renderer/src/components/HybridEditor.tsx @@ -1,4 +1,5 @@ import React, { useState, useCallback, useEffect, useRef } from 'react' +import { SHORTCUTS } from '../lib/shortcuts' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' @@ -29,7 +30,6 @@ interface HybridEditorProps { wordGoal?: number // Daily word goal for progress visualization sessionStartWords?: number // Words at session start for tracking session progress streak?: number // Current writing streak in days - sessionStartTime?: number // Timestamp when session started (for timer display) onToggleTerminal?: () => void // Callback to toggle Terminal panel pomodoroEnabled?: boolean // Show pomodoro timer in status bar onPomodoroComplete?: () => void // Called when a pomodoro work session completes @@ -59,7 +59,6 @@ export function HybridEditor({ wordGoal = 500, sessionStartWords = 0, streak = 0, - sessionStartTime, onToggleTerminal, pomodoroEnabled = true, onPomodoroComplete, @@ -285,7 +284,7 @@ export function HybridEditor({
@@ -305,7 +304,7 @@ export function HybridEditor({ ? 'bg-nexus-accent text-white shadow-sm' : 'text-nexus-text-muted hover:text-nexus-text-primary' }`} - title="Live Preview mode (⌘2)" + title={`Live Preview mode (${SHORTCUTS.livePreview.label})`} > Live @@ -316,7 +315,7 @@ export function HybridEditor({ ? 'bg-nexus-accent text-white shadow-sm' : 'text-nexus-text-muted hover:text-nexus-text-primary' }`} - title="Reading mode (⌘3)" + title={`Reading mode (${SHORTCUTS.readingMode.label})`} > Reading @@ -436,7 +435,7 @@ export function HybridEditor({ {mode === 'source' && 'Source'} {mode === 'live-preview' && 'Live Preview'} {mode === 'reading' && 'Reading'} - ⌘E + {SHORTCUTS.cycleMode.label} {/* Writing progress */} @@ -445,7 +444,6 @@ export function HybridEditor({ wordGoal={wordGoal} sessionStartWords={sessionStartWords} streak={streak} - sessionStartTime={sessionStartTime} /> {/* Pomodoro timer */} @@ -481,7 +479,7 @@ export function HybridEditor({ @@ -34,7 +35,7 @@ export function QuickActions({ onDailyNote, onNewNote, onQuickCapture, onNewProj
New Page
-
⌘N
+
{SHORTCUTS.newNote.label}
@@ -48,7 +49,7 @@ export function QuickActions({ onDailyNote, onNewNote, onQuickCapture, onNewProj
Quick Capture
-
⌘⇧C
+
{SHORTCUTS.quickCapture.label}
@@ -62,7 +63,7 @@ export function QuickActions({ onDailyNote, onNewNote, onQuickCapture, onNewProj
New Project
-
⌘⇧N
+
{SHORTCUTS.newProject.label}
diff --git a/src/renderer/src/components/SearchBar.tsx b/src/renderer/src/components/SearchBar.tsx index 9fe3b642..519b2ebc 100644 --- a/src/renderer/src/components/SearchBar.tsx +++ b/src/renderer/src/components/SearchBar.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react' +import { SHORTCUTS } from '../lib/shortcuts' interface SearchBarProps { onSearch: (query: string) => void @@ -65,7 +66,7 @@ export function SearchBar({ onSearch, onClear }: SearchBarProps) { type="text" value={query} onChange={(e) => handleChange(e.target.value)} - placeholder="Search notes... (⌘K)" + placeholder={`Search notes... (${SHORTCUTS.commandPalette.label})`} className="w-full px-4 py-2 pl-10 pr-8 bg-nexus-bg-primary border border-gray-600 rounded-lg focus:outline-none focus:border-nexus-accent transition-colors text-nexus-text-primary placeholder-gray-500" diff --git a/src/renderer/src/components/Settings/EditorSettingsTab.tsx b/src/renderer/src/components/Settings/EditorSettingsTab.tsx index 0e01e65f..e354805b 100644 --- a/src/renderer/src/components/Settings/EditorSettingsTab.tsx +++ b/src/renderer/src/components/Settings/EditorSettingsTab.tsx @@ -31,7 +31,9 @@ import { } from '../../lib/dailyNoteTemplates' import { api } from '../../lib/api' import { isTauri } from '../../lib/platform' +import { usePreferences } from '../../hooks/usePreferences' import { SettingsSection } from './SettingsSection' +import { SettingsToggle } from './SettingsToggle' interface EditorSettingsTabProps { fontSettings: FontSettings @@ -49,6 +51,7 @@ interface EditorSettingsTabProps { * - Right sidebar settings */ export function EditorSettingsTab({ fontSettings, onFontSettingsChange }: EditorSettingsTabProps) { + const { prefs, togglePref } = usePreferences() // Daily note templates state const [templates] = useState(() => loadTemplates()) const [selectedTemplateId, setSelectedTemplate] = useState(() => getSelectedTemplateId()) @@ -252,25 +255,19 @@ export function EditorSettingsTab({ fontSettings, onFontSettingsChange }: Editor {/* Writing Experience */}
-
-
-
Readable line length
-
Limit maximum line width for focus.
-
-
-
-
-
- -
-
-
Spellcheck
-
Enable browser-native spellchecking.
-
-
-
-
-
+ togglePref('readableLineLength')} + /> + + togglePref('spellcheck')} + />
diff --git a/src/renderer/src/components/Settings/GeneralSettingsTab.tsx b/src/renderer/src/components/Settings/GeneralSettingsTab.tsx index bad7caaf..1e07f7d5 100644 --- a/src/renderer/src/components/Settings/GeneralSettingsTab.tsx +++ b/src/renderer/src/components/Settings/GeneralSettingsTab.tsx @@ -1,12 +1,14 @@ import { useState, useCallback } from 'react' import { User, Terminal, Globe, Database, Trash2, RotateCcw, Home, Timer } from 'lucide-react' import { isBrowser, isTauri } from '../../lib/platform' -import { loadPreferences, updatePreferences } from '../../lib/preferences' import { getDefaultTerminalFolder, setDefaultTerminalFolder } from '../../lib/terminal-utils' import { db, seedDemoData } from '../../lib/browser-db' import { usePomodoroStore } from '../../store/usePomodoroStore' +import { loadPreferences, updatePreferences } from '../../lib/preferences' +import { usePreferences } from '../../hooks/usePreferences' import { PinnedVaultsSettings } from './PinnedVaultsSettings' import { SettingsSection } from './SettingsSection' +import { SettingsToggle } from './SettingsToggle' /** * General Settings Tab @@ -22,53 +24,37 @@ import { SettingsSection } from './SettingsSection' */ export function GeneralSettingsTab() { const [terminalFolder, setTerminalFolder] = useState(() => getDefaultTerminalFolder()) - const [prefs, setPrefs] = useState(() => loadPreferences()) + const { prefs, togglePref: baseTogglePref } = usePreferences() /** Toggle a boolean preference and sync pomodoro store if needed */ const togglePref = useCallback((key: keyof ReturnType) => { - const updated = { [key]: !prefs[key] } - updatePreferences(updated) - const newPrefs = loadPreferences() - setPrefs(newPrefs) - // Keep pomodoro store in sync when any pomodoro pref changes - if (key.startsWith('pomodoro')) { + baseTogglePref(key) + if (String(key).startsWith('pomodoro')) { usePomodoroStore.getState().syncPreferences() } - }, [prefs]) + }, [baseTogglePref]) return (
{/* Startup Settings */} -
-
-
Open last page on startup
-
Return to exactly where you left off.
-
-
-
-
-
+ togglePref('openLastPage')} + /> {/* ADHD Features */} -
-
-
Show writing streak milestones
-
Celebrate at 7, 30, 100, and 365 days. Off by default to avoid anxiety.
-
- -
+ togglePref('streakDisplayOptIn')} + testId="streak-toggle" + />
{/* Focus Timer (Pomodoro) */} @@ -105,7 +91,7 @@ export function GeneralSettingsTab() { min={1} max={120} prefs={prefs} - onChanged={setPrefs} + onChanged={() => {}} /> {}} /> {}} /> {}} />
diff --git a/src/renderer/src/components/Settings/SettingsToggle.tsx b/src/renderer/src/components/Settings/SettingsToggle.tsx new file mode 100644 index 00000000..3b1f1928 --- /dev/null +++ b/src/renderer/src/components/Settings/SettingsToggle.tsx @@ -0,0 +1,32 @@ +interface SettingsToggleProps { + label: string + description: string + checked: boolean + onChange: () => void + testId?: string +} + +export function SettingsToggle({ label, description, checked, onChange, testId }: SettingsToggleProps) { + return ( +
+
+
{label}
+
{description}
+
+ +
+ ) +} diff --git a/src/renderer/src/components/SettingsModal.tsx b/src/renderer/src/components/SettingsModal.tsx index a739f9db..6bf1bd61 100644 --- a/src/renderer/src/components/SettingsModal.tsx +++ b/src/renderer/src/components/SettingsModal.tsx @@ -29,7 +29,6 @@ import { } from 'lucide-react' // Daily note templates handled by GeneralSettingsTab import { - loadPreferences, updatePreferences, TabBarStyle, BorderStyle, @@ -57,6 +56,7 @@ import { } from '../lib/themes' import { api } from '../lib/api' import { useAppViewStore } from '../store/useAppViewStore' +import { usePreferences } from '../hooks/usePreferences' import { GeneralSettingsTab } from './Settings/GeneralSettingsTab' import { EditorSettingsTab } from './Settings/EditorSettingsTab' @@ -94,6 +94,7 @@ export function SettingsModal({ onThemeShortcutsChange, }: SettingsModalProps) { const sidebarWidth = useAppViewStore((s) => s.sidebarWidth) + const { prefs: cachedPrefs, updatePref } = usePreferences() const [activeTab, setActiveTab] = useState('general') const [showCustomCreator, setShowCustomCreator] = useState(false) const [customThemeName, setCustomThemeName] = useState('') @@ -127,39 +128,30 @@ export function SettingsModal({ // Daily note templates handled by GeneralSettingsTab // UI Style preferences state (for reactivity) - const [uiStyles, setUiStyles] = useState(() => { - const prefs = loadPreferences() - return { - tabBarStyle: prefs.tabBarStyle, - borderStyle: prefs.borderStyle, - activeTabStyle: prefs.activeTabStyle - } - }) + const [uiStyles, setUiStyles] = useState(() => ({ + tabBarStyle: cachedPrefs.tabBarStyle, + borderStyle: cachedPrefs.borderStyle, + activeTabStyle: cachedPrefs.activeTabStyle + })) // Update UI style and save to preferences const updateUiStyle = (key: keyof typeof uiStyles, value: TabBarStyle | BorderStyle | ActiveTabStyle) => { setUiStyles(prev => ({ ...prev, [key]: value })) - updatePreferences({ [key]: value }) + updatePref(key, value) } // Right Sidebar preferences state (v1.8) - const [sidebarSettings, setSidebarSettings] = useState(() => { - const prefs = loadPreferences() - return { - tabSize: prefs.sidebarTabSize, - tabOrder: prefs.sidebarTabOrder, - hiddenTabs: prefs.sidebarHiddenTabs - } - }) + const [sidebarSettings, setSidebarSettings] = useState(() => ({ + tabSize: cachedPrefs.sidebarTabSize, + tabOrder: cachedPrefs.sidebarTabOrder, + hiddenTabs: cachedPrefs.sidebarHiddenTabs + })) // Icon Bar preferences state (v1.16) - const [iconBarSettings, setIconBarSettings] = useState(() => { - const prefs = loadPreferences() - return { - iconGlowEffect: prefs.iconGlowEffect ?? true, - iconGlowIntensity: prefs.iconGlowIntensity ?? 'subtle' - } - }) + const [iconBarSettings, setIconBarSettings] = useState(() => ({ + iconGlowEffect: cachedPrefs.iconGlowEffect ?? true, + iconGlowIntensity: cachedPrefs.iconGlowIntensity ?? 'subtle' + })) // Update icon bar setting and save to preferences const updateIconBarSetting = ( @@ -1202,22 +1194,21 @@ export function SettingsModal({