clickable feedback

This commit is contained in:
TC
2026-06-14 20:22:34 +10:00
parent 5bb1212efd
commit 497c29c792
5 changed files with 264 additions and 145 deletions

View File

@@ -534,9 +534,10 @@
/* ── Polish meter ───────────────────────────────────── */
:root {
--polish-good: #1D9E75;
--polish-mid: #EF9F27;
--polish-low: #E24B4A;
--polish-good: #1D9E75;
--polish-mid: #EF9F27;
--polish-low: #E24B4A;
--polish-active: #378ADD;
}
.pm-widget {
@@ -577,6 +578,25 @@
display: flex;
align-items: center;
gap: 8px;
border-radius: 3px;
padding: 1px 3px;
margin: 0 -3px;
}
.pm-dim--clickable {
cursor: pointer;
}
.pm-dim--clickable:hover {
background: rgba(255, 255, 255, 0.05);
}
.pm-dim--active {
background: rgba(55, 138, 221, 0.08);
}
.pm-dim--active .pm-dim-label {
color: var(--polish-active);
}
.pm-dim-label {
@@ -584,6 +604,7 @@
color: var(--text-muted);
width: 112px;
flex-shrink: 0;
transition: color 0.2s;
}
.pm-bar-track {

View File

@@ -3,7 +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 type { PolishScore, PolishDimension } from '../../utils/polishMetrics'
import {
tooltipAnalysisCache,
cancelPendingDismiss,
@@ -28,6 +28,7 @@ function badgeColor(type: TextAnnotation['type']): string {
case 'custom': return 'rgba(30, 200, 150, 0.8)'
case 'user_comment': return 'rgba(240, 100, 180, 0.85)'
case 'document_note': return 'rgba(80, 180, 240, 0.85)'
case 'polish': return 'rgba(55, 138, 221, 0.75)'
}
}
@@ -274,7 +275,39 @@ function scoreColor(score: number): string {
}
function PolishMeter({ score }: { score: PolishScore }): JSX.Element {
const dims = Object.values(score.dimensions)
const [activeKey, setActiveKey] = useState<string | null>(null)
const { setAnnotations, clearAnnotations } = useEditorStore()
const dims = Object.entries(score.dimensions) as [string, PolishDimension][]
function handleDimClick(key: string, dim: PolishDimension): void {
if (activeKey === key) {
setActiveKey(null)
clearAnnotations()
return
}
if (dim.matches.length === 0) return
setActiveKey(key)
const annotations: TextAnnotation[] = dim.matches.map((m, i) => ({
id: `polish-${key}-${i}`,
type: 'polish' as const,
from: m.from,
to: m.to,
matchedText: m.text,
message: `${dim.label}: "${m.text.slice(0, 60)}${m.text.length > 60 ? '…' : ''}"`,
}))
setAnnotations(annotations)
}
// Clear active state when score changes (new file or reanalysis)
const prevOverall = useRef(score.overall)
useEffect(() => {
if (prevOverall.current !== score.overall) {
prevOverall.current = score.overall
setActiveKey(null)
}
}, [score.overall])
return (
<div className="pm-widget">
<div className="pm-header">
@@ -282,18 +315,27 @@ function PolishMeter({ score }: { score: PolishScore }): JSX.Element {
<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) }}
/>
{dims.map(([key, dim]) => {
const isActive = activeKey === key
const clickable = dim.matches.length > 0
return (
<div
key={key}
className={`pm-dim${clickable ? ' pm-dim--clickable' : ''}${isActive ? ' pm-dim--active' : ''}`}
onClick={() => clickable && handleDimClick(key, dim)}
title={clickable ? `Click to highlight ${dim.issueCount} instance${dim.issueCount !== 1 ? 's' : ''}` : undefined}
>
<span className="pm-dim-label">{dim.label}</span>
<div className="pm-bar-track">
<div
className="pm-bar-fill"
style={{ width: `${dim.score}%`, background: isActive ? 'var(--polish-active)' : scoreColor(dim.score) }}
/>
</div>
<span className="pm-dim-score" style={{ color: isActive ? 'var(--polish-active)' : undefined }}>{dim.score}</span>
</div>
<span className="pm-dim-score">{dim.score}</span>
</div>
))}
)
})}
</div>
</div>
)

