diff --git a/docs/plans/2026-04-06-writers-studio-implementation.md b/docs/plans/2026-04-06-writers-studio-implementation.md new file mode 100644 index 0000000..8e79a27 --- /dev/null +++ b/docs/plans/2026-04-06-writers-studio-implementation.md @@ -0,0 +1,1306 @@ +# Writer's Studio Feature Pack — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Zen Mode, Document Outline, and Writing Analytics to MarkdownConverter v4.2.0. + +**Architecture:** Three independent feature modules that integrate into the existing sidebar, modal, command palette, and CodeMirror 6 infrastructure. Each feature is self-contained in its own file with minimal modifications to existing code. + +**Tech Stack:** CodeMirror 6 (ViewPlugin, Decoration, scrollIntoView), existing SidebarManager/ModalManager APIs, pure JS math for analytics. + +--- + +## Task 1: Document Outline Panel + +**Files:** +- Create: `src/sidebar/outline-panel.js` +- Modify: `src/renderer.js` (lazy loader + panel registration + command palette + keyboard shortcut) +- Modify: `src/index.html` (sidebar icon button) + +### Step 1: Create outline panel module + +Create `src/sidebar/outline-panel.js`: + +```javascript +/** + * Document Outline Sidebar Panel + * Shows heading hierarchy parsed from active markdown document. + */ + +function renderOutlinePanel(container, { getEditorContent, onHeadingClick, getActiveLine }) { + container.innerHTML = ` +
+
+ +
+ `; + + let headings = []; + let activeHeadingIndex = -1; + let debounceTimer = null; + + function parseHeadings(content) { + const lines = content.split('\n'); + const result = []; + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^(#{1,6})\s+(.+)$/); + if (match) { + result.push({ + level: match[1].length, + text: match[2].replace(/[*_`~\[\]()#]/g, '').trim(), + line: i + 1 // 1-based line number + }); + } + } + return result; + } + + function renderOutline() { + const list = document.getElementById('outline-list'); + const footer = document.getElementById('outline-footer'); + if (!list) return; + + if (headings.length === 0) { + list.innerHTML = ` +
+

No headings found

+

Use # to create headings

