✨ polish meter
This commit is contained in:
@@ -11,6 +11,7 @@ import DOMPurify from 'dompurify'
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import type { TextAnnotation } from '../../types/editor'
|
||||
import { reanchorAnnotations } from '../../utils/annotationParser'
|
||||
import { computePolishScore } from '../../utils/polishMetrics'
|
||||
import { ContextMenu } from '../FileTree/ContextMenu'
|
||||
import type { MenuItem } from '../FileTree/ContextMenu'
|
||||
import './Editor.css'
|
||||
@@ -724,6 +725,16 @@ export function MarkdownEditor(): JSX.Element {
|
||||
wordTimer = setTimeout(() => { wordTimer = null; setWordStats(getStats()) }, 150)
|
||||
}
|
||||
|
||||
// Debounced polish score — runs static analysis 1.5s after the user stops typing
|
||||
let polishTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function schedulePolishScore(text: string): void {
|
||||
if (polishTimer) clearTimeout(polishTimer)
|
||||
polishTimer = setTimeout(() => {
|
||||
polishTimer = null
|
||||
useEditorStore.getState().setPolishScore(computePolishScore(text))
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// Debounced telemetry snapshot — fires 5s after the user stops typing
|
||||
let telemetryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function scheduleTelemetrySnapshot(wordCount: number): void {
|
||||
@@ -799,6 +810,7 @@ export function MarkdownEditor(): JSX.Element {
|
||||
if (update.docChanged) {
|
||||
const text = update.state.doc.toString()
|
||||
scheduleSetContent(text)
|
||||
schedulePolishScore(text)
|
||||
const total = countWords(text)
|
||||
wordTotalRef.current = total
|
||||
scheduleTelemetrySnapshot(total)
|
||||
@@ -920,6 +932,7 @@ export function MarkdownEditor(): JSX.Element {
|
||||
if (!view) return
|
||||
const current = view.state.doc.toString()
|
||||
if (current !== activeFileContent) {
|
||||
useEditorStore.getState().setPolishScore(computePolishScore(activeFileContent))
|
||||
// Mark the file-load as non-undoable: loading a file should never
|
||||
// appear in the undo stack, so Cmd+Z can't empty the document.
|
||||
view.dispatch({
|
||||
|
||||
@@ -531,3 +531,79 @@
|
||||
.fb-card-header--no-jump:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ── Polish meter ───────────────────────────────────── */
|
||||
:root {
|
||||
--polish-good: #1D9E75;
|
||||
--polish-mid: #EF9F27;
|
||||
--polish-low: #E24B4A;
|
||||
}
|
||||
|
||||
.pm-widget {
|
||||
margin: 10px 10px 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pm-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pm-overall {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pm-dims {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.pm-dim {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pm-dim-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
width: 112px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pm-bar-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pm-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease, background 0.4s ease;
|
||||
}
|
||||
|
||||
.pm-dim-score {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
width: 22px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import type { TextAnnotation } from '../../types/editor'
|
||||
import type { PolishScore } from '../../utils/polishMetrics'
|
||||
import {
|
||||
tooltipAnalysisCache,
|
||||
cancelPendingDismiss,
|
||||
@@ -266,6 +267,38 @@ function ArchiveCard({ ann, onRemove }: ArchiveCardProps): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
if (score >= 75) return 'var(--polish-good)'
|
||||
if (score >= 45) return 'var(--polish-mid)'
|
||||
return 'var(--polish-low)'
|
||||
}
|
||||
|
||||
function PolishMeter({ score }: { score: PolishScore }): JSX.Element {
|
||||
const dims = Object.values(score.dimensions)
|
||||
return (
|
||||
<div className="pm-widget">
|
||||
<div className="pm-header">
|
||||
<span className="pm-title">Polish</span>
|
||||
<span className="pm-overall" style={{ color: scoreColor(score.overall) }}>{score.overall}</span>
|
||||
</div>
|
||||
<div className="pm-dims">
|
||||
{dims.map(dim => (
|
||||
<div key={dim.label} className="pm-dim">
|
||||
<span className="pm-dim-label">{dim.label}</span>
|
||||
<div className="pm-bar-track">
|
||||
<div
|
||||
className="pm-bar-fill"
|
||||
style={{ width: `${dim.score}%`, background: scoreColor(dim.score) }}
|
||||
/>
|
||||
</div>
|
||||
<span className="pm-dim-score">{dim.score}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FeedbackPanel(): JSX.Element {
|
||||
const {
|
||||
annotations,
|
||||
@@ -276,6 +309,7 @@ export function FeedbackPanel(): JSX.Element {
|
||||
clearArchivedAnnotations,
|
||||
removeArchivedAnnotation,
|
||||
addDocumentNote,
|
||||
polishScore,
|
||||
} = useEditorStore()
|
||||
const [analyseAll, setAnalyseAll] = useState(false)
|
||||
const [addingNote, setAddingNote] = useState(false)
|
||||
@@ -330,6 +364,7 @@ export function FeedbackPanel(): JSX.Element {
|
||||
if (!hasActive && !hasArchive) {
|
||||
return (
|
||||
<div className="fb-panel">
|
||||
{polishScore && <PolishMeter score={polishScore} />}
|
||||
<div className="fb-empty">
|
||||
<p>No feedback yet.</p>
|
||||
<p>Run a critique from the toolbar to highlight issues in your text.</p>
|
||||
@@ -363,6 +398,7 @@ export function FeedbackPanel(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="fb-panel">
|
||||
{polishScore && <PolishMeter score={polishScore} />}
|
||||
{hasActive && (
|
||||
<>
|
||||
<div className="fb-toolbar">
|
||||
|
||||
@@ -63,6 +63,10 @@ interface EditorState {
|
||||
// Clear active file (e.g. after deletion)
|
||||
clearActiveFile: () => void
|
||||
|
||||
// Polish score
|
||||
polishScore: import('../utils/polishMetrics').PolishScore | null
|
||||
setPolishScore: (score: import('../utils/polishMetrics').PolishScore | null) => void
|
||||
|
||||
// Word counts
|
||||
projectWordCount: number
|
||||
setProjectWordCount: (count: number) => void
|
||||
@@ -623,6 +627,9 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
})
|
||||
},
|
||||
|
||||
polishScore: null,
|
||||
setPolishScore: (polishScore) => set({ polishScore }),
|
||||
|
||||
projectWordCount: 0,
|
||||
setProjectWordCount: (projectWordCount) => set({ projectWordCount }),
|
||||
|
||||
|
||||
182
src/renderer/utils/polishMetrics.ts
Normal file
182
src/renderer/utils/polishMetrics.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { detectPassiveVoice } from './passiveVoice'
|
||||
|
||||
const WEASEL_PATTERN = /\b(very|quite|rather|fairly|really|just|basically|actually|literally|clearly|obviously|simply|absolutely|totally|completely|certainly|probably|mostly|nearly|almost|somewhat|arguably|undeniably|remarkably|incredibly|extremely|highly)\b/gi
|
||||
|
||||
export interface PolishDimension {
|
||||
label: string
|
||||
score: number // 0–100
|
||||
issueCount: number
|
||||
}
|
||||
|
||||
export interface PolishScore {
|
||||
overall: number
|
||||
dimensions: {
|
||||
passiveVoice: PolishDimension
|
||||
weaselWords: PolishDimension
|
||||
sentenceRhythm: PolishDimension
|
||||
wordVariety: PolishDimension
|
||||
dialogueTags: PolishDimension
|
||||
}
|
||||
}
|
||||
|
||||
// Score a rate (issues per 1000 words): 0 issues = 100, penalises linearly up to `cap`
|
||||
function rateScore(issueCount: number, wordCount: number, cap: number): number {
|
||||
if (wordCount === 0) return 100
|
||||
const rate = (issueCount / wordCount) * 1000
|
||||
return Math.max(0, Math.round(100 - (rate / cap) * 100))
|
||||
}
|
||||
|
||||
function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/^#{1,6}\s+/gm, '') // headings
|
||||
.replace(/\*\*?|__?/g, '') // bold/italic
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links
|
||||
.replace(/`[^`]+`/g, '') // inline code
|
||||
.replace(/^>\s+/gm, '') // blockquotes
|
||||
}
|
||||
|
||||
function countWordsIn(text: string): number {
|
||||
const t = text.trim()
|
||||
return t === '' ? 0 : t.split(/\s+/).length
|
||||
}
|
||||
|
||||
function splitSentences(text: string): string[] {
|
||||
return text
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0)
|
||||
}
|
||||
|
||||
// Sentence rhythm: coefficient of variation of sentence lengths (higher variance = better rhythm)
|
||||
function sentenceRhythmScore(text: string): { score: number; issueCount: number } {
|
||||
const sentences = splitSentences(text)
|
||||
if (sentences.length < 3) return { score: 100, issueCount: 0 }
|
||||
|
||||
const lengths = sentences.map(s => s.split(/\s+/).length)
|
||||
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length
|
||||
const variance = lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length
|
||||
const stdDev = Math.sqrt(variance)
|
||||
const cv = mean > 0 ? stdDev / mean : 0
|
||||
|
||||
// Count "monotonous runs": 3+ consecutive sentences within 2 words of each other in length
|
||||
let runsCount = 0
|
||||
let runLen = 1
|
||||
for (let i = 1; i < lengths.length; i++) {
|
||||
if (Math.abs(lengths[i] - lengths[i - 1]) <= 2) {
|
||||
runLen++
|
||||
if (runLen === 3) runsCount++
|
||||
} else {
|
||||
runLen = 1
|
||||
}
|
||||
}
|
||||
|
||||
// cv >= 0.6 = great rhythm; 0.3 = mediocre; < 0.3 = monotonous
|
||||
const score = Math.min(100, Math.round((cv / 0.6) * 100))
|
||||
return { score, issueCount: runsCount }
|
||||
}
|
||||
|
||||
// Word variety: type-token ratio in sliding windows of 100 words
|
||||
function wordVarietyScore(text: string): { score: number; issueCount: number } {
|
||||
const words = text.toLowerCase().match(/\b[a-z']+\b/g) ?? []
|
||||
if (words.length < 20) return { score: 100, issueCount: 0 }
|
||||
|
||||
const WINDOW = 100
|
||||
const SKIP_WORDS = new Set(['the','a','an','and','or','but','in','on','at','to','for','of','with','is','was','are','were','it','he','she','they','i','you','we','be','been','being','that','this','those','these','have','has','had','do','did','does','not','so','as','if','by','from','into','than','then','when','where','who','which','what','his','her','their','its','my','your','our','up','out','about','after','before','all','some','one','no','more','also'])
|
||||
|
||||
const contentWords = words.filter(w => !SKIP_WORDS.has(w))
|
||||
if (contentWords.length < 20) return { score: 100, issueCount: 0 }
|
||||
|
||||
const ttrs: number[] = []
|
||||
for (let i = 0; i + WINDOW <= contentWords.length; i += Math.floor(WINDOW / 2)) {
|
||||
const window = contentWords.slice(i, i + WINDOW)
|
||||
const unique = new Set(window).size
|
||||
ttrs.push(unique / window.length)
|
||||
}
|
||||
const avgTTR = ttrs.reduce((a, b) => a + b, 0) / ttrs.length
|
||||
|
||||
// TTR >= 0.7 = rich; 0.4 = acceptable; < 0.4 = repetitive
|
||||
const score = Math.min(100, Math.max(0, Math.round(((avgTTR - 0.4) / 0.3) * 100)))
|
||||
|
||||
// Count repeated content words within 50-word windows
|
||||
let repeatedCount = 0
|
||||
for (let i = 0; i + 50 <= contentWords.length; i += 25) {
|
||||
const window = contentWords.slice(i, i + 50)
|
||||
const counts = new Map<string, number>()
|
||||
for (const w of window) counts.set(w, (counts.get(w) ?? 0) + 1)
|
||||
for (const [, count] of counts) if (count >= 3) repeatedCount++
|
||||
}
|
||||
|
||||
return { score, issueCount: repeatedCount }
|
||||
}
|
||||
|
||||
// Dialogue tag quality: penalise said-bookisms and adverbs on tags
|
||||
const SAID_BOOKISMS = /\b(hissed|snapped|exclaimed|barked|spat|snarled|growled|shrieked|whined|croaked|breathed|murmured)\b/gi
|
||||
const ADV_TAG = /\b(said|asked|replied|whispered)\s+\w+ly\b/gi
|
||||
|
||||
function dialogueTagScore(text: string, wordCount: number): { score: number; issueCount: number } {
|
||||
const bookisms = (text.match(SAID_BOOKISMS) ?? []).length
|
||||
const advTags = (text.match(ADV_TAG) ?? []).length
|
||||
const total = bookisms + advTags
|
||||
return { score: rateScore(total, wordCount, 8), issueCount: total }
|
||||
}
|
||||
|
||||
export function computePolishScore(rawText: string): PolishScore {
|
||||
const text = stripMarkdown(rawText)
|
||||
const wordCount = countWordsIn(text)
|
||||
|
||||
const passiveIssues = wordCount > 10 ? detectPassiveVoice(text).length : 0
|
||||
const weaselIssues = wordCount > 10 ? (text.match(WEASEL_PATTERN) ?? []).length : 0
|
||||
|
||||
const passive: PolishDimension = {
|
||||
label: 'Passive voice',
|
||||
score: rateScore(passiveIssues, wordCount, 12),
|
||||
issueCount: passiveIssues
|
||||
}
|
||||
|
||||
const weasel: PolishDimension = {
|
||||
label: 'Weasel words',
|
||||
score: rateScore(weaselIssues, wordCount, 10),
|
||||
issueCount: weaselIssues
|
||||
}
|
||||
|
||||
const rhythmResult = sentenceRhythmScore(text)
|
||||
const rhythm: PolishDimension = {
|
||||
label: 'Sentence rhythm',
|
||||
score: rhythmResult.score,
|
||||
issueCount: rhythmResult.issueCount
|
||||
}
|
||||
|
||||
const varietyResult = wordVarietyScore(text)
|
||||
const variety: PolishDimension = {
|
||||
label: 'Word variety',
|
||||
score: varietyResult.score,
|
||||
issueCount: varietyResult.issueCount
|
||||
}
|
||||
|
||||
const dialogueResult = dialogueTagScore(text, wordCount)
|
||||
const dialogue: PolishDimension = {
|
||||
label: 'Dialogue tags',
|
||||
score: dialogueResult.score,
|
||||
issueCount: dialogueResult.issueCount
|
||||
}
|
||||
|
||||
const weights = { passive: 0.2, weasel: 0.2, rhythm: 0.25, variety: 0.2, dialogue: 0.15 }
|
||||
const overall = Math.round(
|
||||
passive.score * weights.passive +
|
||||
weasel.score * weights.weasel +
|
||||
rhythm.score * weights.rhythm +
|
||||
variety.score * weights.variety +
|
||||
dialogue.score * weights.dialogue
|
||||
)
|
||||
|
||||
return {
|
||||
overall,
|
||||
dimensions: {
|
||||
passiveVoice: passive,
|
||||
weaselWords: weasel,
|
||||
sentenceRhythm: rhythm,
|
||||
wordVariety: variety,
|
||||
dialogueTags: dialogue
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user