View File

@@ -41,7 +41,7 @@ export interface ChatSession {
messages: ChatMessage[]
}
export type AnnotationType = 'passive_voice' | 'past_progressive' | 'weak_verbs' | 'cliches' | 'consistency' | 'style' | 'show_tell' | 'critique' | 'custom' | 'user_comment' | 'document_note'
export type AnnotationType = 'passive_voice' | 'past_progressive' | 'weak_verbs' | 'cliches' | 'consistency' | 'style' | 'show_tell' | 'critique' | 'custom' | 'user_comment' | 'document_note' | 'polish'
export interface TextAnnotation {
id: string

View File

@@ -1,9 +1,16 @@
import { detectPassiveVoice } from './passiveVoice'
export interface PolishMatch {
from: number
to: number
text: string
}
export interface PolishDimension {
label: string
score: number // 0100
score: number
issueCount: number
matches: PolishMatch[]
}
export interface PolishScore {
@@ -24,29 +31,62 @@ export interface PolishScore {
}
}
// 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, '')
.replace(/\*\*?|__?/g, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/`[^`]+`/g, '')
.replace(/^>\s+/gm, '')
}
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)
// Returns {text, from, to} for each sentence, positions in the raw text
function splitSentencesWithPos(text: string): Array<{ text: string; from: number; to: number }> {
const result: Array<{ text: string; from: number; to: number }> = []
const re = /[.!?]+\s+/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const to = m.index + m[0].length
const s = text.slice(last, to).trim()
if (s.length > 0) result.push({ text: s, from: last, to })
last = to
}
if (last < text.length) {
const s = text.slice(last).trim()
if (s.length > 0) result.push({ text: s, from: last, to: text.length })
}
return result
}
function splitParasWithPos(text: string): Array<{ text: string; from: number; to: number }> {
const result: Array<{ text: string; from: number; to: number }> = []
const re = /\n{2,}/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const s = text.slice(last, m.index).trim()
if (s.length > 0) result.push({ text: s, from: last, to: m.index })
last = m.index + m[0].length
}
if (last < text.length) {
const s = text.slice(last).trim()
if (s.length > 0) result.push({ text: s, from: last, to: text.length })
}
return result
}
function regexMatches(text: string, pattern: RegExp): PolishMatch[] {
const re = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g')
re.lastIndex = 0
const result: PolishMatch[] = []
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
result.push({ from: m.index, to: m.index + m[0].length, text: m[0] })
}
return result
}
// ── Weasel words ──────────────────────────────────────────────────────────────
@@ -54,128 +94,153 @@ const WEASEL_PATTERN = /\b(very|quite|rather|fairly|really|basically|actually|li
// ── Adverb density ────────────────────────────────────────────────────────────
const ADVERB_PATTERN = /\b\w+ly\b/gi
const ADVERB_EXCEPTIONS = new Set(['only','early','daily','likely','lonely','lovely','elderly','friendly','lively','deadly','holy','ugly','silly','hilly','belly','bully','rally','ally','jelly','fully','ully'])
const ADVERB_EXCEPTIONS = new Set(['only','early','daily','likely','lonely','lovely','elderly','friendly','lively','deadly','holy','ugly','silly','hilly','belly','bully','rally','ally','jelly','fully'])
function adverbDensityScore(text: string, wordCount: number): { score: number; issueCount: number } {
const matches = (text.match(ADVERB_PATTERN) ?? []).filter(w => !ADVERB_EXCEPTIONS.has(w.toLowerCase()))
return { score: rateScore(matches.length, wordCount, 20), issueCount: matches.length }
function adverbMatches(text: string): PolishMatch[] {
return regexMatches(text, ADVERB_PATTERN).filter(m => !ADVERB_EXCEPTIONS.has(m.text.toLowerCase()))
}
// ── Filter words ──────────────────────────────────────────────────────────────
const FILTER_PATTERN = /\b(she saw|he saw|she heard|he heard|she felt|he felt|she noticed|he noticed|she realized|he realized|she thought|he thought|she wondered|he wondered|she knew|he knew|she watched|he watched|she could see|he could see|she could hear|he could hear|she remembered|he remembered|she decided|he decided)\b/gi
function filterWordScore(text: string, wordCount: number): { score: number; issueCount: number } {
const matches = (text.match(FILTER_PATTERN) ?? []).length
return { score: rateScore(matches, wordCount, 8), issueCount: matches }
}
// ── Show / tell proxies ───────────────────────────────────────────────────────
const EMOTION_ADJECTIVES = 'angry|sad|happy|afraid|scared|nervous|anxious|excited|lonely|confused|surprised|disappointed|embarrassed|ashamed|jealous|tired|bored|furious|terrified|delighted|miserable|guilty|proud|content|relieved|frustrated|hopeful|desperate|bitter|disgusted|horrified|depressed|irritated|overwhelmed|resentful|heartbroken|ecstatic|joyful|melancholy|sorrowful|gloomy|cheerful|peaceful|calm|worried|tense|uneasy|weary'
const SHOW_TELL_PATTERN = new RegExp(`\\b(was|were|is|are)\\s+(very\\s+)?(${EMOTION_ADJECTIVES})\\b`, 'gi')
function showTellScore(text: string, wordCount: number): { score: number; issueCount: number } {
const matches = (text.match(SHOW_TELL_PATTERN) ?? []).length
return { score: rateScore(matches, wordCount, 10), issueCount: matches }
}
// ── Sentence rhythm ───────────────────────────────────────────────────────────
function sentenceRhythmScore(text: string): { score: number; issueCount: number } {
const sentences = splitSentences(text)
if (sentences.length < 3) return { score: 100, issueCount: 0 }
function sentenceRhythmAnalysis(text: string): { score: number; matches: PolishMatch[] } {
const sentences = splitSentencesWithPos(text)
if (sentences.length < 3) return { score: 100, matches: [] }
const lengths = sentences.map(s => s.split(/\s+/).length)
const lengths = sentences.map(s => s.text.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 cv = mean > 0 ? Math.sqrt(variance) / mean : 0
let runsCount = 0
const matches: PolishMatch[] = []
let runStart = 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++
if (runLen === 3) runStart = i - 2
if (runLen >= 3) {
const s = sentences[runStart]
const e = sentences[i]
const existing = matches.find(m => m.from === s.from)
if (existing) {
existing.to = e.to
existing.text = text.slice(s.from, e.to)
} else {
matches.push({ from: s.from, to: e.to, text: text.slice(s.from, e.to) })
}
}
} else {
runLen = 1
}
}
const score = Math.min(100, Math.round((cv / 0.6) * 100))
return { score, issueCount: runsCount }
return { score, matches }
}
// ── Paragraph rhythm ──────────────────────────────────────────────────────────
function paragraphRhythmScore(text: string): { score: number; issueCount: number } {
const paragraphs = text.split(/\n{2,}/).map(p => p.trim()).filter(p => p.length > 0)
if (paragraphs.length < 3) return { score: 100, issueCount: 0 }
function paragraphRhythmAnalysis(text: string): { score: number; matches: PolishMatch[] } {
const paras = splitParasWithPos(text)
if (paras.length < 3) return { score: 100, matches: [] }
const lengths = paragraphs.map(p => countWordsIn(p))
const lengths = paras.map(p => countWordsIn(p.text))
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 cv = mean > 0 ? Math.sqrt(variance) / mean : 0
// Count wall-of-text paragraphs (> 150 words) and tiny runs (≤ 5 words each)
const walls = lengths.filter(l => l > 150).length
const matches: PolishMatch[] = paras
.filter((_, i) => lengths[i] > 150)
.map(p => ({ from: p.from, to: p.to, text: p.text }))
const score = Math.min(100, Math.round((cv / 0.8) * 100))
return { score, issueCount: walls }
return { score, matches }
}
// ── Repeated sentence starters ────────────────────────────────────────────────
function repeatedStarterScore(text: string): { score: number; issueCount: number } {
const sentences = splitSentences(text)
if (sentences.length < 4) return { score: 100, issueCount: 0 }
const PRONOUNS = new Set(['he','she','i','they','it','we','you'])
const starters = sentences.map(s => s.split(/\s+/)[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? '')
function repeatedStarterAnalysis(text: string): { score: number; matches: PolishMatch[] } {
const sentences = splitSentencesWithPos(text)
if (sentences.length < 4) return { score: 100, matches: [] }
let runs = 0
const starters = sentences.map(s => s.text.split(/\s+/)[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? '')
const matches: PolishMatch[] = []
let runLen = 1
let runStart = 0
for (let i = 1; i < starters.length; i++) {
if (starters[i] === starters[i - 1] && starters[i] !== '') {
runLen++
if (runLen === 3) runs++
if (runLen === 3) runStart = i - 2
if (runLen >= 3) {
const s = sentences[runStart]
const e = sentences[i]
const existing = matches.find(m => m.from === s.from)
if (existing) { existing.to = e.to; existing.text = text.slice(s.from, e.to) }
else matches.push({ from: s.from, to: e.to, text: text.slice(s.from, e.to) })
}
} else {
runLen = 1
}
}
// Also penalise pronoun-heavy openings overall
const PRONOUNS = new Set(['he','she','i','they','it','we','you'])
const pronounStarts = starters.filter(w => PRONOUNS.has(w)).length
const pronounRatio = pronounStarts / starters.length
const runPenalty = Math.min(60, runs * 15)
const runPenalty = Math.min(60, matches.length * 15)
const pronounPenalty = Math.max(0, Math.round((pronounRatio - 0.4) / 0.4 * 40))
const score = Math.max(0, 100 - runPenalty - pronounPenalty)
return { score, issueCount: runs }
return { score, matches }
}
// ── Word variety ──────────────────────────────────────────────────────────────
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'])
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 }
function wordVarietyAnalysis(text: string): { score: number; matches: PolishMatch[] } {
const allWordMatches = [...text.matchAll(/\b[a-z']+\b/gi)]
if (allWordMatches.length < 20) return { score: 100, matches: [] }
const contentWords = words.filter(w => !SKIP_WORDS.has(w))
if (contentWords.length < 20) return { score: 100, issueCount: 0 }
const contentWordMatches = allWordMatches.filter(m => !SKIP_WORDS.has(m[0].toLowerCase()))
if (contentWordMatches.length < 20) return { score: 100, matches: [] }
const WINDOW = 100
const words = contentWordMatches.map(m => m[0].toLowerCase())
const ttrs: number[] = []
for (let i = 0; i + WINDOW <= contentWords.length; i += Math.floor(WINDOW / 2)) {
const win = contentWords.slice(i, i + WINDOW)
for (let i = 0; i + WINDOW <= words.length; i += Math.floor(WINDOW / 2)) {
const win = words.slice(i, i + WINDOW)
ttrs.push(new Set(win).size / win.length)
}
const avgTTR = ttrs.reduce((a, b) => a + b, 0) / ttrs.length
const avgTTR = ttrs.length > 0 ? ttrs.reduce((a, b) => a + b, 0) / ttrs.length : 1
const score = Math.min(100, Math.max(0, Math.round(((avgTTR - 0.4) / 0.3) * 100)))
let repeatedCount = 0
for (let i = 0; i + 50 <= contentWords.length; i += 25) {
const win = contentWords.slice(i, i + 50)
const counts = new Map<string, number>()
for (const w of win) counts.set(w, (counts.get(w) ?? 0) + 1)
for (const [, count] of counts) if (count >= 3) repeatedCount++
// Find words that appear 3+ times within any 50-word window; highlight duplicates
const flagged = new Set<number>() // indices into contentWordMatches
for (let i = 0; i + 50 <= words.length; i += 25) {
const win = words.slice(i, i + 50)
const counts = new Map<string, number[]>()
for (let j = 0; j < win.length; j++) {
const w = win[j]
if (!counts.has(w)) counts.set(w, [])
counts.get(w)!.push(i + j)
}
for (const [, indices] of counts) {
if (indices.length >= 3) indices.forEach(idx => flagged.add(idx))
}
}
const score = Math.min(100, Math.max(0, Math.round(((avgTTR - 0.4) / 0.3) * 100)))
return { score, issueCount: repeatedCount }
const matches: PolishMatch[] = [...flagged]
.sort((a, b) => a - b)
.map(idx => {
const m = contentWordMatches[idx]
return { from: m.index!, to: m.index! + m[0].length, text: m[0] }
})
return { score, matches }
}
// ── Clichés ───────────────────────────────────────────────────────────────────
@@ -187,88 +252,79 @@ const CLICHES = [
'face drained','colour drained','blood froze','heart stopped','chest tightened',
'lump in her throat','lump in his throat','pit of her stomach','pit of his stomach',
'at the end of the day','all hell broke loose','couldn\'t believe her eyes',
'couldn\'t believe his eyes','needle in a haystack','tip of the iceberg',
'dead of night','ray of hope','last but not least','speak of the devil',
'cold as ice','sharp as a knife','black as night','white as snow','red as blood',
'silence was deafening','deafening silence','elephant in the room',
'couldn\'t believe his eyes','dead of night','ray of hope','silence was deafening',
'deafening silence','elephant in the room','cold as ice','sharp as a knife',
'black as night','white as snow','red as blood',
]
const CLICHE_PATTERN = new RegExp(CLICHES.map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'gi')
function clicheScore(text: string, wordCount: number): { score: number; issueCount: number } {
const matches = (text.match(CLICHE_PATTERN) ?? []).length
return { score: rateScore(matches, wordCount, 6), issueCount: matches }
}
// ── Overused filler words ─────────────────────────────────────────────────────
const OVERUSED_PATTERN = /\b(just|suddenly|started to|began to|that|somehow|somehow|anyway|whatever|stuff|things|got|get)\b/gi
function overusedWordScore(text: string, wordCount: number): { score: number; issueCount: number } {
const matches = (text.match(OVERUSED_PATTERN) ?? []).length
return { score: rateScore(matches, wordCount, 25), issueCount: matches }
}
const OVERUSED_PATTERN = /\b(just|suddenly|started to|began to|somehow|anyway|stuff|things)\b/gi
// ── Dialogue 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 total = (text.match(SAID_BOOKISMS) ?? []).length + (text.match(ADV_TAG) ?? []).length
return { score: rateScore(total, wordCount, 8), issueCount: total }
}
// ── Main export ───────────────────────────────────────────────────────────────
export function computePolishScore(rawText: string): PolishScore {
const text = stripMarkdown(rawText)
const wordCount = countWordsIn(text)
const wordCount = countWordsIn(rawText)
const skip = wordCount <= 10
const dim = (label: string, score: number, issueCount: number): PolishDimension => ({ label, score, issueCount })
function dim(label: string, score: number, matches: PolishMatch[]): PolishDimension {
return { label, score, issueCount: matches.length, matches }
}
const passiveIssues = skip ? 0 : detectPassiveVoice(text).length
const weaselIssues = skip ? 0 : (text.match(WEASEL_PATTERN) ?? []).length
const passiveAnnotations = skip ? [] : detectPassiveVoice(rawText)
const passiveMatches: PolishMatch[] = passiveAnnotations
.filter(a => a.from !== undefined && a.to !== undefined)
.map(a => ({ from: a.from!, to: a.to!, text: a.matchedText ?? '' }))
const adverb = skip ? { score: 100, issueCount: 0 } : adverbDensityScore(text, wordCount)
const filter = skip ? { score: 100, issueCount: 0 } : filterWordScore(text, wordCount)
const showTell = skip ? { score: 100, issueCount: 0 } : showTellScore(text, wordCount)
const sRhythm = skip ? { score: 100, issueCount: 0 } : sentenceRhythmScore(text)
const pRhythm = skip ? { score: 100, issueCount: 0 } : paragraphRhythmScore(text)
const starters = skip ? { score: 100, issueCount: 0 } : repeatedStarterScore(text)
const variety = skip ? { score: 100, issueCount: 0 } : wordVarietyScore(text)
const cliche = skip ? { score: 100, issueCount: 0 } : clicheScore(text, wordCount)
const overused = skip ? { score: 100, issueCount: 0 } : overusedWordScore(text, wordCount)
const dialogue = skip ? { score: 100, issueCount: 0 } : dialogueTagScore(text, wordCount)
const weaselMatches = skip ? [] : regexMatches(rawText, WEASEL_PATTERN)
const adverbMatches_ = skip ? [] : adverbMatches(rawText)
const filterMatches = skip ? [] : regexMatches(rawText, FILTER_PATTERN)
const showTellMatches= skip ? [] : regexMatches(rawText, SHOW_TELL_PATTERN)
const sRhythm = skip ? { score: 100, matches: [] } : sentenceRhythmAnalysis(rawText)
const pRhythm = skip ? { score: 100, matches: [] } : paragraphRhythmAnalysis(rawText)
const starters = skip ? { score: 100, matches: [] } : repeatedStarterAnalysis(rawText)
const variety = skip ? { score: 100, matches: [] } : wordVarietyAnalysis(rawText)
const clicheMatches = skip ? [] : regexMatches(rawText, CLICHE_PATTERN)
const overusedMatches= skip ? [] : regexMatches(rawText, OVERUSED_PATTERN)
const dialogueMatches= skip ? [] : [
...regexMatches(rawText, SAID_BOOKISMS),
...regexMatches(rawText, ADV_TAG),
]
const passiveScore = rateScore(passiveMatches.length, wordCount, 12)
const weaselScore = rateScore(weaselMatches.length, wordCount, 10)
const adverbScore = rateScore(adverbMatches_.length, wordCount, 20)
const filterScore = rateScore(filterMatches.length, wordCount, 8)
const showTellScore = rateScore(showTellMatches.length, wordCount, 10)
const clicheScore = rateScore(clicheMatches.length, wordCount, 6)
const overusedScore = rateScore(overusedMatches.length, wordCount, 25)
const dialogueScore = rateScore(dialogueMatches.length, wordCount, 8)
const scores = [
rateScore(passiveIssues, wordCount, 12),
rateScore(weaselIssues, wordCount, 10),
adverb.score,
filter.score,
showTell.score,
sRhythm.score,
pRhythm.score,
starters.score,
variety.score,
cliche.score,
overused.score,
dialogue.score,
passiveScore, weaselScore, adverbScore, filterScore, showTellScore,
sRhythm.score, pRhythm.score, starters.score, variety.score,
clicheScore, overusedScore, dialogueScore,
]
const overall = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
return {
overall,
dimensions: {
passiveVoice: dim('Passive voice', rateScore(passiveIssues, wordCount, 12), passiveIssues),
weaselWords: dim('Weasel words', rateScore(weaselIssues, wordCount, 10), weaselIssues),
adverbDensity: dim('Adverb density', adverb.score, adverb.issueCount),
filterWords: dim('Filter words', filter.score, filter.issueCount),
showTell: dim('Show / tell', showTell.score, showTell.issueCount),
sentenceRhythm: dim('Sentence rhythm', sRhythm.score, sRhythm.issueCount),
paragraphRhythm: dim('Paragraph rhythm', pRhythm.score, pRhythm.issueCount),
repeatedStarters: dim('Sentence starters', starters.score, starters.issueCount),
wordVariety: dim('Word variety', variety.score, variety.issueCount),
cliches: dim('Clichés', cliche.score, cliche.issueCount),
overusedWords: dim('Filler words', overused.score, overused.issueCount),
dialogueTags: dim('Dialogue tags', dialogue.score, dialogue.issueCount),
passiveVoice: dim('Passive voice', passiveScore, passiveMatches),
weaselWords: dim('Weasel words', weaselScore, weaselMatches),
adverbDensity: dim('Adverb density', adverbScore, adverbMatches_),
filterWords: dim('Filter words', filterScore, filterMatches),
showTell: dim('Show / tell', showTellScore, showTellMatches),
sentenceRhythm: dim('Sentence rhythm', sRhythm.score, sRhythm.matches),
paragraphRhythm: dim('Paragraph rhythm', pRhythm.score, pRhythm.matches),
repeatedStarters: dim('Sentence starters',starters.score, starters.matches),
wordVariety: dim('Word variety', variety.score, variety.matches),
cliches: dim('Clichés', clicheScore, clicheMatches),
overusedWords: dim('Filler words', overusedScore, overusedMatches),
dialogueTags: dim('Dialogue tags', dialogueScore, dialogueMatches),
}
}
}

File diff suppressed because one or more lines are too long