+
+ `; + footer.textContent = ''; + return; + } + + list.innerHTML = headings.map((h, i) => ` +
+ ${h.text} + H${h.level} +
+ `).join(''); + + // Click handlers + list.querySelectorAll('.outline-item').forEach(item => { + item.addEventListener('click', () => { + const line = parseInt(item.dataset.line); + onHeadingClick(line); + }); + }); + + // Footer summary + const counts = {}; + headings.forEach(h => { counts[h.level] = (counts[h.level] || 0) + 1; }); + const summary = Object.entries(counts) + .sort((a, b) => a[0] - b[0]) + .map(([level, count]) => `${count} H${level}`) + .join(' \u2022 '); + footer.textContent = `${headings.length} headings \u2022 ${summary}`; + } + + function setActiveHeading(cursorLine) { + let newIndex = -1; + for (let i = headings.length - 1; i >= 0; i--) { + if (headings[i].line <= cursorLine) { + newIndex = i; + break; + } + } + if (newIndex !== activeHeadingIndex) { + activeHeadingIndex = newIndex; + renderOutline(); + } + } + + function refresh() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const content = getEditorContent(); + headings = parseHeadings(content); + if (typeof getActiveLine === 'function') { + setActiveHeading(getActiveLine()); + } + renderOutline(); + }, 300); + } + + // Public refresh for external callers + container._refreshOutline = refresh; + container._setActiveHeading = setActiveHeading; + + // Initial render + refresh(); + + return { refresh, setActiveHeading }; +} + +module.exports = { renderOutlinePanel }; +``` + +### Step 2: Add lazy loader in renderer.js + +**File:** `src/renderer.js` +**Location:** After line 22 (after `getRenderSnippetsPanel` declaration), add: + +```javascript +let _renderOutlinePanel; +function getRenderOutlinePanel() { if (!_renderOutlinePanel) _renderOutlinePanel = require('./sidebar/outline-panel').renderOutlinePanel; return _renderOutlinePanel; } +``` + +### Step 3: Add sidebar icon in index.html + +**File:** `src/index.html` +**Location:** After line 1439 (after the templates ``), before `` on line 1440, add: + +```html + +``` + +### Step 4: Register outline panel in renderer.js + +**File:** `src/renderer.js` +**Location:** After line 1468 (after templates panel registration), add: + +```javascript + // Outline panel — refreshes on editor content change + let outlinePanelContainer = null; + sidebarManager.registerPanel('outline', { + title: 'Outline', + render: (container) => { + outlinePanelContainer = container; + getRenderOutlinePanel()(container, { + getEditorContent: () => tabManager.getEditorContent(), + getActiveLine: () => { + const tab = tabManager.tabs.get(tabManager.activeTabId); + if (tab?.editorView) { + const pos = tab.editorView.state.selection.main.head; + return tab.editorView.state.doc.lineAt(pos).number; + } + return 1; + }, + onHeadingClick: (line) => { + const tab = tabManager.tabs.get(tabManager.activeTabId); + if (tab?.editorView) { + const pos = tab.editorView.state.doc.line(line).from; + tab.editorView.dispatch({ + selection: { anchor: pos }, + scrollIntoView: true + }); + tab.editorView.focus(); + } + } + }); + } + }); +``` + +### Step 5: Wire outline refresh to editor content changes + +**File:** `src/renderer.js` +**Location:** In the `onChange` callback inside `createNewTab()` and the initial editor setup. + +Find the `onChange` callback in `createNewTab` (around line 417) and add at the end of the callback: +```javascript +// Refresh outline panel if visible +if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline(); +``` + +Also add to the initial editor `onChange` (around line 1641): +```javascript +if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline(); +``` + +Similarly, in the `onUpdate` callbacks, add: +```javascript +if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number); +``` + +### Step 6: Add command palette entry + keyboard shortcut + +**File:** `src/renderer.js` +**Location:** After line 1603 (after templates command), add: + +```javascript + commandPalette.register('Toggle Sidebar: Outline', 'Ctrl+Shift+O', () => sidebarManager.togglePanel('outline')); +``` + +### Step 7: Add outline panel CSS + +**File:** `src/styles-sidebar.css` +**Location:** End of file, add: + +```css +/* Outline Panel */ +.outline-panel { + display: flex; + flex-direction: column; + height: 100%; +} + +.outline-list { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.outline-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 12px; + cursor: pointer; + font-size: 13px; + color: var(--text-secondary, #6b7280); + transition: background 0.15s, color 0.15s; + border-left: 2px solid transparent; +} + +.outline-item:hover { + background: var(--bg-tertiary, #f3f4f6); + color: var(--text-primary, #1f2937); +} + +.outline-item.active { + color: var(--accent-blue, #3b82f6); + background: rgba(59, 130, 246, 0.08); + border-left-color: var(--accent-blue, #3b82f6); + font-weight: 600; +} + +.outline-level-1 { padding-left: 12px; } +.outline-level-2 { padding-left: 24px; } +.outline-level-3 { padding-left: 36px; } +.outline-level-4 { padding-left: 48px; } +.outline-level-5 { padding-left: 56px; } +.outline-level-6 { padding-left: 64px; } + +.outline-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.outline-badge { + font-size: 10px; + opacity: 0.5; + margin-left: 8px; + flex-shrink: 0; +} + +.outline-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 16px; + color: var(--text-muted, #9ca3af); + text-align: center; +} + +.outline-empty p { margin: 4px 0; } + +.outline-hint { + font-family: monospace; + font-size: 12px; + opacity: 0.7; +} + +.outline-footer { + padding: 8px 12px; + border-top: 1px solid var(--border-color, #e5e7eb); + font-size: 11px; + color: var(--text-muted, #9ca3af); +} +``` + +### Step 8: Commit + +```bash +git add src/sidebar/outline-panel.js src/renderer.js src/index.html src/styles-sidebar.css +git commit -m "feat: add document outline sidebar panel with heading navigation" +``` + +--- + +## Task 2: Zen Mode + +**Files:** +- Create: `src/zen-mode.js` +- Create: `src/styles-zen.css` +- Modify: `src/renderer.js` (lazy loader + init + shortcuts + command palette) +- Modify: `src/index.html` (add CSS link) +- Modify: `src/editor/codemirror-setup.js` (export typewriter + dimming extensions) + +### Step 1: Create CodeMirror extensions in codemirror-setup.js + +**File:** `src/editor/codemirror-setup.js` +**Location:** Before the `createEditor` function (around line 55), add: + +```javascript +const { ViewPlugin, Decoration, EditorView: EV } = require('@codemirror/view'); +const { RangeSetBuilder } = require('@codemirror/state'); + +/** + * Typewriter scrolling — keeps the cursor line vertically centered. + */ +function typewriterScrollExtension() { + return ViewPlugin.fromClass(class { + update(update) { + if (update.selectionSet || update.docChanged) { + const pos = update.state.selection.main.head; + requestAnimationFrame(() => { + update.view.dispatch({ + effects: EV.scrollIntoView(pos, { y: 'center' }) + }); + }); + } + } + }); +} + +/** + * Line dimming — fades lines away from the active cursor line. + */ +function lineDimmingExtension() { + return ViewPlugin.fromClass(class { + constructor(view) { this.decorations = this.buildDimming(view); } + + update(update) { + if (update.docChanged || update.selectionSet || update.viewportChanged) { + this.decorations = this.buildDimming(update.view); + } + } + + buildDimming(view) { + const builder = new RangeSetBuilder(); + const cursorLine = view.state.doc.lineAt(view.state.selection.main.head).number; + for (let i = 1; i <= view.state.doc.lines; i++) { + const distance = Math.abs(i - cursorLine); + let opacity = 1; + if (distance === 1) opacity = 0.7; + else if (distance === 2) opacity = 0.6; + else if (distance === 3) opacity = 0.45; + else if (distance >= 4) opacity = 0.3; + const line = view.state.doc.line(i); + builder.add(line.from, line.from, Decoration.line({ attributes: { style: `opacity:${opacity}` } })); + } + return builder.finish(); + } + }, { decorations: v => v.decorations }); +} + +module.exports = { + createEditor, + getLanguageExtension, + typewriterScrollExtension, + lineDimmingExtension +}; +``` + +**Also update the existing `module.exports`** at the end of the file to include the new exports. + +### Step 2: Create Zen Mode module + +Create `src/zen-mode.js`: + +```javascript +/** + * Zen Mode — distraction-free writing environment. + * Toggles with F11. Escape to exit. + */ +const { typewriterScrollExtension, lineDimmingExtension } = require('./editor/codemirror-setup'); + +class ZenMode { + constructor(tabManager) { + this.tabManager = tabManager; + this.active = false; + this.previousState = {}; + this.sessionStart = null; + this.timerInterval = null; + this.typewriterExt = null; + this.dimmingExt = null; + this.hud = null; + this.wordGoal = parseInt(localStorage.getItem('zen-word-goal')) || 0; + } + + toggle() { + if (this.active) { + this.deactivate(); + } else { + this.activate(); + } + } + + activate() { + if (this.active) return; + this.active = true; + document.body.classList.add('zen-mode'); + + // Save visibility state + this.previousState = { + header: document.getElementById('app-header')?.style.display, + tabBar: document.getElementById('tab-bar')?.style.display, + toolbar: document.querySelector('.toolbar')?.style.display, + sidebar: document.getElementById('sidebar')?.className, + statusBar: document.querySelector('.status-bar')?.style.display, + preview: document.getElementById('preview-container-1')?.style.display, + }; + + // Hide UI chrome + const elements = ['app-header', 'tab-bar']; + elements.forEach(id => { + const el = document.getElementById(id); + if (el) el.style.display = 'none'; + }); + const toolbar = document.querySelector('.toolbar'); + if (toolbar) toolbar.style.display = 'none'; + const statusBar = document.querySelector('.status-bar'); + if (statusBar) statusBar.style.display = 'none'; + const sidebar = document.getElementById('sidebar'); + if (sidebar) sidebar.classList.add('collapsed'); + + // Hide preview pane — editor takes full width + const tab = this.tabManager.tabs.get(this.tabManager.activeTabId); + if (tab) { + const preview = document.getElementById(`preview-container-${tab.id}`); + if (preview) preview.style.display = 'none'; + } + + // Add CodeMirror extensions for typewriter + dimming + if (tab?.editorView) { + this.typewriterExt = typewriterScrollExtension(); + this.dimmingExt = lineDimmingExtension(); + // Reconfigure with additional extensions + const { EditorView } = require('@codemirror/view'); + const { Compartment } = require('@codemirror/state'); + if (!this._zenCompartment) { + this._zenCompartment = new Compartment(); + } + tab.editorView.dispatch({ + effects: this._zenCompartment.reconfigure([ + this.typewriterExt, + this.dimmingExt, + ]) + }); + } + + // Start session timer + this.sessionStart = Date.now(); + + // Create floating HUD + this.createHUD(); + + // Focus editor + if (tab?.editorView) tab.editorView.focus(); + } + + deactivate() { + if (!this.active) return; + this.active = false; + document.body.classList.remove('zen-mode'); + + // Restore UI chrome + const header = document.getElementById('app-header'); + if (header) header.style.display = this.previousState.header || ''; + const tabBar = document.getElementById('tab-bar'); + if (tabBar) tabBar.style.display = this.previousState.tabBar || ''; + const toolbar = document.querySelector('.toolbar'); + if (toolbar) toolbar.style.display = this.previousState.toolbar || ''; + const statusBar = document.querySelector('.status-bar'); + if (statusBar) statusBar.style.display = this.previousState.statusBar || ''; + + // Restore preview + const tab = this.tabManager.tabs.get(this.tabManager.activeTabId); + if (tab) { + const preview = document.getElementById(`preview-container-${tab.id}`); + if (preview) preview.style.display = this.previousState.preview || ''; + } + + // Remove CM6 extensions + if (tab?.editorView && this._zenCompartment) { + tab.editorView.dispatch({ + effects: this._zenCompartment.reconfigure([]) + }); + } + + // Remove HUD + this.removeHUD(); + + // Stop timer + clearInterval(this.timerInterval); + this.timerInterval = null; + this.sessionStart = null; + } + + createHUD() { + this.removeHUD(); + const hud = document.createElement('div'); + hud.className = 'zen-hud'; + hud.id = 'zen-hud'; + document.body.appendChild(hud); + this.hud = hud; + this.updateHUD(); + + this.timerInterval = setInterval(() => this.updateHUD(), 1000); + } + + removeHUD() { + if (this.hud) { + this.hud.remove(); + this.hud = null; + } + } + + updateHUD() { + if (!this.hud) return; + + const content = this.tabManager.getEditorContent(); + const words = content.trim().split(/\s+/).filter(w => w.length > 0).length; + const readMin = Math.ceil(words / 200); + + let sessionStr = ''; + if (this.sessionStart) { + const elapsed = Math.floor((Date.now() - this.sessionStart) / 1000); + const m = Math.floor(elapsed / 60); + const s = elapsed % 60; + sessionStr = `${m}:${s.toString().padStart(2, '0')}`; + } + + let goalHtml = ''; + if (this.wordGoal > 0) { + const pct = Math.min(100, Math.round((words / this.wordGoal) * 100)); + goalHtml = ` +
+
+ ${words}/${this.wordGoal} (${pct}%) +
+ `; + } + + this.hud.innerHTML = ` +
+ ${words} words + \u2022 + ~${readMin} min read + \u2022 + ${sessionStr} session +
+ ${goalHtml} + `; + } +} + +module.exports = { ZenMode }; +``` + +### Step 3: Create Zen Mode CSS + +Create `src/styles-zen.css`: + +```css +/* Zen Mode — Distraction-free writing */ + +/* Hide chrome elements */ +body.zen-mode .app-header, +body.zen-mode .tab-bar, +body.zen-mode .toolbar, +body.zen-mode .status-bar, +body.zen-mode .sidebar { + display: none !important; +} + +/* Editor takes full viewport */ +body.zen-mode .main-content { + display: flex; + flex-direction: column; + height: 100vh; +} + +body.zen-mode .editor-preview-container { + flex: 1; +} + +body.zen-mode .editor-container { + width: 100% !important; +} + +body.zen-mode .preview-container { + display: none !important; +} + +/* Center content, larger font */ +body.zen-mode .cm-content { + max-width: 700px; + margin: 0 auto; + font-size: 18px; + line-height: 1.8; + padding: 0 24px; +} + +body.zen-mode .cm-scroller { + overflow-y: auto; +} + +/* Subtle background */ +body.zen-mode .cm-editor { + background: transparent; +} + +/* Floating HUD */ +.zen-hud { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + color: rgba(255, 255, 255, 0.8); + padding: 10px 24px; + border-radius: 24px; + font-size: 13px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + z-index: 9999; + pointer-events: none; + transition: opacity 0.3s ease; +} + +body:not(.zen-mode) .zen-hud { + display: none; +} + +.zen-hud-stats { + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.zen-hud-sep { + opacity: 0.4; +} + +/* Progress bar */ +.zen-progress { + margin-top: 8px; + position: relative; + height: 4px; + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; + overflow: hidden; +} + +.zen-progress-bar { + height: 100%; + background: rgba(255, 255, 255, 0.5); + border-radius: 2px; + transition: width 0.3s ease; +} + +.zen-progress-text { + position: absolute; + right: 0; + top: -16px; + font-size: 10px; + opacity: 0.6; +} + +/* Dark mode adjustments */ +body.zen-mode.dark .zen-hud, +body.zen-mode[class*="dark"] .zen-hud { + background: rgba(0, 0, 0, 0.7); +} +``` + +### Step 4: Add CSS link in index.html + +**File:** `src/index.html` +**Location:** After line 18 (after `styles-welcome.css` link), add: + +```html + +``` + +### Step 5: Add lazy loader + init + shortcuts in renderer.js + +**File:** `src/renderer.js` + +**After the lazy loaders (line 22):** +```javascript +let _ZenMode; +function getZenMode() { if (!_ZenMode) _ZenMode = require('./zen-mode').ZenMode; return _ZenMode; } +``` + +**After sidebar + modal initialization (around line 1468):** +```javascript + // Zen Mode + const ZenModeClass = getZenMode(); + const zenMode = new ZenModeClass(tabManager); +``` + +**After command palette registrations (around line 1603):** +```javascript + commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle()); + commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => { /* will be added in Task 3 */ }); +``` + +**In the keyboard event listener (around line 1627, after Ctrl+Shift+P handler):** +```javascript + // F11 — Zen Mode + if (e.key === 'F11') { + e.preventDefault(); + zenMode.toggle(); + } + // Ctrl+Shift+A — Writing Analytics (placeholder) + if (e.ctrlKey && e.shiftKey && e.key === 'A') { + e.preventDefault(); + // Will be wired in Task 3 + } + // Escape — Exit Zen Mode + if (e.key === 'Escape' && zenMode.active) { + zenMode.deactivate(); + } +``` + +### Step 6: Commit + +```bash +git add src/zen-mode.js src/styles-zen.css src/editor/codemirror-setup.js src/renderer.js src/index.html +git commit -m "feat: add Zen Mode with typewriter scrolling and line dimming" +``` + +--- + +## Task 3: Writing Analytics + +**Files:** +- Create: `src/analytics/writing-analytics.js` +- Create: `src/analytics/analytics-panel.js` +- Modify: `src/renderer.js` (wire analytics to shortcut + command palette) + +### Step 1: Create analytics engine + +Create `src/analytics/writing-analytics.js`: + +```javascript +/** + * Writing Analytics Engine + * Computes readability, structure, and vocabulary metrics from text. + */ + +const STOP_WORDS = new Set([ + 'the','a','an','is','are','was','were','be','been','being','have','has','had', + 'do','does','did','will','would','could','should','may','might','shall','can', + 'to','of','in','for','on','with','at','by','from','as','into','through','during', + 'before','after','above','below','between','out','off','over','under','again', + 'further','then','once','here','there','when','where','why','how','all','each', + 'every','both','few','more','most','other','some','such','no','nor','not','only', + 'own','same','so','than','too','very','just','because','but','and','or','if', + 'while','about','up','it','its','this','that','these','those','i','me','my', + 'we','our','you','your','he','him','his','she','her','they','them','their','what', + 'which','who','whom','also' +]); + +function countSyllables(word) { + word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, ''); + word = word.replace(/^y/, ''); + const matches = word.match(/[aeiouy]{1,2}/gi); + return matches ? Math.max(1, matches.length) : 1; +} + +function getWords(text) { + return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || []; +} + +function getSentences(text) { + const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); + return sentences.length || 1; +} + +function getParagraphs(text) { + return text.split(/\n\s*\n/).filter(p => p.trim().length > 0).length || 1; +} + +/** + * Analyze text and return comprehensive writing metrics. + * @param {string} text - The document text to analyze + * @returns {Object} Analytics object + */ +function analyze(text) { + if (!text || text.trim().length === 0) { + return emptyMetrics(); + } + + const words = getWords(text); + const wordCount = words.length || 1; + const sentenceCount = getSentences(text); + const paragraphCount = getParagraphs(text); + + // Syllable counting + let totalSyllables = 0; + const syllableMap = {}; + words.forEach(w => { + const s = countSyllables(w); + totalSyllables += s; + syllableMap[w.toLowerCase()] = s; + }); + + // Readability + const fleschEase = 206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount); + const fleschGrade = 0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59; + + // Vocabulary + const uniqueWords = new Set(words.map(w => w.toLowerCase())); + const lexicalDiversity = uniqueWords.size / wordCount; + + // Word frequency (excluding stop words) + const freq = {}; + words.forEach(w => { + const lower = w.toLowerCase(); + if (!STOP_WORDS.has(lower) && lower.length > 1) { + freq[lower] = (freq[lower] || 0) + 1; + } + }); + const topWords = Object.entries(freq) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + // Sentence lengths + const sentenceTexts = text.split(/[.!?]+/).filter(s => s.trim().length > 0); + const sentenceLengths = sentenceTexts.map(s => s.trim().split(/\s+/).filter(w => w.length > 0).length); + const avgSentenceLength = sentenceLengths.length > 0 + ? sentenceLengths.reduce((a, b) => a + b, 0) / sentenceLengths.length + : 0; + const longestSentence = sentenceTexts.reduce((a, b) => a.length >= b.length ? a : b, '').trim(); + + // Timing + const readingTime = Math.ceil(wordCount / 200); + const speakingTime = Math.ceil(wordCount / 130); + + return { + wordCount, + sentenceCount, + paragraphCount, + totalSyllables, + fleschEase: Math.round(fleschEase * 10) / 10, + fleschGrade: Math.round(fleschGrade * 10) / 10, + readabilityLabel: getReadabilityLabel(fleschEase), + readingTime, + speakingTime, + uniqueWordCount: uniqueWords.size, + lexicalDiversity: Math.round(lexicalDiversity * 1000) / 10, + avgSentenceLength: Math.round(avgSentenceLength * 10) / 10, + longestSentence: longestSentence.substring(0, 80) + (longestSentence.length > 80 ? '...' : ''), + longestSentenceLength: longestSentence.split(/\s+/).filter(w => w.length > 0).length, + topWords, + }; +} + +function getReadabilityLabel(score) { + if (score >= 90) return 'Very Easy'; + if (score >= 70) return 'Easy'; + if (score >= 50) return 'Standard'; + if (score >= 30) return 'Difficult'; + return 'Very Difficult'; +} + +function emptyMetrics() { + return { + wordCount: 0, sentenceCount: 0, paragraphCount: 0, totalSyllables: 0, + fleschEase: 0, fleschGrade: 0, readabilityLabel: 'N/A', + readingTime: 0, speakingTime: 0, + uniqueWordCount: 0, lexicalDiversity: 0, + avgSentenceLength: 0, longestSentence: '', longestSentenceLength: 0, + topWords: [], + }; +} + +module.exports = { analyze }; +``` + +### Step 2: Create analytics panel UI + +Create `src/analytics/analytics-panel.js`: + +```javascript +/** + * Writing Analytics Panel UI + * Renders analytics dashboard as a modal overlay. + */ +const { analyze } = require('./writing-analytics'); + +function showAnalyticsModal(tabManager) { + // Remove existing if open + document.getElementById('analytics-modal')?.remove(); + + const content = tabManager.getEditorContent(); + const metrics = analyze(content); + + const overlay = document.createElement('div'); + overlay.id = 'analytics-modal'; + overlay.className = 'analytics-overlay'; + overlay.innerHTML = ` +
+
+

