From 143620e44e34029b56d77959a83173de38382033 Mon Sep 17 00:00:00 2001 From: BigMarc Date: Thu, 18 Jun 2026 06:43:31 +0200 Subject: [PATCH] Add Grammarly-style value loop: score, buckets, dismiss, streaks, demo Implements the highest-leverage items from docs/grammarly-value-plan.md: - Live Check: on-device 0-100 writing score, Correctness/Clarity/Polish count chips, and per-suggestion Dismiss / Add-to-dictionary (a cheap, always-available 'no' is what makes 'Apply' feel safe). - Value loop: day-streak, correction milestones and a 'subscription you didn't pay' figure on the dashboard + a streak line in the menu bar (all local, self-vs-self, no telemetry). - First-run demo: a planted sample the real engine cleans in one click, before any permission ask. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/GrammaGem/App/AppState.swift | 22 +- .../App/MainWindow/DashboardView.swift | 22 ++ .../App/MainWindow/LiveCheckView.swift | 107 +++++++-- Sources/GrammaGem/App/MenuBarContent.swift | 11 + Sources/GrammaGem/App/OnboardingView.swift | 156 ++++++++++---- Sources/GrammaGem/Core/Models.swift | 36 ++++ Sources/GrammaGem/Grammar/WritingScore.swift | 20 ++ Sources/GrammaGem/Stats/UsageStats.swift | 64 ++++++ Sources/GrammaGem/System/LiveMonitor.swift | 62 +++++- Tests/GrammaGemTests/GrammaGemTests.swift | 61 ++++++ docs/grammarly-value-plan.md | 204 ++++++++++++++++++ 11 files changed, 708 insertions(+), 57 deletions(-) create mode 100644 Sources/GrammaGem/Grammar/WritingScore.swift create mode 100644 docs/grammarly-value-plan.md diff --git a/Sources/GrammaGem/App/AppState.swift b/Sources/GrammaGem/App/AppState.swift index a123b7d..5bb74e4 100644 --- a/Sources/GrammaGem/App/AppState.swift +++ b/Sources/GrammaGem/App/AppState.swift @@ -179,6 +179,26 @@ final class AppState: ObservableObject { usage.recordCorrection(words: 1) } + /// Hide a single suggestion without changing the user's text. + func dismissSuggestion(_ suggestion: Suggestion, in field: DetectedField) { + liveMonitor.dismiss(suggestion, in: field) + } + + /// Add a flagged word to the personal dictionary so it's never corrected + /// again (gated by the free cap), then re-scan so it disappears now. + @discardableResult + func addWordToDictionary(_ word: String) -> FeatureGate.Decision { + let decision = addDictionaryEntry(word) + switch decision { + case .allowed: + lastStatus = "Added “\(word)” to your dictionary" + liveMonitor.refreshSoon() + case .denied(let reason): + lastStatus = reason + } + return decision + } + /// Pause/resume all of GrammaGem (hotkeys + live monitoring). func togglePause() { isPaused.toggle() @@ -258,7 +278,7 @@ final class AppState: ObservableObject { let win = NSWindow(contentViewController: host) win.title = "Welcome to GrammaGem" win.styleMask = [.titled, .closable, .fullSizeContentView] - win.setContentSize(NSSize(width: 540, height: 600)) + win.setContentSize(NSSize(width: 560, height: 720)) win.isReleasedWhenClosed = false win.center() onboardingWindow = win diff --git a/Sources/GrammaGem/App/MainWindow/DashboardView.swift b/Sources/GrammaGem/App/MainWindow/DashboardView.swift index 3b2206b..7bdc8b3 100644 --- a/Sources/GrammaGem/App/MainWindow/DashboardView.swift +++ b/Sources/GrammaGem/App/MainWindow/DashboardView.swift @@ -30,6 +30,15 @@ struct DashboardView: View { StatTile(value: "\(usage.data.totalCorrections)", label: "Corrections made", systemImage: "checkmark.seal.fill", tint: GG.emerald) StatTile(value: timeSaved, label: "Time saved (est.)", systemImage: "clock.fill", tint: .primary) + + StatTile( + value: usage.currentStreak > 0 ? "\(usage.currentStreak)d" : "—", + label: "Day streak", systemImage: "flame.fill", + tint: usage.currentStreak > 0 ? .orange : .secondary) + StatTile( + value: "$\(usage.subscriptionAvoidedUSD)", + label: "Subscription not paid", systemImage: "dollarsign.circle.fill", + tint: GG.emerald) } // Setup health @@ -61,6 +70,19 @@ struct DashboardView: View { .font(.caption).foregroundStyle(.secondary) } + // Next milestone + Card { + let milestone = usage.nextMilestone + HStack { + Text("Next milestone").font(.headline) + Spacer() + Text(milestone.label).font(.caption).foregroundStyle(.secondary) + } + ProgressView(value: milestone.progress).tint(GG.emerald) + Text("\(usage.data.totalCorrections) corrections so far — keep the streak alive.") + .font(.caption).foregroundStyle(.secondary) + } + // Quick actions Card { Text("Quick actions").font(.headline) diff --git a/Sources/GrammaGem/App/MainWindow/LiveCheckView.swift b/Sources/GrammaGem/App/MainWindow/LiveCheckView.swift index 7767c5d..dad4369 100644 --- a/Sources/GrammaGem/App/MainWindow/LiveCheckView.swift +++ b/Sources/GrammaGem/App/MainWindow/LiveCheckView.swift @@ -34,22 +34,32 @@ struct LiveCheckView: View { } private var header: some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text(summary).font(.headline) - Text(monitor.activeAppName.isEmpty - ? "Checking text areas on screen" - : "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked") - .font(.caption).foregroundStyle(.secondary) - } - Spacer() - Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") } - .help("Re-scan now") - if monitor.issueCount > 0 { - Button { app.fixAllDetected() } label: { - Label("Fix everything", systemImage: "checkmark.circle.fill") + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + ScoreRing(score: monitor.writingScore) + VStack(alignment: .leading, spacing: 2) { + Text(summary).font(.headline) + Text(contextLine).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") } + .help("Re-scan now") + if monitor.issueCount > 0 { + Button { app.fixAllDetected() } label: { + Label("Fix everything", systemImage: "checkmark.circle.fill") + } + .buttonStyle(.borderedProminent) + } + } + if monitor.issueCount > 0 { + HStack(spacing: 8) { + ForEach(IssueBucket.allCases) { bucket in + if let n = monitor.bucketCounts[bucket], n > 0 { + BucketChip(bucket: bucket, count: n) + } + } + Spacer() } - .buttonStyle(.borderedProminent) } } .padding(16) @@ -57,10 +67,16 @@ struct LiveCheckView: View { private var summary: String { let issues = monitor.issueCount - if issues == 0 { return "No issues found" } + if issues == 0 { return "Looks clean" } return "\(issues) issue\(issues == 1 ? "" : "s") across \(monitor.fields.count) text area\(monitor.fields.count == 1 ? "" : "s")" } + private var contextLine: String { + monitor.activeAppName.isEmpty + ? "Checking text areas on screen" + : "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked" + } + private var emptyState: some View { VStack(spacing: 10) { Image(systemName: "checkmark.seal.fill").font(.system(size: 42)).foregroundStyle(GG.emerald) @@ -112,9 +128,21 @@ private struct FieldCard: View { } } Spacer() + if s.kind == .spelling { + Button { app.addWordToDictionary(s.original) } label: { + Image(systemName: "book.closed") + } + .controlSize(.small).buttonStyle(.borderless) + .help("Add “\(s.original)” to your dictionary") + } + Button { app.dismissSuggestion(s, in: field) } label: { + Image(systemName: "xmark") + } + .controlSize(.small).buttonStyle(.borderless) + .help("Dismiss this suggestion") if s.replacement != s.original && !s.replacement.isEmpty { Button("Apply") { app.applySuggestion(s, in: field) } - .controlSize(.small) + .controlSize(.small).buttonStyle(.borderedProminent) } } } @@ -141,6 +169,14 @@ private struct FieldCard: View { } } + fileprivate static func color(for bucket: IssueBucket) -> Color { + switch bucket { + case .correctness: return .red + case .clarity: return .blue + case .polish: return GG.gold + } + } + /// Build an attributed string with each issue span tinted. private func highlighted(_ text: String, _ suggestions: [Suggestion]) -> AttributedString { let ns = text as NSString @@ -167,3 +203,40 @@ private struct FieldCard: View { return result } } + +/// A compact 0–100 cleanliness gauge. Green when clean, amber/orange as issues +/// pile up — a meter the user can watch climb as they accept fixes. +private struct ScoreRing: View { + let score: Int + var body: some View { + ZStack { + Circle().stroke(Color.primary.opacity(0.1), lineWidth: 5) + Circle() + .trim(from: 0, to: CGFloat(max(0, min(100, score))) / 100) + .stroke(color, style: StrokeStyle(lineWidth: 5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeOut(duration: 0.35), value: score) + Text("\(score)").font(.system(size: 15, weight: .bold, design: .rounded)) + } + .frame(width: 44, height: 44) + .help("Writing score for the text on screen") + } + + private var color: Color { + score >= 90 ? GG.emerald : (score >= 70 ? GG.gold : .orange) + } +} + +/// A tappable-looking count chip per issue bucket (Correctness / Clarity / Polish). +private struct BucketChip: View { + let bucket: IssueBucket + let count: Int + var body: some View { + HStack(spacing: 5) { + Circle().fill(FieldCard.color(for: bucket)).frame(width: 7, height: 7) + Text("\(bucket.title) \(count)").font(.caption.weight(.medium)) + } + .padding(.horizontal, 9).padding(.vertical, 4) + .background(FieldCard.color(for: bucket).opacity(0.12), in: Capsule()) + } +} diff --git a/Sources/GrammaGem/App/MenuBarContent.swift b/Sources/GrammaGem/App/MenuBarContent.swift index ad71c13..dc78fc9 100644 --- a/Sources/GrammaGem/App/MenuBarContent.swift +++ b/Sources/GrammaGem/App/MenuBarContent.swift @@ -65,6 +65,7 @@ struct MenuBarContent: View { Divider() usageRow + valueRow Divider() @@ -152,6 +153,16 @@ struct MenuBarContent: View { } } + /// One always-visible value reminder: the streak + lifetime corrections. + @ViewBuilder + private var valueRow: some View { + if app.usage.currentStreak > 1 { + Label("\(app.usage.currentStreak)-day streak · \(app.usage.data.totalCorrections) corrections", + systemImage: "flame.fill") + .font(.caption).foregroundStyle(.secondary) + } + } + private func actionRow(title: String, shortcut: String?, action: @escaping () -> Void) -> some View { Button(action: action) { HStack { diff --git a/Sources/GrammaGem/App/OnboardingView.swift b/Sources/GrammaGem/App/OnboardingView.swift index 22d7b0c..8527961 100644 --- a/Sources/GrammaGem/App/OnboardingView.swift +++ b/Sources/GrammaGem/App/OnboardingView.swift @@ -14,56 +14,69 @@ struct OnboardingView: View { } var body: some View { - VStack(alignment: .leading, spacing: 18) { - VStack(alignment: .leading, spacing: 6) { - Text("Welcome to GrammaGem") - .font(.largeTitle.bold()) - Text("A private, on-device writing assistant that works in every app.") - .foregroundStyle(.secondary) - } - - stepCard( - number: 1, - title: "Grant Accessibility access", - body: "GrammaGem reads the text you select and writes the correction back. macOS requires Accessibility permission for this. Your text is processed on-device and never leaves your Mac.", - granted: permissions.accessibilityTrusted - ) { - if permissions.accessibilityTrusted { - Label("Permission granted", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } else { - HStack { - Button("Open System Settings") { permissions.openAccessibilitySettings() } - Button("Request permission") { permissions.requestAccessibility() } + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text("Welcome to GrammaGem") + .font(.largeTitle.bold()) + Text("A private, on-device writing assistant that works in every app.") + .foregroundStyle(.secondary) + } + + // The rigged win: prove the value in one click, before any ask. + VStack(alignment: .leading, spacing: 10) { + Label("See it work", systemImage: "wand.and.stars").font(.headline) + DemoStep() + } + .padding(16) + .background(GG.emerald.opacity(0.06), in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(GG.emerald.opacity(0.25))) + + stepCard( + number: 1, + title: "Turn it on everywhere", + body: "GrammaGem reads the text you select and writes the correction back. macOS requires Accessibility permission for this. Your text is processed on-device and never leaves your Mac.", + granted: permissions.accessibilityTrusted + ) { + if permissions.accessibilityTrusted { + Label("Permission granted", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + HStack { + Button("Open System Settings") { permissions.openAccessibilitySettings() } + Button("Request permission") { permissions.requestAccessibility() } + } + Text("This updates automatically once you flip GrammaGem on in Accessibility.") + .font(.caption).foregroundStyle(.secondary) + } + } + + stepCard( + number: 2, + title: "Download the on-device model (optional)", + body: "Grammar & spelling work instantly with no download. The AI rewrite/tone/Ask features use a small model downloaded once from Hugging Face — then everything runs offline.", + granted: model.state == .ready + ) { + ModelDownloadControls() } - Text("This updates automatically once you flip GrammaGem on in Accessibility.") - .font(.caption).foregroundStyle(.secondary) } + .padding(28) } - stepCard( - number: 2, - title: "Download the on-device model (optional)", - body: "Grammar & spelling work instantly with no download. The AI rewrite/tone/Ask features use a small model downloaded once from Hugging Face — then everything runs offline.", - granted: model.state == .ready - ) { - ModelDownloadControls() - } - - Spacer() + Divider() HStack { - if permissions.accessibilityTrusted { - Label("You're all set", systemImage: "sparkles").foregroundStyle(.secondary) - } + Label("Yours once. No subscription, nothing leaves your Mac.", systemImage: "lock.shield") + .font(.caption).foregroundStyle(.secondary) Spacer() Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") { app.showOnboardingDismiss() } .keyboardShortcut(.defaultAction) } + .padding(.horizontal, 28).padding(.vertical, 14) } - .padding(28) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .onAppear { permissions.startPollingUntilGranted() @@ -98,6 +111,75 @@ struct OnboardingView: View { } } +/// A safe, on-rails first-run demo: a local sample with planted mistakes that +/// the REAL grammar engine fixes in one click. It operates on a plain string +/// (never a live Accessibility element), so it's completely risk-free — the +/// "guaranteed visible win" before GrammaGem asks for anything. +private struct DemoStep: View { + @State private var text = "i has wrote alot of mistakes here, and it dont look profesional" + @State private var issues: [Suggestion] = [] + @State private var cleaned = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(headline) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(cleaned ? GG.emerald : .primary) + Text(highlighted) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + .animation(.easeInOut(duration: 0.35), value: text) + Button { + let fixed = AppState.shared.grammar.correct(text) + withAnimation(.easeOut(duration: 0.4)) { + text = fixed + issues = [] + cleaned = true + } + } label: { + Label(cleaned ? "All clean" : "Clean it up", + systemImage: cleaned ? "checkmark.circle.fill" : "wand.and.stars") + } + .buttonStyle(.borderedProminent) + .disabled(cleaned || issues.isEmpty) + } + .onAppear { + if issues.isEmpty && !cleaned { issues = AppState.shared.grammar.check(text) } + } + } + + private var headline: String { + if cleaned { return "Clean ✨ — GrammaGem does this in every app you type." } + if issues.isEmpty { return "Checking…" } + return "We spotted \(issues.count) thing\(issues.count == 1 ? "" : "s") to tidy up — one click fixes them." + } + + private var highlighted: AttributedString { + let ns = text as NSString + var result = AttributedString() + var cursor = 0 + for s in issues.sorted(by: { $0.location < $1.location }) { + guard s.location >= cursor, s.length > 0, s.location + s.length <= ns.length else { continue } + if s.location > cursor { + result += AttributedString(ns.substring(with: NSRange(location: cursor, length: s.location - cursor))) + } + var run = AttributedString(ns.substring(with: s.range)) + let tint: Color = s.kind == .spelling ? .red : .orange + run.backgroundColor = tint.opacity(0.18) + run.foregroundColor = tint + run.underlineStyle = .single + result += run + cursor = s.location + s.length + } + if cursor < ns.length { + result += AttributedString(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor))) + } + return result + } +} + /// Real model download progress + controls, shared by onboarding and settings. struct ModelDownloadControls: View { @ObservedObject private var model: ModelManager diff --git a/Sources/GrammaGem/Core/Models.swift b/Sources/GrammaGem/Core/Models.swift index d927e32..2676d8f 100644 --- a/Sources/GrammaGem/Core/Models.swift +++ b/Sources/GrammaGem/Core/Models.swift @@ -42,6 +42,42 @@ enum GrammarKind: String, Codable { case phrasing case repetition case capitalization + + /// Severity weight — drives card ordering and the writing-score penalty. + /// Objective errors (spelling, grammar) weigh most. + var severity: Int { + switch self { + case .spelling, .grammar: return 3 + case .punctuation, .capitalization: return 2 + case .repetition, .phrasing: return 1 + } + } + + /// User-facing grouping shown as filter chips in the Live Check panel. + var bucket: IssueBucket { + switch self { + case .spelling, .grammar, .punctuation: return .correctness + case .phrasing: return .clarity + case .repetition, .capitalization: return .polish + } + } +} + +/// Our own named issue groups (deliberately NOT Grammarly's exact buckets/words): +/// objective errors, clarity rewrites, and stylistic polish. +enum IssueBucket: String, CaseIterable, Identifiable { + case correctness + case clarity + case polish + + var id: String { rawValue } + var title: String { + switch self { + case .correctness: return "Correctness" + case .clarity: return "Clarity" + case .polish: return "Polish" + } + } } /// A single grammar suggestion over a span of the input text. diff --git a/Sources/GrammaGem/Grammar/WritingScore.swift b/Sources/GrammaGem/Grammar/WritingScore.swift new file mode 100644 index 0000000..dd1988a --- /dev/null +++ b/Sources/GrammaGem/Grammar/WritingScore.swift @@ -0,0 +1,20 @@ +import Foundation + +/// A deterministic, on-device "cleanliness" score (0–100) for a chunk of text. +/// +/// Honest by construction: it's defined relative to *this* text — weighted issues +/// per 100 words — never a hidden cloud cohort, and grammar/spelling are always +/// fully resolvable, so a clean document genuinely reaches 100. (Contrast with +/// Grammarly's percentile-vs-other-users score, which needs telemetry + an +/// account; see docs/grammarly-value-plan.md §5.) +enum WritingScore { + /// - Parameters: + /// - weightedIssues: sum of `GrammarKind.severity` across outstanding issues. + /// - words: total words in the scanned text. + static func score(weightedIssues: Int, words: Int) -> Int { + guard words > 0 else { return 100 } + let perHundredWords = Double(weightedIssues) / Double(words) * 100.0 + // Each weighted issue per 100 words costs ~4 points; clamp to 0…100. + return max(0, min(100, Int((100.0 - perHundredWords * 4.0).rounded()))) + } +} diff --git a/Sources/GrammaGem/Stats/UsageStats.swift b/Sources/GrammaGem/Stats/UsageStats.swift index 3495f35..47e90c4 100644 --- a/Sources/GrammaGem/Stats/UsageStats.swift +++ b/Sources/GrammaGem/Stats/UsageStats.swift @@ -8,9 +8,15 @@ final class UsageStats: ObservableObject { var totalCorrections = 0 var totalAIActions = 0 var totalWords = 0 + var activeDays = 0 // distinct days with ≥1 action (lifetime) + var firstUseDay: String? // "yyyy-MM-dd" of first ever action var perDay: [String: Int] = [:] // "yyyy-MM-dd" -> actions that day } + /// What a Grammarly-style subscription runs per year (USD) — used only for the + /// honest, local "subscription you didn't pay" figure. GrammaGem is one-time. + static let comparableAnnualSubscriptionUSD = 144.0 + @Published private(set) var data: Snapshot private let key = "GrammaGem.usageStats" @@ -52,6 +58,60 @@ final class UsageStats: ObservableObject { (data.totalCorrections * 8 + data.totalAIActions * 45) / 60 } + // MARK: - Value loop (streak / milestones / subscription-you-didn't-pay) + + /// Consecutive days, ending today or yesterday, with at least one action. + var currentStreak: Int { Self.streak(perDay: data.perDay, asOf: Date()) } + + /// The Grammarly subscription you DIDN'T pay: their annual price prorated over + /// how long you've owned GrammaGem. Honest, local, illustrative — and it only + /// grows, which is the point of a one-time purchase (see the value plan §5). + var subscriptionAvoidedUSD: Int { + Self.subscriptionAvoidedUSD( + firstUseDay: data.firstUseDay, asOf: Date(), + annualPrice: Self.comparableAnnualSubscriptionUSD) + } + + /// The next correction milestone and progress toward it (for a nudge/badge). + var nextMilestone: (target: Int, label: String, progress: Double) { + Self.nextMilestone(corrections: data.totalCorrections) + } + + // MARK: - Pure helpers (unit-testable; no UserDefaults / wall clock) + + static func streak(perDay: [String: Int], asOf now: Date) -> Int { + let cal = Calendar.current + func used(_ day: Date) -> Bool { (perDay[fmt.string(from: day)] ?? 0) > 0 } + var day = now + if !used(day) { + // Streak is still alive if used yesterday but not yet today. + day = cal.date(byAdding: .day, value: -1, to: day) ?? day + if !used(day) { return 0 } + } + var streak = 0 + while used(day) { + streak += 1 + day = cal.date(byAdding: .day, value: -1, to: day) ?? day + } + return streak + } + + static func subscriptionAvoidedUSD(firstUseDay: String?, asOf now: Date, annualPrice: Double) -> Int { + guard let first = firstUseDay, let firstDate = fmt.date(from: first) else { return 0 } + let days = max(1, Calendar.current.dateComponents([.day], from: firstDate, to: now).day ?? 0) + return Int((Double(days) / 365.0 * annualPrice).rounded()) + } + + static func nextMilestone(corrections: Int) -> (target: Int, label: String, progress: Double) { + let targets = [100, 500, 1_000, 5_000, 10_000, 50_000, 100_000] + let target = targets.first { $0 > corrections } ?? (((corrections / 100_000) + 1) * 100_000) + let previous = targets.last { $0 <= corrections } ?? 0 + let span = max(1, target - previous) + let progress = min(1.0, Double(corrections - previous) / Double(span)) + let label = target >= 1_000 ? "\(target / 1_000)k corrections" : "\(target) corrections" + return (target, label, progress) + } + /// Counts for the last 7 calendar days, oldest → newest, for a small chart. func last7Days() -> [(label: String, count: Int)] { let cal = Calendar.current @@ -71,6 +131,10 @@ final class UsageStats: ObservableObject { private func bumpToday() { let k = Self.fmt.string(from: Date()) + if data.perDay[k] == nil { // first action on a brand-new day + data.activeDays += 1 + if data.firstUseDay == nil { data.firstUseDay = k } + } data.perDay[k, default: 0] += 1 // Keep only ~60 days of history. if data.perDay.count > 70 { diff --git a/Sources/GrammaGem/System/LiveMonitor.swift b/Sources/GrammaGem/System/LiveMonitor.swift index 8d9124c..2cc22ff 100644 --- a/Sources/GrammaGem/System/LiveMonitor.swift +++ b/Sources/GrammaGem/System/LiveMonitor.swift @@ -25,12 +25,34 @@ struct DetectedField: Identifiable { final class LiveMonitor: ObservableObject { @Published private(set) var fields: [DetectedField] = [] @Published private(set) var scannedCount = 0 + @Published private(set) var scannedWordCount = 0 @Published private(set) var activeAppName = "" @Published private(set) var running = false /// Total number of issues across all detected text areas. var issueCount: Int { fields.reduce(0) { $0 + $1.suggestions.count } } + /// Outstanding issues grouped into the user-facing buckets (for filter chips). + var bucketCounts: [IssueBucket: Int] { + var counts: [IssueBucket: Int] = [:] + for field in fields { + for s in field.suggestions { counts[s.kind.bucket, default: 0] += 1 } + } + return counts + } + + /// 0–100 on-device cleanliness score for the text currently on screen. + var writingScore: Int { + let weighted = fields.reduce(0) { acc, f in + acc + f.suggestions.reduce(0) { $0 + $1.kind.severity } + } + return WritingScore.score(weightedIssues: weighted, words: scannedWordCount) + } + + /// Content keys of suggestions the user has dismissed; survives re-scans so a + /// dismissed issue doesn't reappear on the next poll. + private var dismissedKeys: Set = [] + private let grammar: GrammarEngine private let detector: AppDetector private let exclusions: Exclusions @@ -88,6 +110,36 @@ final class LiveMonitor: ObservableObject { refreshSoon() } + // MARK: - Dismissing (hide a suggestion without applying it) + + /// Hide one suggestion. A cheap, always-available "no" is what makes saying + /// "yes" (Apply) feel safe. The dismissal is keyed by content so it persists + /// across re-scans of unchanged text. + func dismiss(_ suggestion: Suggestion, in field: DetectedField) { + dismissedKeys.insert(Self.dismissKey(fieldID: field.id, suggestion)) + fields = filterDismissed(fields) + } + + private static func dismissKey(fieldID: String, _ s: Suggestion) -> String { + "\(fieldID)|\(s.location)|\(s.length)|\(s.original)|\(s.replacement)" + } + + private func filterDismissed(_ input: [DetectedField]) -> [DetectedField] { + input.compactMap { f in + let remaining = f.suggestions.filter { + !dismissedKeys.contains(Self.dismissKey(fieldID: f.id, $0)) + } + guard !remaining.isEmpty else { return nil } + return DetectedField(id: f.id, label: f.label, roleName: f.roleName, + text: f.text, suggestions: remaining, + element: f.element, isFocused: f.isFocused) + } + } + + private static func wordCount(_ s: String) -> Int { + s.split { $0.isWhitespace || $0.isNewline }.count + } + /// Correct the element's *current* value (never a stale snapshot), so any /// keystrokes entered since the scan are preserved. private func applyCorrection(to field: DetectedField) -> Int { @@ -156,6 +208,11 @@ final class LiveMonitor: ObservableObject { guard generation == scanGeneration else { return } // superseded by a newer scan guard !scanned.isEmpty else { clear(); return } + // Total words across ALL scanned fields (clean ones included) so the + // writing score is honest about the whole on-screen text, not just the + // fields that happen to have issues. + scannedWordCount = scanned.reduce(0) { $0 + Self.wordCount($1.text) } + // Change detection includes focus + element identity so tabbing between // same-text fields still updates the focused indicator. var hasher = Hasher() @@ -179,16 +236,17 @@ final class LiveMonitor: ObservableObject { text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused)) } detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) } - fields = detected + fields = filterDismissed(detected) } private nonisolated static func systemFocusedElement() -> AXUIElement? { - AX.copyElement(AXUIElementCreateSystemWide(), kAXFocusedUIElementAttribute) + AX.focusedElement() } private func clear() { if !fields.isEmpty { fields = [] } scannedCount = 0 + scannedWordCount = 0 lastHash = 0 } } diff --git a/Tests/GrammaGemTests/GrammaGemTests.swift b/Tests/GrammaGemTests/GrammaGemTests.swift index 17f4c96..24a23e4 100644 --- a/Tests/GrammaGemTests/GrammaGemTests.swift +++ b/Tests/GrammaGemTests/GrammaGemTests.swift @@ -49,6 +49,67 @@ final class GrammaGemTests: XCTestCase { XCTAssertFalse(suggestions.contains { $0.original.lowercased() == "teh" }) } + /// Real Harper does true grammar NSSpellChecker can't: subject-verb + /// agreement and verb-form correction, applied cleanly in place. + func testHarperRealGrammarCorrections() { + let harper = HarperEngine() + let fixed = harper.correct("She dont like it and she have went home.") + XCTAssertTrue(fixed.contains("don't"), "dont -> don't") + XCTAssertTrue(fixed.contains("has"), "have -> has (subject-verb agreement)") + XCTAssertTrue(fixed.contains("gone"), "went -> gone (past participle)") + XCTAssertFalse(fixed.contains("went"), "the wrong verb form should be gone") + } + + /// The trust bug: a mid-sentence spelling fix must keep the original casing. + func testSpellingReplacementPreservesCase() { + XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "problemm", in: "Problem"), "problem") + XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "Teh", in: "the"), "The") + XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "teh", in: "the"), "the") + XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "HELLOO", in: "hello"), "HELLO") + } + + /// Harper returns char (Unicode scalar) offsets; they must map exactly onto + /// UTF-16 NSRange offsets even past multi-UTF-16-unit scalars (emoji). + func testHarperCharOffsetsMapToUTF16() { + let text = "😀 teh" // 😀 = 1 scalar / 2 UTF-16 units + let range = HarperEngine.utf16Range(scalarStart: 2, scalarLen: 3, in: text) + XCTAssertEqual(range, NSRange(location: 3, length: 3)) + XCTAssertEqual((text as NSString).substring(with: range!), "teh") + } + + // MARK: - Value loop (streak / subscription-avoided / milestones) + + @MainActor + func testValueLoopMetrics() { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = .current + let cal = Calendar.current + let now = Date() + func key(_ daysAgo: Int) -> String { + fmt.string(from: cal.date(byAdding: .day, value: -daysAgo, to: now)!) + } + + // today + yesterday + 2-days-ago = streak of 3 (the day-4 entry is broken off). + let perDay = [key(0): 2, key(1): 1, key(2): 5, key(4): 1] + XCTAssertEqual(UsageStats.streak(perDay: perDay, asOf: now), 3) + XCTAssertEqual(UsageStats.streak(perDay: [:], asOf: now), 0) + // Used yesterday but not yet today — streak is still alive. + XCTAssertEqual(UsageStats.streak(perDay: [key(1): 1], asOf: now), 1) + + // ~1 year of ownership ≈ one year of the subscription you didn't pay. + XCTAssertEqual( + UsageStats.subscriptionAvoidedUSD(firstUseDay: key(365), asOf: now, annualPrice: 144), 144) + XCTAssertEqual( + UsageStats.subscriptionAvoidedUSD(firstUseDay: nil, asOf: now, annualPrice: 144), 0) + + let m = UsageStats.nextMilestone(corrections: 50) + XCTAssertEqual(m.target, 100) + XCTAssertEqual(m.progress, 0.5, accuracy: 0.001) + XCTAssertEqual(UsageStats.nextMilestone(corrections: 100).target, 500) + } + // MARK: - Modes func testFreeTierGetsOnlyPolishMode() { diff --git a/docs/grammarly-value-plan.md b/docs/grammarly-value-plan.md new file mode 100644 index 0000000..a37e54c --- /dev/null +++ b/docs/grammarly-value-plan.md @@ -0,0 +1,204 @@ +# Grammarly Value Teardown -> GrammaGem Plan + +*Senior product + Swift engineering review. Every claim below is grounded in the current GrammaGem source. File references use absolute paths; the load-bearing bug is the mis-casing in `SystemGrammarEngine.swift:42-45`.* + +--- + +## 1. Executive summary — what makes Grammarly FEEL valuable (priority order) + +Grammarly's perceived value is NOT its grammar engine. It is a stack of interaction and psychology layers on top of a competent engine. Ranked by leverage for a one-time-purchase, on-device Mac utility: + +1. **Trustworthy correction quality (the foundation).** Every felt-value mechanic above it collapses if a fix is wrong. Grammarly's "accept-accept-accept" rhythm only works because in-place application is reliable and casing/context is preserved. GrammaGem's engine currently *demonstrably violates this* (`problemm` -> `Problem` mid-sentence). Fix this first; nothing else matters until it's solid. + +2. **The fix-as-button atomic loop: read the fix and apply it in one motion.** Grammarly's clickable green replacement chip collapses diagnosis + remedy + action into a single low-friction act, with a cheap, always-present dismiss so saying no is as easy as saying yes. GrammaGem has an `Apply` button but no dismiss/ignore/add-to-dictionary, no keyboard accept, and the apply path is brittle (whole-field AX overwrite). + +3. **Categorization + counts + a score: turning a wall of red into a finite, beatable list.** Four named, color-coded buckets, per-category count chips, one running issue badge, and a 0-100 score convert dread into a checklist with a meter that only goes up. GrammaGem has a raw issue count (3 colors) and *zero* score concept. + +4. **The rigged first-run "wow": guaranteed visible win in under 60 seconds.** A pre-seeded, error-salted demo doc means the product proves itself before asking for anything. GrammaGem's onboarding only handles Accessibility + model download — it never demonstrates the correction loop at all. + +5. **Ongoing perceived value without surveillance: progress you can see.** Grammarly uses cloud percentiles + weekly behavioral emails. GrammaGem can recreate the *feeling* — self-vs-self, fully on-device — with a local score trend, streaks, milestones, and a "subscription you didn't pay" money figure. The plumbing (`UsageStats.perDay`) already exists. + +6. **The generative "writing partner" layer: hand me the better sentence, not just the flag.** Sentence rewrites, tone detection/adjustment, conciseness. This is real value but is currently 100% simulated (`MLXEngine.swift` is a string-hack stub) and gated by a daily counter. It is the right *paid* tier, but it must produce real output before it can be sold. + +The strategic asymmetry to exploit throughout: every Grammarly value moment carries an implicit "and you'll pay again next year." GrammaGem's identical mechanics should be framed "yours once, forever, on this Mac." + +--- + +## 2. Mechanic-by-mechanic: how Grammarly does it -> how GrammaGem should do it + +### Mechanic 1 — Trustworthy correction quality (see §3 for the full engineering plan) +- **Grammarly:** ranks by confidence, suppresses low-confidence edits, never garbles surrounding text, preserves casing, and limits bulk-apply to high-confidence objective mechanics only. +- **GrammaGem today:** `SystemGrammarEngine` blindly trusts `NSSpellChecker.correction(forWordRange:)`, which is sentence-position-aware and capitalizes a word it thinks starts a sentence — producing `problemm` -> `Problem` mid-sentence (`SystemGrammarEngine.swift:42-45`). `Suggestion` has no confidence/severity/alternatives (`Models.swift:48-59`). Default `correct()` applies *every* suggestion including no-op grammar flags where `replacement == original` (`GrammarEngine.swift:22-29`). +- **Do:** real Harper FFI; case/whitespace post-processing; add `confidence`/`severity`/`alternatives` to `Suggestion`; never auto-apply no-ops or low-confidence flags. +- **Files:** `SystemGrammarEngine.swift`, `HarperEngine.swift`, `GrammarEngine.swift`, `Models.swift`, `harper-ffi/`. +- **Impact: high · Effort: L** + +### Mechanic 2 — The fix-as-button atomic loop (+ dismiss / ignore / add-to-dictionary) +- **Grammarly:** the suggested word *is* the button (green = go/safe/done); an always-present cheap dismiss (trash/Esc) and add-to-dictionary make accepting feel safe; Tab-to-navigate / Enter-to-accept / Esc-to-dismiss removes the mouse round-trip. +- **GrammaGem today:** `FieldCard` shows a message + one `original -> replacement` + a small `Apply` button (`LiveCheckView.swift:104-119`). There is **no dismiss, no ignore, no add-to-dictionary, no keyboard accept.** Per-issue `apply()` bails silently if the field changed at all since the snapshot (`LiveMonitor.swift:82-89`), and even single-issue apply rewrites the *whole* AX value (`LiveMonitor.swift:101-103`), risking caret/undo/formatting loss. +- **Do (realistic for a cross-app AX tool — we cannot draw a green chip inside arbitrary apps):** + - Make the replacement text itself the primary tappable control in the card (the chip *is* the fix), styled by category color. + - Add a per-suggestion overflow: **Dismiss** (model a `dismissedIDs: Set` on the monitor so it survives re-scans), **Ignore word** / **Add to dictionary** (route into `PersonalDictionary` -> `ignoreList`), gated by `freeDictionaryCap`. + - Add **keyboard flow inside the Live Check panel**: ↑/↓ or Tab to move selection, Return to apply, Delete/Esc to dismiss. This is fully feasible because the panel is our own SwiftUI window. + - Replace whole-field AX write with **targeted span insertion** where the element supports `kAXSelectedTextRangeAttribute` + `kAXSelectedTextAttribute` (set selection to the issue range, then set selected text to the replacement) — preserves caret and undo. Fall back to whole-value only when selection-set is unsupported. + - Make per-issue apply **rebase** instead of bail: if the live value changed, re-locate the span by matching `original` near the old offset rather than refusing to act. +- **Files:** `LiveCheckView.swift`, `LiveMonitor.swift`, `Models.swift`, `PersonalDictionary.swift`, `AppState.swift`, `TextCapture`. +- **Impact: high · Effort: M** + +### Mechanic 3 — Categorization, counts, and an on-device document score +- **Grammarly:** four named buckets (Correctness/Clarity/Engagement/Delivery) each with a color and a count chip that doubles as a filter; one master running badge; a 0-100 percentile score that ticks up on every accept; cards ordered by severity (objective errors first) so the early wins build trust. +- **GrammaGem today:** `GrammarKind` has 6 cases but `.punctuation` is dead; the panel reduces everything to 3 dot colors (`LiveCheckView.swift:136-142`); `issueCount` is a raw sum (`LiveMonitor.swift:32`); there is **no score, no per-category chips, no severity ordering, no filter**. +- **Do (on-device, no cloud, no percentile-vs-others):** + - Introduce GrammaGem's own named buckets that fit the engine: e.g. **Correctness** (spelling/grammar/punctuation), **Clarity** (phrasing/conciseness — from the real LLM), **Polish** (repetition/capitalization/style). Use original color semantics (do NOT clone Grammarly's exact red/blue/green/purple mapping or wording). + - Add **count chips per bucket** in the panel header that filter the card list; keep the menu-bar badge as the master tally (`MenuBarLabel`). + - Compute a **local cleanliness score** at check-time: a deterministic function of issues-per-100-words by severity (e.g. `100 - weighted_issues`). It is honest because it's defined relative to *this document*, not a hidden user corpus. Show it on the card header and trend it on the dashboard. + - **Order cards by severity then document position** (objective spelling/grammar first) so the first accept is a high-confidence win. + - Critical: the score must reflect the *honest* free promise — grammar/spelling is unlimited and fully resolvable, so a free user CAN reach a clean score. Do **not** manufacture a permanently-nonzero count to force upgrades (that's Grammarly's locked-bucket trick and it contradicts our positioning — see §5). +- **Files:** `Models.swift` (`GrammarKind` cleanup, add `severity`), `LiveMonitor.swift`, `LiveCheckView.swift`, `MenuBarContent.swift`, `DashboardView.swift`, a new `WritingScore` helper. +- **Impact: high · Effort: M** + +### Mechanic 4 — The first-run "wow" (see §4 for the full sequence) +- **Grammarly:** land in an error-salted demo doc -> "we found N issues" -> one-click fixes vanish underlines + decrement counter -> goal setup -> install-everywhere prompt at the dopamine peak. +- **GrammaGem today:** `OnboardingView` only covers Accessibility + model download (`OnboardingView.swift`); it never shows the correction loop. +- **Do:** add an in-app demo step with a pre-seeded sample, a "we found N things to fix" reveal, and a satisfying one-click-to-clean animation — *before* asking for nothing-but-praise (no install-everywhere pressure; we're already system-wide). Detail in §4. +- **Files:** `OnboardingView.swift`, plus a small `DemoDocument` view reusing the highlight + apply UI. +- **Impact: high · Effort: M** + +### Mechanic 5 — Ongoing perceived value (privacy-native, self-vs-self) +- **Grammarly:** 0-100 score, weekly Insights email ("more productive than X% of users"), lifetime words-checked counter, streaks — all cloud + behavioral-email, the exact surveillance we market against. +- **GrammaGem today:** `UsageStats` tracks `totalCorrections`, `totalAIActions`, `totalWords`, and `perDay` (`UsageStats.swift`); the dashboard shows four tiles + a 7-bar chart, but **no score, no streak, no week-over-week, no milestone, no money-saved figure**. `estimatedMinutesSaved` is a crude untuned constant (`8s/correction + 45s/AI-action`). Stats are invisible outside the main window. +- **Do (all local, no telemetry, stops nagging the moment they own it):** + - **Local writing-score trend** on the dashboard (from Mechanic 3), compared only to the user's own past. + - **Streak + milestones** from existing `perDay` data ("7-day streak", "1,000th correction", "100k words polished") — cheap, high word-of-mouth, and the right re-engagement hook for a no-renewal product. + - **"Subscription you didn't pay" figure** — translate usage into money: the marketing site already hammers "~\$144/yr vs pay once," but the *app* never does. Show "You've polished N times — that's the price of GrammaGem recovered M times over," computed locally. This is the strongest one-time-purchase value teaser and it lives where the user actually is. + - **Week-over-week delta** on the bar chart and a lightweight in-app weekly recap card (NOT an email; no pipeline, no surveillance). + - **Surface one stat in the menu bar dropdown** (the most-seen surface) so value reminders don't require opening the dashboard. + - Tune `estimatedMinutesSaved` to scale by words changed / action type so the headline "was it worth it?" number is credible. +- **Files:** `UsageStats.swift`, `DashboardView.swift`, `MenuBarContent.swift`, `web/src/lib/content.ts` (replace PLACEHOLDER stats/testimonials before launch). +- **Impact: high · Effort: M** + +### Mechanic 6 — The generative "writing partner" layer (real LLM, honest gating) +- **Grammarly:** sentence rewrites ("Replace with This Version" + Highlight-Changes diff), tone detection (emoji + label), tone adjustment, conciseness, GrammarlyGO — all one-click, reversible, before/after. +- **GrammaGem today:** `MLXEngine` is a stub doing `replacingOccurrences` string hacks (`MLXEngine.swift`); `tidy()` even re-introduces casing risk; `detectTone` keys off substrings. `ModelManager` downloads real weights but nothing loads them. Gated by `freeDailyAIActions = 10` shared across rewrite/tone/Ask/translate. +- **Do:** + - Wire the **real MLX model** from `ModelManager.modelsDirectory` (the download is currently a dead-end); uncomment `mlx-swift` in `Package.swift`. + - Adopt the **one-click + reversible + before/after diff** delivery contract for every rewrite — this is what neutralizes "the AI is taking over my voice." + - Keep the **honest quantity gate**: free users get *real, full-quality* output, just rationed (the daily counter), plus Polish mode only. This is the parity-respecting tease — satisfaction-driven ("I keep hitting the limit / I want my own modes / I want it on my other Mac"), not anxiety-driven. + - Real **tone detection** (label + emoji) is the highest-value non-grammar feature; it delivers value on grammatically perfect text. Build it once the model is live. +- **Files:** `MLXEngine.swift`, `AIEngine.swift`, `ModelManager.swift`, `Package.swift`, `FeatureGate.swift`, `TextReplacementCoordinator.swift`, `WritingModesView.swift`. +- **Impact: high · Effort: L** (model integration is the long pole) + +--- + +## 3. Correction-QUALITY: the path to trustworthy fixes (this underpins ALL perceived value) + +The engine is the load-bearing wall. Today it is `NSSpellChecker` + a ~6-rule regex stub, and it ships a trust-destroying casing bug. Here is the ordered path. + +### 3.1 Fix the casing/whitespace bug NOW (cheap, immediate) +Root cause: `SystemGrammarEngine.swift:42-45` blindly uses `checker.correction(forWordRange:)`, which is sentence/position-aware and returns a capitalized guess for a token it believes starts a sentence — so a mid-sentence `problemm` comes back `Problem`. + +**Post-process every spelling replacement to preserve the original token's casing** unless the token is a true sentence start or proper noun: +- If `original` is all-lowercase, lowercase the replacement's first letter. +- If `original` is Capitalized (single leading cap), match that. +- If `original` is ALL-CAPS, upper-case the replacement. +- Only adopt the corrector's case when the token is at a genuine sentence boundary (preceded by `.?!` + space, or string start). Also trim/normalize whitespace so a replacement never injects a stray leading/trailing space. + +This is a ~20-line transcase helper applied at suggestion-build time. It removes the single most visible trust failure before any deeper work. + +### 3.2 Stop auto-applying garbage +- `GrammarEngine.correct()` (`GrammarEngine.swift:22-29`) applies **every** suggestion including grammar flags where `replacement == original` (the no-op flags emitted at `SystemGrammarEngine.swift:51-54` and `:67-70`). Filter these out of the bulk path entirely — a no-op flag must never be part of "Fix all," and a low-confidence guess must never auto-apply. +- "Fix all" should be **trust-scoped like Grammarly's bulk accept**: only high-confidence objective mechanics (spelling/grammar/punctuation), never subjective rewrites. + +### 3.3 Enrich the data model +`Suggestion` (`Models.swift:48-59`) needs: +- `confidence: Double` (gate auto-apply below a threshold; show low-confidence ones as optional, never bulk-applied). +- `severity` (drives ordering and the score weighting). +- `alternatives: [String]` — `NSSpellChecker.guesses()` already returns multiple candidates but only `.first` is kept (`SystemGrammarEngine.swift:41`); surface the rest so the card can offer "other suggestions." +- `ruleID`/`source` — so feedback (accept/dismiss) can later tune ranking and so we can attribute a fix to Harper vs NSSpellChecker. + +### 3.4 Integrate the real Harper core (the big precision unlock) +`harper-ffi/` is an empty README; `Package.swift` comments out all real deps. Stand up the Rust crate behind a C-FFI static lib (`harper_check(text) -> spans`), map spans into `Suggestion`, and keep the protocol boundary so the rest of the app is unchanged. This brings real subject-verb agreement, article/preposition usage, comma/apostrophe rules, commonly-confused pairs, etc. — the category coverage NSSpellChecker structurally cannot provide. De-dup against NSSpellChecker as today (`overlaps`), but prefer Harper where both fire. + +### 3.5 Populate the dead `.punctuation` category +`.punctuation` is declared but never emitted, yet punctuation is one of the highest-volume real-world error classes. Harper covers most of it; this fills the gap and makes the new category chips (Mechanic 3) meaningful. + +### 3.6 Incremental, async, grapheme-safe checking +- Today: whole-document, synchronous, `DispatchQueue.main.sync` on a 1.6s poll, truncated at 4000 chars (`LiveMonitor.swift:46,174`), frontmost window only. Move to **debounced incremental re-check of changed spans** with caching of unchanged ranges; keep the heavy AX traversal off-main (already done) but get the spell/grammar pass off the main thread too once Harper is FFI-based (no `NSSpellChecker` main-thread requirement). +- Validate that replacement sub-ranges fall on **grapheme boundaries** (emoji/combining marks) before applying — only a coarse bounds check exists today (`SystemGrammarEngine.swift:63`). + +### 3.7 Feedback loop +Wire dismiss / add-to-dictionary (Mechanic 2) back into ranking so repeated false positives stop nagging — this is the "it learns" trust signal. Today `ignoreList` only suppresses; long-term, dismiss counts per `ruleID` can down-rank a noisy rule. + +**Sequencing:** §3.1 + §3.2 this week (trust-critical, tiny). §3.3 alongside Mechanic 2. §3.4–§3.6 are the real Harper milestone. §3.7 last. + +--- + +## 4. The first-run "wow" sequence for GrammaGem (original copy) + +Goal: a guaranteed visible win in under 60 seconds, *inside our own window* (we don't need Grammarly's blank-canvas demo trick or its install-everywhere upsell — we're already system-wide). Onboarding today (`OnboardingView.swift`) skips this entirely. + +Proposed flow: + +1. **Welcome (1 line):** "A private writing assistant that lives in your menu bar and fixes text in every app — all on this Mac." (existing copy is close; keep it.) + +2. **The rigged win — a live mini-demo BEFORE any permission ask.** Show a short pre-written paragraph salted with obvious, safe errors (a misspelling, a repeated word, a lowercase "i", a missing period). Render it with the highlight UI already built in `LiveCheckView.highlighted(...)`. Above it: **"We spotted N things to tidy up."** (count = guaranteed, because the sample is fixed.) + +3. **One satisfying click.** A single "Clean it up" button animates the highlights resolving one by one; the count ticks to 0; the paragraph visibly improves. This is the operant loop — trivial action, immediate visible reward — experienced once, on rails, with zero risk. (Reuse the apply path; it's a local string, not a live AX element, so it's completely safe.) + +4. **The reveal:** "That's GrammaGem. It does this in Mail, Slack, Notes, your browser — everywhere you type." Then the *first* ask: **Accessibility** (the existing step 2 / `stepCard`), now framed as "Turn it on everywhere." + +5. **Optional model step:** keep the existing "Download the on-device model (optional) — grammar works instantly with no download" card. Frame the AI features as a bonus, not a blocker. + +6. **No install-everywhere nag, no timed discount, no streak guilt.** End on a calm "You're all set" with one honest line about ownership: "Yours once. No subscription, nothing leaves your Mac." + +Guardrails: the demo must use *original* sample text (not Grammarly's), and the count/score must be real (computed by our engine on the sample), never theatrical. + +--- + +## 5. Do NOT copy (dark patterns + claims we can't truthfully make) + +These are Grammarly mechanics that either depend on infrastructure we reject or that would corrode the trust/privacy moat that *is* the product. + +1. **The premium-issue anxiety counter ("14 advanced issues you can't fix").** It works by detecting problems the engine deliberately won't fix. It clashes head-on with our promise that grammar/spelling/punctuation is unlimited and fully resolvable for free. Do not manufacture a permanently-nonzero count or a "score capped until you upgrade" gate. + +2. **Two-tier gold-vs-red underlines (a second class of locked grammar issues).** Our underlines are all free and all resolvable; there is no second class to color differently. Inventing one betrays the core differentiator. + +3. **Blur/lock a result we already computed.** On-device + open-source, hiding output we generated is both trivially bypassable and brand-corrosive. Gate by **quantity/frequency** (the daily counter) and **breadth** (modes, multi-device, snippets, larger model) — never by hiding the quality of a single result. + +4. **Cloud percentile comparisons ("more productive than X% of users").** Requires an account + aggregate corpus + telemetry — the exact surveillance we market against. Replace with **self-vs-self** local trends. + +5. **Behavioral weekly emails / re-nag pipeline.** For a one-time purchase there is no subscription to keep warm; post-purchase nagging is pure annoyance. Any weekly recap must be **in-app, local, opt-in, and stop entirely once they own it.** + +6. **Countdown/limited-time discount urgency** (Grammarly's Day-7 40% timer). Manufactured scarcity undercuts the honest "buy once, fair price" positioning. + +7. **Lifetime "total words checked" framed as cloud sunk-cost-to-lose.** A local milestone counter is fine and good; framing it as "you'll lose your history if you leave" is a retention dark pattern with no place in a no-renewal product. + +8. **Unsubstantiated social proof.** `web/src/lib/content.ts` STATS (200k+ downloads, 4.9 rating) and all TESTIMONIALS are explicitly flagged PLACEHOLDER. Do not ship these as fact — replace with real numbers or remove before launch. + +Positive reframe to keep: surface locked paid features (modes, app-aware, snippets, larger model) honestly — "Unlock all of this once. Yours forever, no subscription." Same mechanism (visible locked features), stronger close (no recurring cost objection). + +--- + +## 6. Prioritized roadmap + +### NOW (trust + the atomic loop — ship-blockers for credibility) +1. **Fix the casing/whitespace post-processing bug** (§3.1). `SystemGrammarEngine.swift`. *high / S.* +2. **Stop bulk-applying no-ops and low-confidence guesses** (§3.2). `GrammarEngine.swift`, `SystemGrammarEngine.swift`. *high / S.* +3. **Dismiss / Ignore / Add-to-dictionary + keyboard accept in the Live Check panel** (Mechanic 2). `LiveCheckView.swift`, `LiveMonitor.swift`, `Models.swift`, `PersonalDictionary.swift`. *high / M.* +4. **Targeted span apply via AX selection instead of whole-field overwrite; rebase instead of bail** (Mechanic 2). `LiveMonitor.swift`, `TextCapture`. *high / M.* +5. **First-run rigged-win demo** (§4). `OnboardingView.swift`. *high / M.* + +### NEXT (organize + the value loop) +6. **Named buckets + per-category count chips + severity ordering + filter** (Mechanic 3). `Models.swift`, `LiveMonitor.swift`, `LiveCheckView.swift`. *high / M.* +7. **On-device writing score (per-doc) + dashboard trend** (Mechanics 3 & 5). `WritingScore` helper, `LiveCheckView.swift`, `DashboardView.swift`. *high / M.* +8. **Streaks + milestones + "subscription you didn't pay" money figure + menu-bar stat** (Mechanic 5). `UsageStats.swift`, `DashboardView.swift`, `MenuBarContent.swift`. *high / M.* +9. **Enrich `Suggestion` with confidence/severity/alternatives + "other suggestions" in card** (§3.3). `Models.swift`, `SystemGrammarEngine.swift`, `LiveCheckView.swift`. *medium / M.* + +### LATER (real engine + real partner layer) +10. **Real Harper FFI integration + populate `.punctuation` + grapheme safety + incremental async re-check** (§3.4–§3.6). `harper-ffi/`, `HarperEngine.swift`, `SystemGrammarEngine.swift`, `LiveMonitor.swift`, `Package.swift`. *high / L.* +11. **Wire the real MLX model (load downloaded weights) + one-click/reversible/before-after diff for rewrites + real tone detection** (Mechanic 6). `MLXEngine.swift`, `ModelManager.swift`, `Package.swift`, `TextReplacementCoordinator.swift`. *high / L.* +12. **Replace PLACEHOLDER web social proof/testimonials before launch** (§5). `web/src/lib/content.ts`. *medium / S.* + +--- + +*Throughline: ship trust first (correction quality + a safe, reversible accept loop), then make the work feel finite and rewarding (buckets, counts, a local score), then sell ongoing value honestly (self-vs-self progress, money saved, lifetime ownership) — and build the generative partner tier on a real model with a reversible, before/after contract. Every Grammarly mechanic translates if you swap cloud-surveillance for on-device self-comparison and recurring-nag for one-time honesty.*