From e47f1458cc30851b5745eebe37ac83d9b01719c7 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 23 Feb 2026 12:54:11 -0700 Subject: [PATCH 01/17] docs: post-release sync for v1.19.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PomodoroTimer.tsx and usePomodoroStore.ts to CLAUDE.md project structure - Update feature tiers labels ("Build Now" → "Core (Shipped)") - Fix .STATUS spec targets (Pomodoro → Released, Settings → v1.20.0) - Update TESTS_SUMMARY.md generated date - Remove stale "Install Stable: v1.14.0" line from CLAUDE.md Co-Authored-By: Claude Opus 4.6 --- .STATUS | 6 +++--- docs/reference/CLAUDE.md | 17 ++++++++++------- docs/reference/TESTS_SUMMARY.md | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.STATUS b/.STATUS index 2ccb8b0f..6f286bcd 100644 --- a/.STATUS +++ b/.STATUS @@ -49,7 +49,7 @@ tags: ## Recently Completed -### Pomodoro Focus Timer (2026-02-23) — PR #45 merged to dev +### 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)` @@ -127,8 +127,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/docs/reference/CLAUDE.md b/docs/reference/CLAUDE.md index 3585ad3f..e58d2f0e 100644 --- a/docs/reference/CLAUDE.md +++ b/docs/reference/CLAUDE.md @@ -167,12 +167,13 @@ scribe/ │ └── renderer/src/ # React frontend │ ├── components/ │ │ ├── MissionControl/ # Mission Control HUD sidebar -│ │ ├── Settings/ # Modular settings components [NEW] +│ │ ├── Settings/ # Modular settings components │ │ │ ├── GeneralSettingsTab.tsx │ │ │ ├── EditorSettingsTab.tsx │ │ │ └── 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 │ │ └── ... │ ├── lib/ # Core utilities @@ -182,6 +183,8 @@ scribe/ │ │ ├── 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 ``` @@ -244,7 +247,6 @@ scribe help --all # Full reference **Released:** v1.19.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) ### Latest Work: Pomodoro Focus Timer (PR #45) @@ -549,17 +551,18 @@ toggleIcon: (type, id) => { ## ✅ 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 diff --git a/docs/reference/TESTS_SUMMARY.md b/docs/reference/TESTS_SUMMARY.md index c187b592..713f812c 100644 --- a/docs/reference/TESTS_SUMMARY.md +++ b/docs/reference/TESTS_SUMMARY.md @@ -1,6 +1,6 @@ # Test Coverage Summary - Scribe Editor -**Generated:** 2026-01-24 +**Generated:** 2026-02-23 **Total Tests:** 2,255 passing (73 test files) **Test Framework:** Vitest + Testing Library + happy-dom **TypeScript:** 0 production errors, 67 test file warnings (documented) From 2025831c3d439f8a3fce0466b8112d9f74193930 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 23 Feb 2026 13:05:44 -0700 Subject: [PATCH 02/17] =?UTF-8?q?docs:=20optimize=20CLAUDE.md=20(-36%,=207?= =?UTF-8?q?44=E2=86=92475=20lines)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Condense Icon-Centric Sidebar from 210 lines to 3-line summary - Condense Previous Releases to compact table + CHANGELOG link - Condense Tech Debt section to single paragraph - Remove redundant browser-mode file list and AI code sample - Keep actionable patterns (Tauri serialization, tab pattern) Co-Authored-By: Claude Opus 4.6 --- docs/reference/CLAUDE.md | 301 ++------------------------------------- 1 file changed, 15 insertions(+), 286 deletions(-) diff --git a/docs/reference/CLAUDE.md b/docs/reference/CLAUDE.md index e58d2f0e..52b8ed89 100644 --- a/docs/reference/CLAUDE.md +++ b/docs/reference/CLAUDE.md @@ -273,279 +273,30 @@ 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) - -**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 +### Previous: Tech Debt + Quarto Stabilization (v1.16.2) ---- - -### 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):** +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). -```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); - } -} - -/* 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; - } -} -``` +### Previous: Icon-Centric Sidebar (v1.16.0) -**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. --- @@ -626,29 +377,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) From 06bde7e3a2629f22cde1403b2692cb8e86b1a40c Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 24 Feb 2026 10:43:21 -0700 Subject: [PATCH 03/17] docs: add orchestration plan for session timer removal Brainstorm + implementation plan to remove the legacy session timer and consolidate time-tracking into the Pomodoro timer. Co-Authored-By: Claude Opus 4.6 --- ...NSTORM-session-timer-removal-2026-02-24.md | 86 ++++++++++++ ORCHESTRATE-remove-session-timer.md | 126 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 BRAINSTORM-session-timer-removal-2026-02-24.md create mode 100644 ORCHESTRATE-remove-session-timer.md diff --git a/BRAINSTORM-session-timer-removal-2026-02-24.md b/BRAINSTORM-session-timer-removal-2026-02-24.md new file mode 100644 index 00000000..d0154f08 --- /dev/null +++ b/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/ORCHESTRATE-remove-session-timer.md b/ORCHESTRATE-remove-session-timer.md new file mode 100644 index 00000000..cea22f49 --- /dev/null +++ b/ORCHESTRATE-remove-session-timer.md @@ -0,0 +1,126 @@ +# Remove Session Timer Orchestration Plan + +> **Branch:** `feature/remove-session-timer` +> **Base:** `dev` +> **Worktree:** `~/.git-worktrees/scribe/feature-remove-session-timer` +> **Brainstorm:** `BRAINSTORM-session-timer-removal-2026-02-24.md` + +## Objective + +Remove the legacy session timer (top-right breadcrumb area) and consolidate all time-tracking into the Pomodoro timer (status bar). The session timer is confusing (two timers), useless (raw elapsed time), and buggy (localStorage persists forever, showing values like `2296:20`). + +## Architecture Overview + +**Two timers exist today:** + +| Timer | Location | State | Purpose | +|-------|----------|-------|---------| +| Session timer | Top-right breadcrumb bar | `useState` in App.tsx + localStorage | Total elapsed time since app open | +| Pomodoro timer | Status bar | Zustand (`usePomodoroStore`) | Work/break cycle countdown | + +**After this change:** Only the Pomodoro timer remains. StatsPanel gets a Pomodoro count card. + +## Phase Overview (Bottom-Up Approach) + +| Phase | Task | Files | Priority | +|-------|------|-------|----------| +| 1 | Strip `sessionStartTime` from leaf components | WritingProgress.tsx, StatsPanel.tsx | High | +| 2 | Strip prop threading from intermediate components | EditorOrchestrator.tsx, HybridEditor.tsx | High | +| 3 | Remove session timer state + UI from App.tsx | App.tsx | High | +| 4 | Add Pomodoro count to StatsPanel | StatsPanel.tsx | Medium | +| 5 | Update all affected tests | *.test.tsx | High | +| 6 | Clean up localStorage keys | App.tsx (removal already covers this) | Low | + +## Phase 1: Strip Leaf Components + +### WritingProgress.tsx +- Remove `sessionStartTime` from `WritingProgressProps` interface +- Remove `sessionDuration` state and `useEffect` interval +- Remove Clock icon import (if no longer used) +- Remove session timer JSX block (lines 139-144) +- Keep: word delta, progress bar, streak, milestone celebrations + +### StatsPanel.tsx +- Remove `sessionStartTime` from `StatsPanelProps` interface +- Remove `sessionDuration` memo +- Remove the "Duration" card from the Session section grid +- Change grid from `grid-cols-2` to single column for Words card only (or remove Session section header and just show Words) + +## Phase 2: Strip Intermediate Components + +### EditorOrchestrator.tsx +- Remove `sessionStartTime` from props interface (line 29) +- Remove prop pass-through to HybridEditor and WritingProgress + +### HybridEditor.tsx +- Remove `sessionStartTime` from props interface (line 32) +- Remove prop destructuring (line 62) +- Remove prop pass-through to WritingProgress (line 448) + +## Phase 3: Remove from App.tsx + +### State to delete (~80 lines): +- `sessionStart` useState + localStorage effect (lines 163-179) +- `sessionDuration` useState (line 167) +- `timerPaused` useState + localStorage effect (lines 168-184) +- `pausedDuration` useState + localStorage effect (lines 171-189) +- Timer interval useEffect (lines 191-197) +- `formatSessionTime()` function (lines 199-203) +- `toggleTimerPause()` function (lines 205-220) +- `resetTimer()` function (lines 221-228) + +### UI to delete: +- `.focus-timer` div in breadcrumb bar (lines 1290-1309) — the ⏸/▶ + ↺ controls + +### Props to remove: +- `sessionStartTime` from EditorOrchestrator calls +- `sessionStartTime` from HybridEditor calls +- Any other pass-throughs + +### localStorage keys removed: +- `sessionStart` +- `timerPaused` +- `pausedDuration` +- `pauseStart` + +## Phase 4: Add Pomodoro Count to StatsPanel + +- Import `usePomodoroStore` in StatsPanel +- Subscribe to `completedToday` +- Replace the removed Duration card with a Pomodoro card showing count + tomato icon +- Format: "3 completed" with 🍅 icon + +## Phase 5: Update Tests + +### StatsPanel.test.tsx +- Remove `sessionStartTime` from `defaultProps` +- Remove tests that assert session duration display +- Add test for Pomodoro count display (mock `usePomodoroStore`) + +### EditorOrchestrator.test.tsx +- Remove `sessionStartTime` from mock props (line 40) + +### WritingProgress tests (if they exist) +- Remove `sessionStartTime` from test props +- Remove session timer display assertions + +## Acceptance Criteria + +- [ ] No session timer visible in breadcrumb bar +- [ ] Pomodoro timer in status bar still works (start/pause/reset) +- [ ] StatsPanel shows Pomodoro count instead of session duration +- [ ] WritingProgress shows word delta + progress bar (no session time) +- [ ] No `sessionStartTime` prop in any component interface +- [ ] No `sessionStart`/`timerPaused`/`pausedDuration`/`pauseStart` in localStorage +- [ ] All tests pass (`npm test`) +- [ ] App renders correctly in browser mode (preview) +- [ ] ~95 net lines removed + +## How to Start + +```bash +cd ~/.git-worktrees/scribe/feature-remove-session-timer +claude +``` + +Start with Phase 1 (leaf components), commit after each phase. From b6fae80995319dbb148ce5eea34ef741c85cb68d Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 24 Feb 2026 11:13:57 -0700 Subject: [PATCH 04/17] refactor: remove session timer, consolidate to Pomodoro Remove the legacy session timer (top-right breadcrumb area) that showed raw elapsed time with pause/reset controls. This eliminates user confusion from having two overlapping timers and fixes the localStorage bug that caused absurd values like "2296:20" across restarts. Changes: - Remove ~80 lines of useState/useEffect/localStorage from App.tsx - Remove sessionStartTime prop chain through 4 components - Replace StatsPanel Duration card with Pomodoro count from store - Strip session timer display from WritingProgress - Update all affected tests with usePomodoroStore mock The Pomodoro timer in the status bar remains as the sole time-tracking mechanism, providing meaningful focus tracking via work/break cycles. Co-Authored-By: Claude Opus 4.6 --- src/renderer/src/App.tsx | 102 +----------------- .../src/__tests__/EditorOrchestrator.test.tsx | 1 - .../src/__tests__/StatsPanel.test.tsx | 32 +++--- .../src/components/EditorOrchestrator.tsx | 4 - src/renderer/src/components/HybridEditor.tsx | 3 - src/renderer/src/components/StatsPanel.tsx | 22 ++-- .../src/components/WritingProgress.tsx | 41 +------ 7 files changed, 25 insertions(+), 180 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fc299115..8004e512 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -159,74 +159,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') @@ -292,9 +224,6 @@ 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) @@ -536,11 +465,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 || {} @@ -1043,7 +967,7 @@ function App() { wordCount={wordCount} sessionStartWords={sessionStartWords} streakInfo={streakInfo} - sessionStartTime={sessionStartTime} + preferences={preferences} onToggleTerminal={() => { if (rightActiveTab === 'terminal' && !rightSidebarCollapsed) { @@ -1287,26 +1211,6 @@ function App() { )} - {/* Focus timer with controls */} -
- - - {formatSessionTime(sessionDuration)} - - -
{/* Editor tabs bar */} @@ -1353,7 +1257,7 @@ function App() { wordCount={wordCount} sessionStartWords={sessionStartWords} streakInfo={streakInfo} - sessionStartTime={sessionStartTime} + preferences={preferences} onToggleTerminal={() => { if (rightActiveTab === 'terminal' && !rightSidebarCollapsed) { @@ -1536,7 +1440,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__/StatsPanel.test.tsx b/src/renderer/src/__tests__/StatsPanel.test.tsx index 1650aa0a..f19b6a49 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: 3 }), +})) + // 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('3') }) 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/components/EditorOrchestrator.tsx b/src/renderer/src/components/EditorOrchestrator.tsx index 4e5cc745..4a89d096 100644 --- a/src/renderer/src/components/EditorOrchestrator.tsx +++ b/src/renderer/src/components/EditorOrchestrator.tsx @@ -26,7 +26,6 @@ interface EditorOrchestratorProps { wordCount: number sessionStartWords: Record streakInfo: { streak: number; isActiveToday: boolean } - sessionStartTime: number | null preferences: UserPreferences // Terminal toggle @@ -80,7 +79,6 @@ export function EditorOrchestrator({ wordCount, sessionStartWords, streakInfo, - sessionStartTime, preferences, onToggleTerminal, focusMode, @@ -125,7 +123,6 @@ export function EditorOrchestrator({ wordGoal={selectedNote.properties?.word_goal ? Number(selectedNote.properties.word_goal.value) : preferences.defaultWordGoal} sessionStartWords={sessionStartWords[selectedNote.id] || wordCount} streak={streakInfo.streak} - sessionStartTime={sessionStartTime || undefined} onToggleTerminal={onToggleTerminal} pomodoroEnabled={pomodoroEnabled} onPomodoroComplete={onPomodoroComplete} @@ -180,7 +177,6 @@ export function EditorOrchestrator({ wordGoal={selectedNote.properties?.word_goal ? Number(selectedNote.properties.word_goal.value) : preferences.defaultWordGoal} sessionStartWords={sessionStartWords[selectedNote.id] || wordCount} streak={streakInfo.streak} - sessionStartTime={sessionStartTime || undefined} onToggleTerminal={onToggleTerminal} pomodoroEnabled={pomodoroEnabled} onPomodoroComplete={onPomodoroComplete} diff --git a/src/renderer/src/components/HybridEditor.tsx b/src/renderer/src/components/HybridEditor.tsx index 21590a95..5303923d 100644 --- a/src/renderer/src/components/HybridEditor.tsx +++ b/src/renderer/src/components/HybridEditor.tsx @@ -29,7 +29,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 +58,6 @@ export function HybridEditor({ wordGoal = 500, sessionStartWords = 0, streak = 0, - sessionStartTime, onToggleTerminal, pomodoroEnabled = true, onPomodoroComplete, @@ -445,7 +443,6 @@ export function HybridEditor({ wordGoal={wordGoal} sessionStartWords={sessionStartWords} streak={streak} - sessionStartTime={sessionStartTime} /> {/* Pomodoro timer */} diff --git a/src/renderer/src/components/StatsPanel.tsx b/src/renderer/src/components/StatsPanel.tsx index 2904f4e7..9cffff29 100644 --- a/src/renderer/src/components/StatsPanel.tsx +++ b/src/renderer/src/components/StatsPanel.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { Project, Note } from '../types' -import { TrendingUp, Clock, FileText, Folder, ChevronRight } from 'lucide-react' +import { TrendingUp, FileText, Folder, ChevronRight } from 'lucide-react' +import { usePomodoroStore } from '../store/usePomodoroStore' interface StatsPanelProps { projects: Project[] @@ -8,7 +9,6 @@ interface StatsPanelProps { currentProjectId: string | null wordCount: number wordGoal?: number - sessionStartTime?: number onSelectProject: (projectId: string) => void onSelectNote: (noteId: string) => void } @@ -19,7 +19,6 @@ export function StatsPanel({ currentProjectId, wordCount, wordGoal = 500, - sessionStartTime, onSelectProject, onSelectNote }: StatsPanelProps) { @@ -46,16 +45,7 @@ export function StatsPanel({ return notes.filter(n => n.project_id === currentProjectId && !n.deleted_at) }, [notes, currentProjectId]) - // Session duration - const sessionDuration = useMemo(() => { - if (!sessionStartTime) return null - const minutes = Math.floor((Date.now() - sessionStartTime) / 60000) - if (minutes < 1) return 'Just started' - if (minutes < 60) return `${minutes}m` - const hours = Math.floor(minutes / 60) - const mins = minutes % 60 - return `${hours}h ${mins}m` - }, [sessionStartTime]) + const completedPomodoros = usePomodoroStore(s => s.completedToday) // Word goal progress const goalProgress = Math.min(100, Math.round((wordCount / wordGoal) * 100)) @@ -71,11 +61,11 @@ export function StatsPanel({
- - Duration + 🍅 + Pomodoros
- {sessionDuration || '--'} + {completedPomodoros}
diff --git a/src/renderer/src/components/WritingProgress.tsx b/src/renderer/src/components/WritingProgress.tsx index 5a285134..91a083b0 100644 --- a/src/renderer/src/components/WritingProgress.tsx +++ b/src/renderer/src/components/WritingProgress.tsx @@ -1,5 +1,5 @@ import { useMemo, useState, useEffect, useRef } from 'react' -import { Target, TrendingUp, Flame, Clock, Sparkles } from 'lucide-react' +import { Target, TrendingUp, Flame, Sparkles } from 'lucide-react' interface WritingProgressProps { wordCount: number @@ -7,7 +7,6 @@ interface WritingProgressProps { sessionStartWords?: number streak?: number showDetails?: boolean - sessionStartTime?: number // Timestamp when session started } // Word count milestones to celebrate @@ -19,7 +18,6 @@ const MILESTONES = [100, 250, 500, 750, 1000, 1500, 2000] * Features: * - Word goal progress bar with percentage * - Session word count (words written this session) - * - Session timer (time since first keystroke) * - Writing streak indicator with glow * - Milestone celebrations at specific word counts * - Respects prefers-reduced-motion @@ -29,8 +27,7 @@ export function WritingProgress({ wordGoal = 500, sessionStartWords = 0, streak = 0, - showDetails = false, - sessionStartTime + showDetails = false }: WritingProgressProps) { // Track last word count to detect milestones const lastWordCountRef = useRef(wordCount) @@ -54,32 +51,6 @@ export function WritingProgress({ return Math.max(0, wordCount - sessionStartWords) }, [wordCount, sessionStartWords]) - // Session timer - const [sessionDuration, setSessionDuration] = useState('0m') - - useEffect(() => { - if (!sessionStartTime) { - setSessionDuration('0m') - return - } - - const updateTimer = () => { - const elapsed = Date.now() - sessionStartTime - const minutes = Math.floor(elapsed / 60000) - const hours = Math.floor(minutes / 60) - - if (hours > 0) { - setSessionDuration(`${hours}h ${minutes % 60}m`) - } else { - setSessionDuration(`${minutes}m`) - } - } - - updateTimer() - const interval = setInterval(updateTimer, 60000) // Update every minute - return () => clearInterval(interval) - }, [sessionStartTime]) - // Detect milestone crossings and trigger celebrations useEffect(() => { const prev = lastWordCountRef.current @@ -135,14 +106,6 @@ export function WritingProgress({ return (
- {/* Session timer */} - {sessionStartTime && sessionDuration !== '0m' && ( -
- - {sessionDuration} -
- )} - {/* Word delta this session */} {sessionWords > 0 && (
Date: Tue, 24 Feb 2026 11:23:22 -0700 Subject: [PATCH 05/17] chore: reduce default editor font size from 18px to 15px The 18px default was too large for academic writing workflows. 15px provides better information density while remaining readable. Co-Authored-By: Claude Opus 4.6 --- src/renderer/src/index.css | 2 +- src/renderer/src/lib/themes.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index 2920b4f2..71738e8e 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -41,7 +41,7 @@ /* Editor typography defaults (overridden by JS) */ --editor-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --editor-font-size: 18px; + --editor-font-size: 15px; --editor-line-height: 1.8; } diff --git a/src/renderer/src/lib/themes.ts b/src/renderer/src/lib/themes.ts index 2fc854fa..5cb75a48 100644 --- a/src/renderer/src/lib/themes.ts +++ b/src/renderer/src/lib/themes.ts @@ -891,7 +891,7 @@ export interface FontSettings { export const DEFAULT_FONT_SETTINGS: FontSettings = { family: 'system', - size: 18, + size: 15, lineHeight: 1.8, } From b0bc5264ae247770134bce85aa110c21c21b5b46 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 24 Feb 2026 11:23:29 -0700 Subject: [PATCH 06/17] docs: add brainstorm and spec for Quarto code chunk styling Max brainstorm with frontend architect + UX designer agents. Spec covers ViewPlugin architecture, derived CSS variables, language badge widget, and code font settings. Co-Authored-By: Claude Opus 4.6 --- BRAINSTORM-quarto-code-chunks-2026-02-24.md | 184 ++++++ docs/specs/SPEC-code-chunk-ux.md | 541 ++++++++++++++++++ .../SPEC-quarto-code-chunks-2026-02-24.md | 246 ++++++++ 3 files changed, 971 insertions(+) create mode 100644 BRAINSTORM-quarto-code-chunks-2026-02-24.md create mode 100644 docs/specs/SPEC-code-chunk-ux.md create mode 100644 docs/specs/SPEC-quarto-code-chunks-2026-02-24.md 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/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..cee5c24b --- /dev/null +++ b/docs/specs/SPEC-quarto-code-chunks-2026-02-24.md @@ -0,0 +1,246 @@ +# SPEC: VS Code-Style Quarto Code Chunks + +> **Status:** draft +> **Created:** 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]`, `[PY]`) appears on the opening fence line +- [ ] Chunk option lines (`#|`) have distinct but subtle styling +- [ ] A "Code Font" preference exists in Settings > Editor +- [ ] No run buttons, toolbars, or other IDE chrome is added +- [ ] 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: JetBrains Mono at 88% of editor font size. + +4. **No execution features** — Scribe is a writing tool. Run buttons, output cells, and execution status belong in VS Code. + +## 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: 'jetbrains-mono', // NEW + codeSize: 0.88, // NEW +} +``` + +### 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 | +| `cm-quarto-lang-badge` | Language badge widget | Accent-tinted pill, 0.7em | + +## 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 is uppercase for screen reader clarity +- [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: [JetBrains Mono v] <- filtered to mono fonts only +│ └── Size Ratio: [0.88] ========●== (0.75 - 1.00) +``` + +## Open Questions + +1. **`color-mix()` vs JS computation?** — `color-mix(in srgb, ...)` is cleaner CSS but requires Safari 16.2+ / Chromium 111+. Tauri's WebKit should support it, but the JS `rgba()` approach in `applyTheme()` is guaranteed to work everywhere. + +2. **Language badge cursor-reveal?** — Should the badge hide when cursor is on the fence line (like heading mark hiding), or always show? + +3. **Chunk option collapsing?** — Should `#|` lines fold when cursor isn't on them? This adds complexity and could be a follow-up feature. + +4. **Inline executable code?** — VS Code also styles `` `{r} expr` `` inline code. Should Scribe? + +## Review Checklist + +- [ ] Spec reviewed by user +- [ ] Architecture aligns with existing patterns (callout decorations, theme system) +- [ ] No new dependencies introduced +- [ ] 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. + +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. + +### 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 | + +## History + +| Date | Change | +|------|--------| +| 2026-02-24 | Initial spec from max brainstorm with 2 agents (frontend architect + UX designer) | From ee8eab5fb71bde7169d7bd64e1f6592f5fd6400f Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 24 Feb 2026 11:24:01 -0700 Subject: [PATCH 07/17] docs: update docs and remove orphaned CSS after session timer removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 48 lines of orphaned .focus-timer CSS styles - Update docs/index.md: "Session timer" → "Pomodoro timer" - Update PROJECT-DEFINITION.md: session timer → Pomodoro timer - Add [Unreleased] CHANGELOG entry for session timer removal Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 16 ++++++++++ docs/index.md | 2 +- docs/reference/PROJECT-DEFINITION.md | 4 +-- src/renderer/src/index.css | 48 ---------------------------- 4 files changed, 19 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad847bef..cf0a966f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [Unreleased] + +### Removed + +- **Session timer** — Removed the legacy session timer from the breadcrumb bar (⏸/▶/↺ controls). It showed raw elapsed time that 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. + +### 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. + +--- + ## [v1.19.0] - 2026-02-23 — Pomodoro Focus Timer ### Added diff --git a/docs/index.md b/docs/index.md index 0961d9ca..0dc153dd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. 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/src/renderer/src/index.css b/src/renderer/src/index.css index 2920b4f2..b3743b07 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -453,54 +453,6 @@ body { text-decoration-color: var(--nexus-text-muted, #64748b); } -/* Focus timer */ -.focus-timer { - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - font-family: 'SF Mono', 'Fira Code', monospace; - color: var(--nexus-text-secondary, #cbd5e1); -} - -.timer-value { - min-width: 40px; - text-align: center; - font-weight: 500; - cursor: default; /* Don't inherit grab cursor from parent */ -} - -.timer-value.paused { - opacity: 0.6; - color: var(--nexus-text-muted, #94a3b8); -} - -.timer-btn { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - background: transparent; - border: none; - border-radius: 4px; - color: var(--nexus-text-secondary, #cbd5e1); - cursor: pointer; - font-size: 10px; - transition: all 150ms ease; - opacity: 0.6; -} - -.timer-btn:hover { - background: rgba(128, 128, 128, 0.15); - color: var(--nexus-text-primary, #e2e8f0); - opacity: 1; -} - -.timer-btn.reset { - font-size: 14px; -} .sidebar-tab { display: flex; From e843b129b0882b8db00fcc09b3b9416471694083 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 24 Feb 2026 11:36:23 -0700 Subject: [PATCH 08/17] docs(spec): approve Quarto code chunk spec after review Resolved 8 open questions: - JS rgba() over color-mix() for compatibility - Badge always visible (no cursor-reveal) - No chunk option folding (style only) - No inline executable code styling - Match existing theme reactivity pattern - Hardcoded 0.82 ratio for option font size - Full lowercase badge text (r, python, julia) - 8px gap between adjacent chunks - Default code font: Fira Code (changed from JetBrains Mono) Added edge cases: empty chunks, test impact, badge color, getThemeColors inconsistency, adjacent chunk separation. Co-Authored-By: Claude Opus 4.6 --- .../SPEC-quarto-code-chunks-2026-02-24.md | 97 ++++++++++++++----- 1 file changed, 74 insertions(+), 23 deletions(-) diff --git a/docs/specs/SPEC-quarto-code-chunks-2026-02-24.md b/docs/specs/SPEC-quarto-code-chunks-2026-02-24.md index cee5c24b..0f177504 100644 --- a/docs/specs/SPEC-quarto-code-chunks-2026-02-24.md +++ b/docs/specs/SPEC-quarto-code-chunks-2026-02-24.md @@ -1,7 +1,8 @@ # SPEC: VS Code-Style Quarto Code Chunks -> **Status:** draft +> **Status:** approved > **Created:** 2026-02-24 +> **Reviewed:** 2026-02-24 > **From Brainstorm:** `BRAINSTORM-quarto-code-chunks-2026-02-24.md` ## Overview @@ -19,10 +20,11 @@ Add distinct visual treatment to Quarto code chunks in Scribe's editor, inspired - [ ] 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]`, `[PY]`) appears on the opening fence line -- [ ] Chunk option lines (`#|`) have distinct but subtle styling +- [ ] 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 @@ -68,10 +70,23 @@ index.css (fallback only for plain ```lang 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: JetBrains Mono at 88% of editor font size. +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. @@ -93,11 +108,24 @@ export const DEFAULT_FONT_SETTINGS: FontSettings = { family: 'system', size: 15, lineHeight: 1.8, - codeFamily: 'jetbrains-mono', // NEW + 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) | @@ -115,8 +143,8 @@ export const DEFAULT_FONT_SETTINGS: FontSettings = { | `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 | -| `cm-quarto-lang-badge` | Language badge widget | Accent-tinted pill, 0.7em | +| `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 @@ -146,7 +174,7 @@ AFTER: │ This is prose text in the document. │ │ │ │ ┌╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶┐ │ -│ ┃ ```{r} [R] ┃ │ <- tinted background +│ ┃ ```{r} r ┃ │ <- tinted background │ ┃ library(ggplot2) ┃ │ monospace font │ ┃ ggplot(mtcars, aes(x=mpg)) + ┃ │ language badge │ ┃ geom_histogram() ┃ │ 3px accent border @@ -179,7 +207,7 @@ AFTER: - [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 is uppercase for screen reader clarity +- [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 @@ -191,26 +219,20 @@ Editor Settings ├── Line Height: [1.8] ├── ───────────────────────────────── ├── Code Font -│ ├── Font Family: [JetBrains Mono v] <- filtered to mono fonts only +│ ├── Font Family: [Fira Code v] <- filtered to mono fonts only │ └── Size Ratio: [0.88] ========●== (0.75 - 1.00) ``` ## Open Questions -1. **`color-mix()` vs JS computation?** — `color-mix(in srgb, ...)` is cleaner CSS but requires Safari 16.2+ / Chromium 111+. Tauri's WebKit should support it, but the JS `rgba()` approach in `applyTheme()` is guaranteed to work everywhere. - -2. **Language badge cursor-reveal?** — Should the badge hide when cursor is on the fence line (like heading mark hiding), or always show? - -3. **Chunk option collapsing?** — Should `#|` lines fold when cursor isn't on them? This adds complexity and could be a follow-up feature. - -4. **Inline executable code?** — VS Code also styles `` `{r} expr` `` inline code. Should Scribe? +~~All resolved during review (2026-02-24).~~ ## Review Checklist -- [ ] Spec reviewed by user -- [ ] Architecture aligns with existing patterns (callout decorations, theme system) -- [ ] No new dependencies introduced -- [ ] Backward compatible (existing preferences unaffected) +- [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 @@ -222,11 +244,25 @@ Editor Settings 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. +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. +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 @@ -239,8 +275,23 @@ Editor Settings | 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 | From 6dc3a4a5ec9d5420778f7b371c1a6722b263a14b Mon Sep 17 00:00:00 2001 From: Davood Tofighi Date: Tue, 24 Feb 2026 12:11:20 -0700 Subject: [PATCH 09/17] =?UTF-8?q?feat:=20settings=20improvements=20?= =?UTF-8?q?=E2=80=94=20components,=20shortcuts=20registry,=20preferences?= =?UTF-8?q?=20hook=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add ORCHESTRATE and SPEC for settings improvements Planning files for feature/settings-improvements: - SettingsToggle extraction, shortcut registry, usePreferences hook - Pre-commit ORCHESTRATE guard, settings tab audit - ~3.5h total across 5 phases Co-Authored-By: Claude Opus 4.6 * refactor: extract settings components, add shortcut registry, harden preferences - Extract reusable SettingsToggle component from duplicated toggle markup - Create SHORTCUTS registry constant replacing hardcoded labels across 13 files - Add usePreferences() hook with event-based cross-component sync - Migrate per-render loadPreferences() calls in SettingsModal, EditorTabs, MissionControl to cached hook - Add husky pre-commit guard blocking ORCHESTRATE files on dev/main - Fix 3x per-render loadPreferences() bug in GeneralSettingsTab streak toggle Co-Authored-By: Claude Opus 4.6 * feat: wire up toggles, add matchesShortcut, migrate App.tsx, update docs - Add openLastPage, readableLineLength, spellcheck preferences with persistence - Add matchesShortcut() helper for registry-based keyboard event matching - Refactor 12 hardcoded shortcut checks in KeyboardShortcutHandler to use registry - Migrate App.tsx from manual useState/loadPreferences to usePreferences hook - Update ARCHITECTURE.md with preferences system diagram and patterns - Update COMPONENTS.md, REFCARD-SETTINGS.md, shortcuts guide with new APIs Co-Authored-By: Claude Opus 4.6 * fix: complete keyboard shortcuts cheatsheet with missing entries Add quickCapture, newProject, recentNotes, dashboard, terminal, and Tabs category to the shortcuts panel so it matches the full SHORTCUTS registry. Co-Authored-By: Claude Opus 4.6 * fix: address PR #47 review — accessibility, consistency, cleanup - Add role="switch", aria-checked, aria-label to SettingsToggle (a11y) - Add accessibility tests for SettingsToggle (2 new tests) - Migrate SettingsModal fully to usePreferences hook (remove loadPreferences) - Document togglePref's intentional localStorage read pattern - Add key casing convention docs to SHORTCUTS registry - Remove unused toggleRightPanel/collapseAll from registry - Remove ORCHESTRATE file (merge cleanup) - Sync version to 1.19.0 (was stale at 1.18.0) Co-Authored-By: Claude Opus 4.6 * fix: update font size test to match dev's 18→15 change Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Test User Co-authored-by: Claude Opus 4.6 --- .husky/pre-commit | 11 ++ docs/ARCHITECTURE.md | 44 +++++ docs/COMPONENTS.md | 62 ++++++- docs/guide/shortcuts.md | 40 +++++ docs/reference/REFCARD-SETTINGS.md | 17 ++ .../SPEC-settings-improvements-2026-02-23.md | 168 ++++++++++++++++++ package-lock.json | 17 ++ package.json | 4 +- src/renderer/src/App.tsx | 59 +++--- .../src/__tests__/SettingsToggle.test.tsx | 114 ++++++++++++ src/renderer/src/__tests__/Themes.test.ts | 2 +- src/renderer/src/__tests__/shortcuts.test.ts | 98 ++++++++++ src/renderer/src/__tests__/testUtils.ts | 3 + .../src/__tests__/usePreferences.test.ts | 96 ++++++++++ .../src/components/CommandPalette.tsx | 11 +- .../src/components/DashboardHeader.tsx | 15 +- .../src/components/DashboardShell.tsx | 15 +- .../src/components/EditorTabs/EditorTabs.tsx | 9 +- .../components/EditorTabs/TabContextMenu.tsx | 3 +- src/renderer/src/components/EmptyState.tsx | 7 +- src/renderer/src/components/HybridEditor.tsx | 13 +- .../components/KeyboardShortcutHandler.tsx | 29 +-- .../src/components/KeyboardShortcuts.tsx | 46 +++-- .../src/components/MissionControl.tsx | 5 +- src/renderer/src/components/QuickActions.tsx | 9 +- src/renderer/src/components/SearchBar.tsx | 3 +- .../components/Settings/EditorSettingsTab.tsx | 35 ++-- .../Settings/GeneralSettingsTab.tsx | 62 +++---- .../components/Settings/SettingsToggle.tsx | 32 ++++ src/renderer/src/components/SettingsModal.tsx | 53 +++--- .../src/components/sidebar/ActivityBar.tsx | 9 +- .../src/components/sidebar/IconBar.tsx | 3 +- .../components/sidebar/ProjectContextMenu.tsx | 3 +- src/renderer/src/hooks/usePreferences.ts | 46 +++++ src/renderer/src/lib/preferences.ts | 6 + src/renderer/src/lib/shortcuts.ts | 94 ++++++++++ 36 files changed, 1041 insertions(+), 202 deletions(-) create mode 100755 .husky/pre-commit create mode 100644 docs/specs/SPEC-settings-improvements-2026-02-23.md create mode 100644 src/renderer/src/__tests__/SettingsToggle.test.tsx create mode 100644 src/renderer/src/__tests__/shortcuts.test.ts create mode 100644 src/renderer/src/__tests__/usePreferences.test.ts create mode 100644 src/renderer/src/components/Settings/SettingsToggle.tsx create mode 100644 src/renderer/src/hooks/usePreferences.ts create mode 100644 src/renderer/src/lib/shortcuts.ts 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/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..c16a64c9 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,65 @@ 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) +- 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 27 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/guide/shortcuts.md b/docs/guide/shortcuts.md index fe025dc1..7a8553bd 100644 --- a/docs/guide/shortcuts.md +++ b/docs/guide/shortcuts.md @@ -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 **27 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/reference/REFCARD-SETTINGS.md b/docs/reference/REFCARD-SETTINGS.md index f95f37f1..1fd9baf5 100644 --- a/docs/reference/REFCARD-SETTINGS.md +++ b/docs/reference/REFCARD-SETTINGS.md @@ -20,6 +20,7 @@ | Category | Icon | Contents | |----------|------|----------| +| **General** | ⚙️ | Open last page, readable line length, spellcheck | | **Editor** | 📝 | Font, spacing, ligatures, focus mode | | **Themes** | 🎨 | Visual theme gallery (8 themes) | | **AI & Workflow** | ⚡ | Quick Actions, chat, @ references | @@ -30,6 +31,22 @@ --- +## 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 | +| **Readable Line Length** | ON | Editor | Limit editor line width for comfortable reading | +| **Spellcheck** | OFF | Editor | Enable browser-native spellcheck in the editor | + +All toggles write-through to `localStorage` immediately (no Save button). +Changes propagate to other components via the `preferences-changed` event. + +--- + ## Fuzzy Search **Access:** Just start typing in the search box (auto-focused when Settings opens) 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..98945bf9 --- /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:** Draft +> **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..1a7e0629 100644 --- a/package.json +++ b/package.json @@ -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/renderer/src/App.tsx b/src/renderer/src/App.tsx index fc299115..655c51f7 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) @@ -255,14 +253,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) @@ -299,10 +294,7 @@ function App() { 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([]) @@ -742,26 +734,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(() => { 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__/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..165b3cd5 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' @@ -285,7 +286,7 @@ export function HybridEditor({
@@ -305,7 +306,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 +317,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 +437,7 @@ export function HybridEditor({ {mode === 'source' && 'Source'} {mode === 'live-preview' && 'Live Preview'} {mode === 'reading' && 'Reading'} - ⌘E + {SHORTCUTS.cycleMode.label} {/* Writing progress */} @@ -481,7 +482,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({