mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
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:
parent
3ea090ae4b
commit
143620e44e
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -34,13 +34,12 @@ struct LiveCheckView: View {
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
ScoreRing(score: monitor.writingScore)
|
||||
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)
|
||||
Text(contextLine).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") }
|
||||
@ -52,15 +51,32 @@ struct LiveCheckView: View {
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -14,6 +14,8 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Welcome to GrammaGem")
|
||||
@ -22,9 +24,18 @@ struct OnboardingView: View {
|
||||
.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: "Grant Accessibility access",
|
||||
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
|
||||
) {
|
||||
@ -49,21 +60,23 @@ struct OnboardingView: View {
|
||||
) {
|
||||
ModelDownloadControls()
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -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.
|
||||
|
||||
20
Sources/GrammaGem/Grammar/WritingScore.swift
Normal file
20
Sources/GrammaGem/Grammar/WritingScore.swift
Normal 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())))
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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<String> = []
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
204
docs/grammarly-value-plan.md
Normal file
204
docs/grammarly-value-plan.md
Normal 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.*
|
||||
Loading…
x
Reference in New Issue
Block a user