Writing Analytics

+ +
+
+ +
+

Readability

+
+ Flesch Reading Ease + ${metrics.fleschEase} ${metrics.readabilityLabel} +
+
+ Grade Level + ${metrics.fleschGrade} +
+
+
+
+
+ +
+

Timing

+
+ Reading Time + ~${metrics.readingTime} min +
+
+ Speaking Time + ~${metrics.speakingTime} min +
+
+ +
+

Structure

+
+ Sentences + ${metrics.sentenceCount} +
+
+ Paragraphs + ${metrics.paragraphCount} +
+
+ Avg Sentence Length + ${metrics.avgSentenceLength} words +
+ ${metrics.longestSentence ? ` +
+ Longest (${metrics.longestSentenceLength} words) + "${metrics.longestSentence}" +
+ ` : ''} +
+ +
+

Vocabulary

+
+ Unique Words + ${metrics.uniqueWordCount} / ${metrics.wordCount} (${metrics.lexicalDiversity}%) +
+ ${metrics.topWords.length > 0 ? ` +
+ ${metrics.topWords.map(([word, count]) => + `${word} ${count}` + ).join('')} +
+ ` : ''} +
+ +
+
+ `; + + document.body.appendChild(overlay); + + // Close handlers + document.getElementById('analytics-close')?.addEventListener('click', () => overlay.remove()); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) overlay.remove(); + }); + document.addEventListener('keydown', function handler(e) { + if (e.key === 'Escape') { + overlay.remove(); + document.removeEventListener('keydown', handler); + } + }); +} + +module.exports = { showAnalyticsModal }; +``` + +### Step 3: Add analytics CSS + +**File:** `src/styles-modern.css` +**Location:** End of file, add: + +```css +/* Writing Analytics Modal */ +.analytics-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.2s ease; +} + +.analytics-modal { + background: var(--bg-primary, #fff); + border-radius: 12px; + width: 520px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0,0,0,0.3); + animation: slideUp 0.3s ease; +} + +.analytics-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color, #e5e7eb); +} + +.analytics-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +.analytics-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: var(--text-muted); + padding: 4px 8px; + border-radius: 4px; +} + +.analytics-close:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.analytics-body { + padding: 16px 24px 24px; +} + +.analytics-section { + margin-bottom: 20px; +} + +.analytics-section h3 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted, #9ca3af); + margin: 0 0 10px 0; +} + +.analytics-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 6px 0; + font-size: 14px; +} + +.analytics-label { + color: var(--text-secondary, #6b7280); +} + +.analytics-value { + font-weight: 500; + color: var(--text-primary); +} + +.analytics-value small { + color: var(--text-muted); + font-weight: 400; + margin-left: 6px; +} + +.analytics-longest { + flex-direction: column; + gap: 4px; +} + +.analytics-sentence-preview { + font-style: italic; + font-size: 13px; + color: var(--text-secondary); + font-weight: 400; +} + +.readability-meter { + height: 4px; + background: var(--bg-tertiary, #f3f4f6); + border-radius: 2px; + margin-top: 8px; + overflow: hidden; +} + +.readability-fill { + height: 100%; + background: linear-gradient(90deg, #ef4444, #f59e0b, #10b981); + border-radius: 2px; + transition: width 0.5s ease; +} + +.word-cloud { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.word-tag { + background: var(--bg-tertiary, #f3f4f6); + padding: 4px 10px; + border-radius: 12px; + font-size: 13px; + color: var(--text-secondary); +} + +.word-tag small { + opacity: 0.5; + font-size: 10px; + margin-left: 2px; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { transform: translateY(16px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +/* Dark mode analytics */ +body[class*="dark"] .analytics-modal { + background: var(--gray-800, #1f2937); +} + +body[class*="dark"] .word-tag { + background: var(--gray-700, #374151); +} +``` + +### Step 4: Wire analytics to renderer.js + +**File:** `src/renderer.js` + +**After lazy loaders (after ZenMode loader):** +```javascript +let _showAnalyticsModal; +function getShowAnalyticsModal() { if (!_showAnalyticsModal) _showAnalyticsModal = require('./analytics/analytics-panel').showAnalyticsModal; return _showAnalyticsModal; } +``` + +**Update the Ctrl+Shift+A keyboard shortcut (from Task 2 Step 5):** +Replace the placeholder with: +```javascript + if (e.ctrlKey && e.shiftKey && e.key === 'A') { + e.preventDefault(); + getShowAnalyticsModal()(tabManager); + } +``` + +**Update the command palette entry (from Task 2 Step 5):** +Replace the placeholder with: +```javascript + commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager)); +``` + +### Step 5: Commit + +```bash +git add src/analytics/writing-analytics.js src/analytics/analytics-panel.js src/renderer.js src/styles-modern.css +git commit -m "feat: add writing analytics with readability scores and vocabulary analysis" +``` + +--- + +## Task 4: Integration Testing & Final Polish + +### Step 1: Test Document Outline + +Manual test: +1. Open app with `npm start` +2. Open a markdown file with headings +3. Click the outline icon in sidebar +4. Verify heading tree appears with correct indentation +5. Click a heading — verify editor scrolls to it +6. Type in editor — verify outline updates +7. Move cursor — verify current heading highlights + +### Step 2: Test Zen Mode + +Manual test: +1. Press F11 — verify all chrome hides +2. Verify typewriter scrolling (cursor stays centered) +3. Verify line dimming (surrounding lines fade) +4. Verify floating HUD shows word count + timer +5. Press Escape — verify all chrome restores +6. Verify preview pane returns + +### Step 3: Test Writing Analytics + +Manual test: +1. Press Ctrl+Shift+A — verify analytics modal opens +2. Verify readability scores appear +3. Verify timing estimates are reasonable +4. Verify vocabulary section shows top words +5. Click backdrop — verify modal closes +6. Press Escape — verify modal closes + +### Step 4: Test keyboard shortcuts + +- F11: Toggle zen mode +- Escape: Exit zen mode +- Ctrl+Shift+O: Toggle outline panel +- Ctrl+Shift+A: Open analytics +- Ctrl+Shift+P: Command palette (existing — verify still works) + +### Step 5: Update version + commit + +Update `package.json` version to `4.2.0`. +Update `src/index.html` version badge to `v4.2.0`. + +```bash +git add package.json src/index.html +git commit -m "chore: bump to v4.2.0 — Writer's Studio Feature Pack" +``` + +--- + +## Summary + +| Task | Feature | New Files | Modified Files | +|------|---------|-----------|----------------| +| 1 | Document Outline | `src/sidebar/outline-panel.js` | `renderer.js`, `index.html`, `styles-sidebar.css` | +| 2 | Zen Mode | `src/zen-mode.js`, `src/styles-zen.css` | `codemirror-setup.js`, `renderer.js`, `index.html` | +| 3 | Writing Analytics | `src/analytics/writing-analytics.js`, `src/analytics/analytics-panel.js` | `renderer.js`, `styles-modern.css` | +| 4 | Integration & Polish | — | `package.json`, `index.html` |