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) <noreply@anthropic.com>
This commit is contained in:
BigMarc 2026-06-18 06:43:31 +02:00
parent 3ea090ae4b
commit 143620e44e
11 changed files with 708 additions and 57 deletions

View File

@ -179,6 +179,26 @@ final class AppState: ObservableObject {
usage.recordCorrection(words: 1) 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). /// Pause/resume all of GrammaGem (hotkeys + live monitoring).
func togglePause() { func togglePause() {
isPaused.toggle() isPaused.toggle()
@ -258,7 +278,7 @@ final class AppState: ObservableObject {
let win = NSWindow(contentViewController: host) let win = NSWindow(contentViewController: host)
win.title = "Welcome to GrammaGem" win.title = "Welcome to GrammaGem"
win.styleMask = [.titled, .closable, .fullSizeContentView] win.styleMask = [.titled, .closable, .fullSizeContentView]
win.setContentSize(NSSize(width: 540, height: 600)) win.setContentSize(NSSize(width: 560, height: 720))
win.isReleasedWhenClosed = false win.isReleasedWhenClosed = false
win.center() win.center()
onboardingWindow = win onboardingWindow = win

View File

@ -30,6 +30,15 @@ struct DashboardView: View {
StatTile(value: "\(usage.data.totalCorrections)", label: "Corrections made", systemImage: "checkmark.seal.fill", tint: GG.emerald) 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: 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 // Setup health
@ -61,6 +70,19 @@ struct DashboardView: View {
.font(.caption).foregroundStyle(.secondary) .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 // Quick actions
Card { Card {
Text("Quick actions").font(.headline) Text("Quick actions").font(.headline)

View File

@ -34,22 +34,32 @@ struct LiveCheckView: View {
} }
private var header: some View { private var header: some View {
HStack(spacing: 12) { VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 2) { HStack(spacing: 12) {
Text(summary).font(.headline) ScoreRing(score: monitor.writingScore)
Text(monitor.activeAppName.isEmpty VStack(alignment: .leading, spacing: 2) {
? "Checking text areas on screen" Text(summary).font(.headline)
: "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked") Text(contextLine).font(.caption).foregroundStyle(.secondary)
.font(.caption).foregroundStyle(.secondary) }
} Spacer()
Spacer() Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") }
Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") } .help("Re-scan now")
.help("Re-scan now") if monitor.issueCount > 0 {
if monitor.issueCount > 0 { Button { app.fixAllDetected() } label: {
Button { app.fixAllDetected() } label: { Label("Fix everything", systemImage: "checkmark.circle.fill")
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) .padding(16)
@ -57,10 +67,16 @@ struct LiveCheckView: View {
private var summary: String { private var summary: String {
let issues = monitor.issueCount 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")" 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 { private var emptyState: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "checkmark.seal.fill").font(.system(size: 42)).foregroundStyle(GG.emerald) Image(systemName: "checkmark.seal.fill").font(.system(size: 42)).foregroundStyle(GG.emerald)
@ -112,9 +128,21 @@ private struct FieldCard: View {
} }
} }
Spacer() 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 { if s.replacement != s.original && !s.replacement.isEmpty {
Button("Apply") { app.applySuggestion(s, in: field) } 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. /// Build an attributed string with each issue span tinted.
private func highlighted(_ text: String, _ suggestions: [Suggestion]) -> AttributedString { private func highlighted(_ text: String, _ suggestions: [Suggestion]) -> AttributedString {
let ns = text as NSString let ns = text as NSString
@ -167,3 +203,40 @@ private struct FieldCard: View {
return result 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())
}
}

View File

@ -65,6 +65,7 @@ struct MenuBarContent: View {
Divider() Divider()
usageRow usageRow
valueRow
Divider() 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 { private func actionRow(title: String, shortcut: String?, action: @escaping () -> Void) -> some View {
Button(action: action) { Button(action: action) {
HStack { HStack {

View File

@ -14,56 +14,69 @@ struct OnboardingView: View {
} }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 18) { VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 6) { ScrollView {
Text("Welcome to GrammaGem") VStack(alignment: .leading, spacing: 18) {
.font(.largeTitle.bold()) VStack(alignment: .leading, spacing: 6) {
Text("A private, on-device writing assistant that works in every app.") Text("Welcome to GrammaGem")
.foregroundStyle(.secondary) .font(.largeTitle.bold())
} Text("A private, on-device writing assistant that works in every app.")
.foregroundStyle(.secondary)
stepCard( }
number: 1,
title: "Grant Accessibility access", // The rigged win: prove the value in one click, before any ask.
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.", VStack(alignment: .leading, spacing: 10) {
granted: permissions.accessibilityTrusted Label("See it work", systemImage: "wand.and.stars").font(.headline)
) { DemoStep()
if permissions.accessibilityTrusted { }
Label("Permission granted", systemImage: "checkmark.circle.fill") .padding(16)
.foregroundStyle(.green) .background(GG.emerald.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
} else { .overlay(RoundedRectangle(cornerRadius: 12).stroke(GG.emerald.opacity(0.25)))
HStack {
Button("Open System Settings") { permissions.openAccessibilitySettings() } stepCard(
Button("Request permission") { permissions.requestAccessibility() } 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( Divider()
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()
HStack { HStack {
if permissions.accessibilityTrusted { Label("Yours once. No subscription, nothing leaves your Mac.", systemImage: "lock.shield")
Label("You're all set", systemImage: "sparkles").foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
}
Spacer() Spacer()
Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") { Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") {
app.showOnboardingDismiss() app.showOnboardingDismiss()
} }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
} }
.padding(.horizontal, 28).padding(.vertical, 14)
} }
.padding(28)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear { .onAppear {
permissions.startPollingUntilGranted() 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. /// Real model download progress + controls, shared by onboarding and settings.
struct ModelDownloadControls: View { struct ModelDownloadControls: View {
@ObservedObject private var model: ModelManager @ObservedObject private var model: ModelManager

View File

@ -42,6 +42,42 @@ enum GrammarKind: String, Codable {
case phrasing case phrasing
case repetition case repetition
case capitalization 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. /// A single grammar suggestion over a span of the input text.

View File

@ -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())))
}
}

View File

@ -8,9 +8,15 @@ final class UsageStats: ObservableObject {
var totalCorrections = 0 var totalCorrections = 0
var totalAIActions = 0 var totalAIActions = 0
var totalWords = 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 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 @Published private(set) var data: Snapshot
private let key = "GrammaGem.usageStats" private let key = "GrammaGem.usageStats"
@ -52,6 +58,60 @@ final class UsageStats: ObservableObject {
(data.totalCorrections * 8 + data.totalAIActions * 45) / 60 (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. /// Counts for the last 7 calendar days, oldest → newest, for a small chart.
func last7Days() -> [(label: String, count: Int)] { func last7Days() -> [(label: String, count: Int)] {
let cal = Calendar.current let cal = Calendar.current
@ -71,6 +131,10 @@ final class UsageStats: ObservableObject {
private func bumpToday() { private func bumpToday() {
let k = Self.fmt.string(from: Date()) 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 data.perDay[k, default: 0] += 1
// Keep only ~60 days of history. // Keep only ~60 days of history.
if data.perDay.count > 70 { if data.perDay.count > 70 {

View File

@ -25,12 +25,34 @@ struct DetectedField: Identifiable {
final class LiveMonitor: ObservableObject { final class LiveMonitor: ObservableObject {
@Published private(set) var fields: [DetectedField] = [] @Published private(set) var fields: [DetectedField] = []
@Published private(set) var scannedCount = 0 @Published private(set) var scannedCount = 0
@Published private(set) var scannedWordCount = 0
@Published private(set) var activeAppName = "" @Published private(set) var activeAppName = ""
@Published private(set) var running = false @Published private(set) var running = false
/// Total number of issues across all detected text areas. /// Total number of issues across all detected text areas.
var issueCount: Int { fields.reduce(0) { $0 + $1.suggestions.count } } 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<String> = []
private let grammar: GrammarEngine private let grammar: GrammarEngine
private let detector: AppDetector private let detector: AppDetector
private let exclusions: Exclusions private let exclusions: Exclusions
@ -88,6 +110,36 @@ final class LiveMonitor: ObservableObject {
refreshSoon() 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 /// Correct the element's *current* value (never a stale snapshot), so any
/// keystrokes entered since the scan are preserved. /// keystrokes entered since the scan are preserved.
private func applyCorrection(to field: DetectedField) -> Int { 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 generation == scanGeneration else { return } // superseded by a newer scan
guard !scanned.isEmpty else { clear(); return } 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 // Change detection includes focus + element identity so tabbing between
// same-text fields still updates the focused indicator. // same-text fields still updates the focused indicator.
var hasher = Hasher() var hasher = Hasher()
@ -179,16 +236,17 @@ final class LiveMonitor: ObservableObject {
text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused)) text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused))
} }
detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) } detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) }
fields = detected fields = filterDismissed(detected)
} }
private nonisolated static func systemFocusedElement() -> AXUIElement? { private nonisolated static func systemFocusedElement() -> AXUIElement? {
AX.copyElement(AXUIElementCreateSystemWide(), kAXFocusedUIElementAttribute) AX.focusedElement()
} }
private func clear() { private func clear() {
if !fields.isEmpty { fields = [] } if !fields.isEmpty { fields = [] }
scannedCount = 0 scannedCount = 0
scannedWordCount = 0
lastHash = 0 lastHash = 0
} }
} }

View File

@ -49,6 +49,67 @@ final class GrammaGemTests: XCTestCase {
XCTAssertFalse(suggestions.contains { $0.original.lowercased() == "teh" }) 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 // MARK: - Modes
func testFreeTierGetsOnlyPolishMode() { func testFreeTierGetsOnlyPolishMode() {

View File

@ -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<UUID>` 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.*