mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
f
This commit is contained in:
parent
3d749e8139
commit
e5b0977abf
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Info.plist for the packaged GrammaGem.app bundle.
|
||||
Info.plist for the packaged GrammarGem.app bundle.
|
||||
|
||||
`swift build` produces a bare executable; to ship a real menu-bar app you wrap
|
||||
it in a .app bundle with this Info.plist (see scripts/build.sh) or, more
|
||||
@ -18,13 +18,13 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>GrammaGem</string>
|
||||
<string>GrammarGem</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>GrammaGem</string>
|
||||
<string>GrammarGem</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.grammagem.app</string>
|
||||
<string>com.grammargem.app</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>GrammaGem</string>
|
||||
<string>GrammarGem</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
@ -44,11 +44,11 @@
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
|
||||
<!-- Sparkle auto-update. Appcast is served from grammagem.app and updates are
|
||||
<!-- Sparkle auto-update. Appcast is served from grammargem.com and updates are
|
||||
EdDSA-signed with the key whose public half is below (private key lives in
|
||||
the release machine's keychain; sign with scripts/release.sh). -->
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://grammagem.app/appcast.xml</string>
|
||||
<string>https://www.grammargem.com/appcast.xml</string>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>IwYch+DRgNk3gmCwe86z+SIc4sRdyDf4Z6xGpS+JXDc=</string>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import PackageDescription
|
||||
import Foundation
|
||||
|
||||
// GrammaGem — native macOS menu-bar writing assistant.
|
||||
// GrammarGem — native macOS menu-bar writing assistant.
|
||||
//
|
||||
// Layer-1 grammar is the real **Harper** core (Apache-2.0), embedded as a Rust
|
||||
// C-FFI static library (see `harper-ffi/`). Build the lib first with
|
||||
@ -18,41 +18,41 @@ let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().pa
|
||||
let harperLibDir = packageRoot + "/harper-ffi/lib"
|
||||
|
||||
let package = Package(
|
||||
name: "GrammaGem",
|
||||
name: "GrammarGem",
|
||||
platforms: [
|
||||
.macOS(.v14) // Apple Silicon, macOS 14+ (per the product spec)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "GrammaGem", targets: ["GrammaGem"])
|
||||
.executable(name: "GrammarGem", targets: ["GrammarGem"])
|
||||
],
|
||||
dependencies: [
|
||||
// On-device LLM runtime. Pinned to an exact tag (the LLM libraries still
|
||||
// ship from this repo at 2.25.9; the package name is "mlx-libraries").
|
||||
.package(url: "https://github.com/ml-explore/mlx-swift-examples.git", exact: "2.25.9"),
|
||||
// Secure auto-updates (EdDSA-signed appcast served from grammagem.app).
|
||||
// Secure auto-updates (EdDSA-signed appcast served from grammargem.com).
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
|
||||
],
|
||||
targets: [
|
||||
// C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift.
|
||||
.target(name: "CHarper", path: "Sources/CHarper"),
|
||||
.executableTarget(
|
||||
name: "GrammaGem",
|
||||
name: "GrammarGem",
|
||||
dependencies: [
|
||||
"CHarper",
|
||||
.product(name: "MLXLLM", package: "mlx-swift-examples"),
|
||||
.product(name: "MLXLMCommon", package: "mlx-swift-examples"),
|
||||
.product(name: "Sparkle", package: "Sparkle"),
|
||||
],
|
||||
path: "Sources/GrammaGem",
|
||||
path: "Sources/GrammarGem",
|
||||
linkerSettings: [
|
||||
// Link the prebuilt Harper static library (arm64 slice).
|
||||
.unsafeFlags(["-L\(harperLibDir)", "-lharper_ffi"])
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "GrammaGemTests",
|
||||
dependencies: ["GrammaGem"],
|
||||
path: "Tests/GrammaGemTests"
|
||||
name: "GrammarGemTests",
|
||||
dependencies: ["GrammarGem"],
|
||||
path: "Tests/GrammarGemTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
18
README.md
18
README.md
@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
# ✒️ GrammaGem
|
||||
# ✒️ GrammarGem
|
||||
|
||||
### Write it once. Get it right.
|
||||
|
||||
@ -12,7 +12,7 @@ Fix grammar, sharpen tone, and rewrite anything — in every app — for one pay
|
||||

|
||||

|
||||
|
||||
[**⬇️ Download GrammaGem**](https://grammagem.app/#pricing) · [Docs](https://grammagem.app/docs) · [How it works](https://grammagem.app)
|
||||
[**⬇️ Download GrammarGem**](https://www.grammargem.com/#pricing) · [Docs](https://www.grammargem.com/docs) · [How it works](https://www.grammargem.com)
|
||||
|
||||
</div>
|
||||
|
||||
@ -20,7 +20,7 @@ Fix grammar, sharpen tone, and rewrite anything — in every app — for one pay
|
||||
|
||||
## Stop renting your grammar checker.
|
||||
|
||||
Grammarly costs around **$144 every year**, reads everything you type, and sends it to their servers. GrammaGem does the same job — **better** — entirely on your Mac, for a **single payment**.
|
||||
Grammarly costs around **$144 every year**, reads everything you type, and sends it to their servers. GrammarGem does the same job — **better** — entirely on your Mac, for a **single payment**.
|
||||
|
||||
Select text in any app, press one hotkey, and it’s polished in place. No copy-paste, no switching windows, and **nothing ever leaves your machine**.
|
||||
|
||||
@ -30,7 +30,7 @@ Select text in any app, press one hotkey, and it’s polished in place. No copy-
|
||||
|
||||
## Why people switch
|
||||
|
||||
| | Grammarly | **GrammaGem** |
|
||||
| | Grammarly | **GrammarGem** |
|
||||
|---|:---:|:---:|
|
||||
| **Price** | ~$144 / year, forever | **Pay once** |
|
||||
| **Your words** | Sent to the cloud | **Never leave your Mac** |
|
||||
@ -46,7 +46,7 @@ Select text in any app, press one hotkey, and it’s polished in place. No copy-
|
||||
- 🔒 **Private by design** — everything runs on your Mac’s Neural Engine. Your writing is never uploaded, logged, or used to train a model.
|
||||
- ⌨️ **One global hotkey** — fix or rewrite the selected text in any app: Mail, Slack, Notion, Chrome, Messages, VS Code, and more.
|
||||
- ✨ **Ask & rewrite** — “make it shorter,” “more formal,” “translate to German” — done in place.
|
||||
- 🎚️ **Writing Modes** — Email, Post, Team Chat, Academic, Code — switch the tone instantly, or let GrammaGem pick the right one for the app you’re in.
|
||||
- 🎚️ **Writing Modes** — Email, Post, Team Chat, Academic, Code — switch the tone instantly, or let GrammarGem pick the right one for the app you’re in.
|
||||
- 📖 **Personal dictionary** — teach it your names, brand terms, and jargon once; it stops “correcting” them forever.
|
||||
- 🖥️ **A clean management app** — manage your devices, shortcuts, modes, and privacy from one tidy window.
|
||||
|
||||
@ -67,7 +67,7 @@ No subscription. No per-seat fees. One payment, lifetime updates.
|
||||
|
||||
<div align="center">
|
||||
|
||||
[**⬇️ Get GrammaGem**](https://grammagem.app/#pricing)
|
||||
[**⬇️ Get GrammarGem**](https://www.grammargem.com/#pricing)
|
||||
|
||||
</div>
|
||||
|
||||
@ -85,17 +85,17 @@ No. Core features work fully offline.
|
||||
No — it’s a one-time purchase with lifetime updates and a 14-day money-back guarantee.
|
||||
|
||||
**What about plagiarism detection?**
|
||||
GrammaGem is private-first, so it doesn’t do cloud plagiarism scanning — that would require sending your writing to a server. Everything else you’d expect is here.
|
||||
GrammarGem is private-first, so it doesn’t do cloud plagiarism scanning — that would require sending your writing to a server. Everything else you’d expect is here.
|
||||
|
||||
---
|
||||
|
||||
## Built in public
|
||||
|
||||
GrammaGem is open source under the **MIT License**. We believe a privacy tool should be one you can actually inspect. Issues and pull requests are welcome.
|
||||
GrammarGem is open source under the **MIT License**. We believe a privacy tool should be one you can actually inspect. Issues and pull requests are welcome.
|
||||
|
||||
<div align="center">
|
||||
|
||||
**[grammagem.app](https://grammagem.app)**
|
||||
**[grammargem.com](https://www.grammargem.com)**
|
||||
|
||||
© 2026 FounderGem LLC · 1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# Built-in Writing Modes
|
||||
|
||||
Each Mode is a named system-prompt preset for the local LLM. The canonical,
|
||||
compiled definitions live in [`Sources/GrammaGem/AI/ModeRegistry.swift`](../../Sources/GrammaGem/AI/ModeRegistry.swift);
|
||||
compiled definitions live in [`Sources/GrammarGem/AI/ModeRegistry.swift`](../../Sources/GrammarGem/AI/ModeRegistry.swift);
|
||||
these JSON files mirror them for transparency and easy editing.
|
||||
|
||||
| Mode | Tier | In public repo? |
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// CHarper is a header-only shim that exposes libharper_ffi's C ABI (declared in
|
||||
// include/harper.h) to Swift as the `CHarper` module. The actual symbols are
|
||||
// provided by libharper_ffi.a, linked via the GrammaGem target's linkerSettings.
|
||||
// provided by libharper_ffi.a, linked via the GrammarGem target's linkerSettings.
|
||||
// This translation unit exists only so SwiftPM treats CHarper as a C target.
|
||||
|
||||
@ -8,8 +8,8 @@ final class CustomModesStore: ObservableObject {
|
||||
/// bundleID -> mode id (overrides the default App-Aware mapping).
|
||||
@Published private(set) var appOverrides: [String: String]
|
||||
|
||||
private let modesKey = "GrammaGem.customModes"
|
||||
private let mapKey = "GrammaGem.appModeOverrides"
|
||||
private let modesKey = "GrammarGem.customModes"
|
||||
private let mapKey = "GrammarGem.appModeOverrides"
|
||||
private let d = UserDefaults.standard
|
||||
|
||||
init() {
|
||||
|
||||
@ -38,7 +38,7 @@ final class ModelManager: ObservableObject {
|
||||
|
||||
nonisolated static func modelsDirectory() -> URL {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
return base.appendingPathComponent("GrammaGem/Models", isDirectory: true)
|
||||
return base.appendingPathComponent("GrammarGem/Models", isDirectory: true)
|
||||
}
|
||||
|
||||
nonisolated static func modelDir(_ repo: String) -> URL {
|
||||
|
||||
@ -115,7 +115,7 @@ final class AppState: ObservableObject {
|
||||
}
|
||||
|
||||
Task { await license.validateIfNeeded() }
|
||||
Log.app.info("GrammaGem started. Licensed: \(self.license.isLicensed, privacy: .public)")
|
||||
Log.app.info("GrammarGem started. Licensed: \(self.license.isLicensed, privacy: .public)")
|
||||
}
|
||||
|
||||
// MARK: - Hotkey handling
|
||||
@ -205,19 +205,19 @@ final class AppState: ObservableObject {
|
||||
return decision
|
||||
}
|
||||
|
||||
/// Pause/resume all of GrammaGem (hotkeys + live monitoring).
|
||||
/// Pause/resume all of GrammarGem (hotkeys + live monitoring).
|
||||
func togglePause() {
|
||||
isPaused.toggle()
|
||||
liveMonitor.setPaused(isPaused)
|
||||
lastStatus = isPaused ? "Paused" : "Ready"
|
||||
}
|
||||
|
||||
/// A reason GrammaGem should stay out of the way right now, or nil.
|
||||
/// A reason GrammarGem should stay out of the way right now, or nil.
|
||||
private func blockReason() -> String? {
|
||||
if isPaused { return "GrammaGem is paused." }
|
||||
if isPaused { return "GrammarGem is paused." }
|
||||
let front = detector.frontmost()
|
||||
if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) {
|
||||
return "GrammaGem is off for \(front?.name ?? "this app")."
|
||||
return "GrammarGem is off for \(front?.name ?? "this app")."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -282,7 +282,7 @@ final class AppState: ObservableObject {
|
||||
if onboardingWindow == nil {
|
||||
let host = NSHostingController(rootView: OnboardingView().environmentObject(self))
|
||||
let win = NSWindow(contentViewController: host)
|
||||
win.title = "Welcome to GrammaGem"
|
||||
win.title = "Welcome to GrammarGem"
|
||||
win.styleMask = [.titled, .closable, .fullSizeContentView]
|
||||
win.setContentSize(NSSize(width: 560, height: 720))
|
||||
win.isReleasedWhenClosed = false
|
||||
@ -311,7 +311,7 @@ final class AppState: ObservableObject {
|
||||
.environmentObject(liveMonitor)
|
||||
let host = NSHostingController(rootView: root)
|
||||
let win = NSWindow(contentViewController: host)
|
||||
win.title = "GrammaGem"
|
||||
win.title = "GrammarGem"
|
||||
win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
|
||||
win.setContentSize(NSSize(width: 940, height: 660))
|
||||
win.minSize = NSSize(width: 820, height: 560)
|
||||
@ -349,7 +349,7 @@ final class AppState: ObservableObject {
|
||||
if askWindow == nil {
|
||||
let host = NSHostingController(rootView: AskView().environmentObject(self))
|
||||
let win = NSWindow(contentViewController: host)
|
||||
win.title = "Ask GrammaGem"
|
||||
win.title = "Ask GrammarGem"
|
||||
win.styleMask = [.titled, .closable]
|
||||
win.setContentSize(NSSize(width: 460, height: 220))
|
||||
win.isReleasedWhenClosed = false
|
||||
|
||||
@ -8,7 +8,7 @@ struct AskView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Ask GrammaGem")
|
||||
Text("Ask GrammarGem")
|
||||
.font(.headline)
|
||||
Text("Acts on the text selected in the frontmost app. Runs on-device.")
|
||||
.font(.caption)
|
||||
|
||||
@ -5,7 +5,7 @@ import AppKit
|
||||
/// and runs one real generation (verifies the MLX runtime + metallib headlessly),
|
||||
/// then exits. Otherwise the normal menu-bar app launches.
|
||||
@main
|
||||
enum GrammaGemMain {
|
||||
enum GrammarGemMain {
|
||||
static func main() {
|
||||
if CommandLine.arguments.contains("--mlx-selftest") {
|
||||
MLXSelfTest.run()
|
||||
@ -16,7 +16,7 @@ enum GrammaGemMain {
|
||||
UpdaterSelfTest.run(feedURL: feed)
|
||||
return
|
||||
}
|
||||
GrammaGemApp.main()
|
||||
GrammarGemApp.main()
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ enum MLXSelfTest {
|
||||
/// App entry point. A menu-bar (`LSUIElement`) SwiftUI app: the only persistent
|
||||
/// scene is the `MenuBarExtra`; Settings uses the standard scene; Onboarding and
|
||||
/// Ask are AppKit-hosted windows opened on demand (see `AppState`).
|
||||
struct GrammaGemApp: App {
|
||||
struct GrammarGemApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@StateObject private var app = AppState.shared
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ struct GeneralView: View {
|
||||
@EnvironmentObject private var permissions: Permissions
|
||||
|
||||
var body: some View {
|
||||
DetailScaffold(title: "General", subtitle: "Make GrammaGem feel like yours.") {
|
||||
DetailScaffold(title: "General", subtitle: "Make GrammarGem feel like yours.") {
|
||||
Card {
|
||||
Toggle("Launch at login", isOn: $prefs.launchAtLogin)
|
||||
Divider()
|
||||
@ -134,7 +134,7 @@ struct AboutView: View {
|
||||
.frame(width: 56, height: 56)
|
||||
.background(GG.emerald.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("GrammaGem").font(.title2.bold())
|
||||
Text("GrammarGem").font(.title2.bold())
|
||||
Text("Version \(AppConfig.appVersion)").foregroundStyle(.secondary)
|
||||
Text("The private, on-device writing assistant for Mac.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
@ -153,7 +153,7 @@ struct AboutView: View {
|
||||
|
||||
Card {
|
||||
Text("Open source").font(.headline)
|
||||
Text("GrammaGem is open source under the MIT License — a privacy tool you can inspect.")
|
||||
Text("GrammarGem is open source under the MIT License — a privacy tool you can inspect.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
|
||||
@ -91,7 +91,7 @@ struct DashboardView: View {
|
||||
Label("Fix selection", systemImage: "checkmark.circle")
|
||||
}
|
||||
Button { app.showAsk() } label: {
|
||||
Label("Ask GrammaGem", systemImage: "sparkles")
|
||||
Label("Ask GrammarGem", systemImage: "sparkles")
|
||||
}
|
||||
if !license.isLicensed {
|
||||
Spacer()
|
||||
|
||||
@ -12,7 +12,7 @@ struct DictionaryView: View {
|
||||
var body: some View {
|
||||
DetailScaffold(
|
||||
title: "Personal Dictionary",
|
||||
subtitle: "Words GrammaGem should never “correct” — names, brands, jargon."
|
||||
subtitle: "Words GrammarGem should never “correct” — names, brands, jargon."
|
||||
) {
|
||||
Card {
|
||||
HStack {
|
||||
|
||||
@ -83,7 +83,7 @@ struct DevicesView: View {
|
||||
HStack {
|
||||
Link("Open license portal", destination: AppConfig.modelPortalURL)
|
||||
Spacer()
|
||||
Text("Add a Mac: install GrammaGem there and paste the same key.")
|
||||
Text("Add a Mac: install GrammarGem there and paste the same key.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,12 +8,12 @@ struct ExclusionsView: View {
|
||||
var body: some View {
|
||||
DetailScaffold(
|
||||
title: "Page Blocker",
|
||||
subtitle: "Keep GrammaGem out of the way in sensitive apps and sites."
|
||||
subtitle: "Keep GrammarGem out of the way in sensitive apps and sites."
|
||||
) {
|
||||
Card {
|
||||
Label("How it works", systemImage: "hand.raised")
|
||||
.font(.headline)
|
||||
Text("When the frontmost app or website is on these lists, GrammaGem stops monitoring and the hotkeys do nothing — useful for password managers, banking, and private work.")
|
||||
Text("When the frontmost app or website is on these lists, GrammarGem stops monitoring and the hotkeys do nothing — useful for password managers, banking, and private work.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
|
||||
@ -59,7 +59,7 @@ enum MainSection: String, CaseIterable, Identifiable {
|
||||
]
|
||||
}
|
||||
|
||||
/// The full GrammaGem management window: a clean sidebar + detail layout.
|
||||
/// The full GrammarGem management window: a clean sidebar + detail layout.
|
||||
struct MainWindowView: View {
|
||||
@State private var selection: MainSection?
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ struct ShortcutsView: View {
|
||||
combo: $prefs.fixHotkey)
|
||||
Divider()
|
||||
shortcutRow(
|
||||
name: "Ask GrammaGem",
|
||||
name: "Ask GrammarGem",
|
||||
detail: "Rewrite with an instruction",
|
||||
combo: $prefs.askHotkey)
|
||||
}
|
||||
@ -141,7 +141,7 @@ struct PrivacyView: View {
|
||||
}
|
||||
|
||||
Card {
|
||||
Text("The only times GrammaGem uses the network").font(.headline)
|
||||
Text("The only times GrammarGem uses the network").font(.headline)
|
||||
networkRow("One-time AI model download", "Hugging Face, on first run")
|
||||
Divider()
|
||||
networkRow("License activation & checks", "To enforce your device cap")
|
||||
|
||||
@ -15,7 +15,7 @@ struct WritingModesView: View {
|
||||
var body: some View {
|
||||
DetailScaffold(
|
||||
title: "Writing Modes",
|
||||
subtitle: "Tune the tone for every context — or let GrammaGem pick it for you."
|
||||
subtitle: "Tune the tone for every context — or let GrammarGem pick it for you."
|
||||
) {
|
||||
builtInCard
|
||||
customCard
|
||||
@ -88,7 +88,7 @@ struct WritingModesView: View {
|
||||
private var appAwareCard: some View {
|
||||
Card {
|
||||
HStack { Text("App-aware switching").font(.headline); if !paid { UpgradeBadge() } }
|
||||
Text("Map an app to a Mode and GrammaGem applies it automatically when that app is in front.")
|
||||
Text("Map an app to a Mode and GrammarGem applies it automatically when that app is in front.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
|
||||
if !customModes.appOverrides.isEmpty {
|
||||
|
||||
@ -49,7 +49,7 @@ struct MenuBarContent: View {
|
||||
actionRow(title: "Fix selection", shortcut: app.preferences.fixHotkey.display) {
|
||||
Task { await app.runFix() }
|
||||
}
|
||||
actionRow(title: "Ask GrammaGem…", shortcut: app.preferences.askHotkey.display) {
|
||||
actionRow(title: "Ask GrammarGem…", shortcut: app.preferences.askHotkey.display) {
|
||||
app.showAsk()
|
||||
}
|
||||
if app.gate.appAwareEnabled {
|
||||
@ -57,14 +57,14 @@ struct MenuBarContent: View {
|
||||
Task { await app.runAppAwareRewrite() }
|
||||
}
|
||||
}
|
||||
actionRow(title: app.isPaused ? "Resume GrammaGem" : "Pause GrammaGem", shortcut: nil) {
|
||||
actionRow(title: app.isPaused ? "Resume GrammarGem" : "Pause GrammarGem", shortcut: nil) {
|
||||
app.togglePause()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
actionRow(title: "Show live issues…", shortcut: nil) { app.showLiveCheck() }
|
||||
actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() }
|
||||
actionRow(title: "Open GrammarGem…", shortcut: nil) { app.showMainWindow() }
|
||||
actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) }
|
||||
actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
|
||||
actionRow(title: "Check for Updates…", shortcut: nil) { app.updater.checkForUpdates() }
|
||||
@ -90,7 +90,7 @@ struct MenuBarContent: View {
|
||||
.frame(width: 290)
|
||||
}
|
||||
|
||||
/// Shown when Sparkle has found a newer signed build on grammagem.app.
|
||||
/// Shown when Sparkle has found a newer signed build on grammargem.com.
|
||||
private func updateBanner(version: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.down.circle.fill").foregroundStyle(GG.emerald)
|
||||
@ -110,7 +110,7 @@ struct MenuBarContent: View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "pencil.and.scribble").foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("GrammaGem").font(.headline)
|
||||
Text("GrammarGem").font(.headline)
|
||||
Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free"))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// First-run onboarding: explains why GrammaGem needs Accessibility, deep-links
|
||||
/// First-run onboarding: explains why GrammarGem needs Accessibility, deep-links
|
||||
/// to System Settings, and advances automatically the moment permission is
|
||||
/// granted (even if granted directly in System Settings).
|
||||
struct OnboardingView: View {
|
||||
@ -18,7 +18,7 @@ struct OnboardingView: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Welcome to GrammaGem")
|
||||
Text("Welcome to GrammarGem")
|
||||
.font(.largeTitle.bold())
|
||||
Text("A private, on-device writing assistant that works in every app.")
|
||||
.foregroundStyle(.secondary)
|
||||
@ -36,7 +36,7 @@ struct OnboardingView: View {
|
||||
stepCard(
|
||||
number: 1,
|
||||
title: "Turn it on everywhere",
|
||||
body: "GrammaGem reads the text you select and writes the correction back. macOS requires Accessibility permission for this. Your text is processed on-device and never leaves your Mac.",
|
||||
body: "GrammarGem 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 {
|
||||
@ -47,7 +47,7 @@ struct OnboardingView: View {
|
||||
Button("Open System Settings") { permissions.openAccessibilitySettings() }
|
||||
Button("Request permission") { permissions.requestAccessibility() }
|
||||
}
|
||||
Text("This updates automatically once you flip GrammaGem on in Accessibility.")
|
||||
Text("This updates automatically once you flip GrammarGem on in Accessibility.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@ -114,7 +114,7 @@ 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.
|
||||
/// "guaranteed visible win" before GrammarGem 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] = []
|
||||
@ -151,7 +151,7 @@ private struct DemoStep: View {
|
||||
}
|
||||
|
||||
private var headline: String {
|
||||
if cleaned { return "Clean ✨ — GrammaGem does this in every app you type." }
|
||||
if cleaned { return "Clean ✨ — GrammarGem 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."
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ private struct GeneralSettings: View {
|
||||
Form {
|
||||
Section("Shortcuts") {
|
||||
LabeledContent("Fix selection", value: "⌘;")
|
||||
LabeledContent("Ask GrammaGem", value: "⌘'")
|
||||
LabeledContent("Ask GrammarGem", value: "⌘'")
|
||||
Text("Custom shortcuts coming via the KeyboardShortcuts recorder (see README).")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
@ -147,7 +147,7 @@ private struct DictionarySettings: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
TextField("Add a word GrammaGem should never correct", text: $newWord)
|
||||
TextField("Add a word GrammarGem should never correct", text: $newWord)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit(add)
|
||||
Button("Add", action: add).disabled(newWord.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
/// Compile-time configuration. The Lemon Squeezy identifiers are hard-coded so
|
||||
/// the licensing layer can *hard-verify* that a key belongs to GrammaGem (and to
|
||||
/// the licensing layer can *hard-verify* that a key belongs to GrammarGem (and to
|
||||
/// the claimed tier) rather than to some other Lemon Squeezy product.
|
||||
/// TODO(real-integration): replace every `TODO_*` value with your real IDs.
|
||||
enum AppConfig {
|
||||
static let appName = "GrammaGem"
|
||||
static let bundleIdentifier = "com.foundergem.grammagem" // TODO(real-integration)
|
||||
static let supportEmail = "support@grammagem.app" // TODO(real-integration)
|
||||
static let modelPortalURL = URL(string: "https://grammagem.app/license")! // TODO
|
||||
static let websiteURL = URL(string: "https://grammagem.app")!
|
||||
static let appName = "GrammarGem"
|
||||
static let bundleIdentifier = "com.foundergem.grammargem" // TODO(real-integration)
|
||||
static let supportEmail = "support@grammargem.com" // TODO(real-integration)
|
||||
static let modelPortalURL = URL(string: "https://www.grammargem.com/license")! // TODO
|
||||
static let websiteURL = URL(string: "https://www.grammargem.com")!
|
||||
static let appVersion = "0.1.0"
|
||||
|
||||
// Legal entity (shown in About / legal).
|
||||
|
||||
@ -72,7 +72,7 @@ final class FeatureGate: ObservableObject {
|
||||
/// Free-tier AI action counter. Persists `{date, count}` in UserDefaults and
|
||||
/// resets at local midnight (compares the stored local date string to today's).
|
||||
final class DailyCounter {
|
||||
private let key = "GrammaGem.aiActions.daily"
|
||||
private let key = "GrammarGem.aiActions.daily"
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
private static let formatter: DateFormatter = {
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
/// The user's personal dictionary — names, brand terms, jargon GrammaGem should
|
||||
/// The user's personal dictionary — names, brand terms, jargon GrammarGem should
|
||||
/// never "correct". Feeds Harper's ignore-list and the LLM context. Capped at 25
|
||||
/// entries on free, unlimited on paid (enforced by `FeatureGate`, not here).
|
||||
@MainActor
|
||||
final class PersonalDictionary: ObservableObject {
|
||||
@Published private(set) var entries: [String]
|
||||
|
||||
private let key = "GrammaGem.personalDictionary"
|
||||
private let key = "GrammarGem.personalDictionary"
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
init() {
|
||||
|
||||
@ -38,7 +38,7 @@ enum DeviceFingerprint {
|
||||
}
|
||||
|
||||
private static func fallbackID() -> String {
|
||||
let key = "GrammaGem.fallbackDeviceID"
|
||||
let key = "GrammarGem.fallbackDeviceID"
|
||||
if let existing = UserDefaults.standard.string(forKey: key) { return existing }
|
||||
let id = UUID().uuidString
|
||||
UserDefaults.standard.set(id, forKey: key)
|
||||
|
||||
@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
/// Talks to the **Lemon Squeezy License API** directly from the app — no server
|
||||
/// of ours, no database, no per-user cost (spec §6). Exposes activate / validate
|
||||
/// / deactivate and hard-verifies that a key belongs to GrammaGem + the claimed tier.
|
||||
/// / deactivate and hard-verifies that a key belongs to GrammarGem + the claimed tier.
|
||||
final class LemonSqueezyClient {
|
||||
private let session: URLSession
|
||||
private let base = AppConfig.LemonSqueezy.apiBase
|
||||
@ -54,7 +54,7 @@ final class LemonSqueezyClient {
|
||||
// MARK: - Hard verification (spec §6)
|
||||
|
||||
/// Confirm the key is for OUR store/product, then resolve which tier its
|
||||
/// variant corresponds to. Prevents a key from another LS product unlocking GrammaGem.
|
||||
/// variant corresponds to. Prevents a key from another LS product unlocking GrammarGem.
|
||||
private func verifyAndResolveTier(meta: LSMeta?, licenseKey: LSLicenseKey?) throws -> Tier {
|
||||
guard let meta else { throw LicenseError.wrongProduct }
|
||||
|
||||
|
||||
@ -62,7 +62,7 @@ struct LSLicenseKey: Codable {
|
||||
}
|
||||
|
||||
/// `meta` carries the store/product/variant identity we *hard-verify* against
|
||||
/// AppConfig so a key from another Lemon Squeezy product can't unlock GrammaGem.
|
||||
/// AppConfig so a key from another Lemon Squeezy product can't unlock GrammarGem.
|
||||
struct LSMeta: Codable {
|
||||
let store_id: Int?
|
||||
let product_id: Int?
|
||||
|
||||
@ -14,12 +14,12 @@ final class UsageStats: ObservableObject {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// honest, local "subscription you didn't pay" figure. GrammarGem is one-time.
|
||||
static let comparableAnnualSubscriptionUSD = 144.0
|
||||
|
||||
@Published private(set) var data: Snapshot
|
||||
|
||||
private let key = "GrammaGem.usageStats"
|
||||
private let key = "GrammarGem.usageStats"
|
||||
private let d = UserDefaults.standard
|
||||
|
||||
private static let fmt: DateFormatter = {
|
||||
@ -64,7 +64,7 @@ final class UsageStats: ObservableObject {
|
||||
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
|
||||
/// how long you've owned GrammarGem. 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(
|
||||
|
||||
@ -43,7 +43,7 @@ enum AX {
|
||||
///
|
||||
/// On recent macOS, `AXUIElementCreateSystemWide()` returns
|
||||
/// `kAXErrorCannotComplete` (-25204) for *every* query made by a background /
|
||||
/// accessory process (which is exactly what a menu-bar app like GrammaGem is).
|
||||
/// accessory process (which is exactly what a menu-bar app like GrammarGem is).
|
||||
/// When that happens the system-wide focused-element read yields nil and the
|
||||
/// app "sees" no text or text fields at all. Resolving the focused element
|
||||
/// through the **frontmost application's** own AX element works reliably in
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// The "page blocker": apps and domains where GrammaGem should stay completely
|
||||
/// The "page blocker": apps and domains where GrammarGem should stay completely
|
||||
/// out of the way — password managers, banking sites, anything sensitive.
|
||||
/// When the frontmost context is excluded, live monitoring and the hotkeys are
|
||||
/// suppressed.
|
||||
@ -10,8 +10,8 @@ final class Exclusions: ObservableObject {
|
||||
@Published private(set) var blockedApps: [String] // bundle identifiers
|
||||
@Published private(set) var blockedDomains: [String] // hostnames, e.g. "chase.com"
|
||||
|
||||
private let appsKey = "GrammaGem.blockedApps"
|
||||
private let domainsKey = "GrammaGem.blockedDomains"
|
||||
private let appsKey = "GrammarGem.blockedApps"
|
||||
private let domainsKey = "GrammarGem.blockedDomains"
|
||||
private let d = UserDefaults.standard
|
||||
|
||||
/// Common sensitive apps offered as one-tap suggestions in the UI.
|
||||
@ -31,7 +31,7 @@ final class Exclusions: ObservableObject {
|
||||
/// a browser's URL can't be read).
|
||||
var hasDomainRules: Bool { !blockedDomains.isEmpty }
|
||||
|
||||
/// Whether GrammaGem should be suppressed for this frontmost context.
|
||||
/// Whether GrammarGem should be suppressed for this frontmost context.
|
||||
func isBlocked(bundleID: String?, domain: String?) -> Bool {
|
||||
if let bundleID, blockedApps.contains(bundleID) { return true }
|
||||
if let host = domain?.lowercased(), !host.isEmpty {
|
||||
|
||||
@ -19,7 +19,7 @@ struct DetectedField: Identifiable {
|
||||
/// Performance & safety: the Accessibility-tree traversal (the part that can hang
|
||||
/// on a busy/wedged app) runs OFF the main thread with a per-message timeout; only
|
||||
/// the bounded spell/grammar pass and publish happen on the main actor. It never
|
||||
/// scans GrammaGem itself, respects the page blocker, and applies fixes against the
|
||||
/// scans GrammarGem itself, respects the page blocker, and applies fixes against the
|
||||
/// element's *live* value so it can't overwrite text typed since the last scan.
|
||||
@MainActor
|
||||
final class LiveMonitor: ObservableObject {
|
||||
@ -179,7 +179,7 @@ final class LiveMonitor: ObservableObject {
|
||||
if scanning && !force { return } // don't pile up scans
|
||||
guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return }
|
||||
|
||||
// Never scan GrammaGem's own windows.
|
||||
// Never scan GrammarGem's own windows.
|
||||
if app.processIdentifier == ownPID { clear(); return }
|
||||
activeAppName = app.localizedName ?? ""
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import Foundation
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
|
||||
/// Tracks and requests the macOS permissions GrammaGem needs to read & replace
|
||||
/// Tracks and requests the macOS permissions GrammarGem needs to read & replace
|
||||
/// text system-wide: **Accessibility** (required) and, for the synthetic-keystroke
|
||||
/// clipboard fallback, **Input Monitoring** may also be prompted by the system.
|
||||
@MainActor
|
||||
|
||||
@ -13,7 +13,7 @@ struct Snippet: Codable, Identifiable, Equatable {
|
||||
final class SnippetStore: ObservableObject {
|
||||
@Published private(set) var snippets: [Snippet]
|
||||
|
||||
private let key = "GrammaGem.snippets"
|
||||
private let key = "GrammarGem.snippets"
|
||||
private let d = UserDefaults.standard
|
||||
|
||||
init() {
|
||||
|
||||
@ -4,7 +4,7 @@ import ApplicationServices
|
||||
import Carbon.HIToolbox
|
||||
|
||||
/// Reads the user's selected text from the frontmost app and writes a corrected
|
||||
/// value back — the mechanism that makes GrammaGem work *everywhere*.
|
||||
/// value back — the mechanism that makes GrammarGem work *everywhere*.
|
||||
///
|
||||
/// Two paths, mirroring the spec (§4):
|
||||
/// 1. **Accessibility API** (preferred): read/write `kAXSelectedTextAttribute`
|
||||
@ -27,7 +27,7 @@ final class TextCapture {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noSelection: return "Select some text first, then press the hotkey."
|
||||
case .notPermitted: return "GrammaGem needs Accessibility permission to read and replace text."
|
||||
case .notPermitted: return "GrammarGem needs Accessibility permission to read and replace text."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ struct ScannedField {
|
||||
|
||||
/// Walks the Accessibility tree of the frontmost window and collects every
|
||||
/// editable text element (text fields, text areas, combo/search boxes) with
|
||||
/// content — so GrammaGem can check *all* the text on screen, not just the one
|
||||
/// content — so GrammarGem can check *all* the text on screen, not just the one
|
||||
/// the cursor is in. Bounded so huge web/Electron trees can't stall the app, and
|
||||
/// every cross-process message has a timeout so a wedged app can't hang the scan.
|
||||
///
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import Sparkle
|
||||
|
||||
/// Wraps Sparkle's updater. It background-checks the appcast on grammagem.app and
|
||||
/// Wraps Sparkle's updater. It background-checks the appcast on grammargem.com and
|
||||
/// publishes `updateAvailableVersion` when a newer, EdDSA-verified build exists, so
|
||||
/// the menu bar can show an "Update available" button. Triggering an update runs
|
||||
/// Sparkle's verified download + install flow.
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import XCTest
|
||||
@testable import GrammaGem
|
||||
@testable import GrammarGem
|
||||
|
||||
final class GrammaGemTests: XCTestCase {
|
||||
final class GrammarGemTests: XCTestCase {
|
||||
|
||||
// MARK: - Tiers / entitlements
|
||||
|
||||
@ -117,10 +117,10 @@ final class GrammaGemTests: XCTestCase {
|
||||
/// MLX integration end-to-end.
|
||||
func testMLXRealGenerationIfModelPresent() async throws {
|
||||
// Opt-in: `swift test` (CLI) can't place mlx-swift's metallib, so this is
|
||||
// gated. Run it via the app's `--mlx-selftest`, or set GRAMMAGEM_MLX_TEST=1
|
||||
// gated. Run it via the app's `--mlx-selftest`, or set GRAMMARGEM_MLX_TEST=1
|
||||
// with the metallib colocated. See scripts/build-metallib.sh.
|
||||
guard ProcessInfo.processInfo.environment["GRAMMAGEM_MLX_TEST"] == "1" else {
|
||||
throw XCTSkip("set GRAMMAGEM_MLX_TEST=1 (metallib colocated) to run the MLX integration test")
|
||||
guard ProcessInfo.processInfo.environment["GRAMMARGEM_MLX_TEST"] == "1" else {
|
||||
throw XCTSkip("set GRAMMARGEM_MLX_TEST=1 (metallib colocated) to run the MLX integration test")
|
||||
}
|
||||
guard let dir = ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo) else {
|
||||
throw XCTSkip("on-device model not downloaded; skipping MLX integration test")
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Grammarly Value Teardown -> GrammaGem Plan
|
||||
# Grammarly Value Teardown -> GrammarGem 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`.*
|
||||
*Senior product + Swift engineering review. Every claim below is grounded in the current GrammarGem source. File references use absolute paths; the load-bearing bug is the mis-casing in `SystemGrammarEngine.swift:42-45`.*
|
||||
|
||||
---
|
||||
|
||||
@ -8,34 +8,34 @@
|
||||
|
||||
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.
|
||||
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. GrammarGem'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).
|
||||
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. GrammarGem 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.
|
||||
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. GrammarGem 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.
|
||||
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. GrammarGem'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.
|
||||
5. **Ongoing perceived value without surveillance: progress you can see.** Grammarly uses cloud percentiles + weekly behavioral emails. GrammarGem 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."
|
||||
The strategic asymmetry to exploit throughout: every Grammarly value moment carries an implicit "and you'll pay again next year." GrammarGem'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
|
||||
## 2. Mechanic-by-mechanic: how Grammarly does it -> how GrammarGem 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`).
|
||||
- **GrammarGem 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.
|
||||
- **GrammarGem 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`.
|
||||
@ -47,9 +47,9 @@ The strategic asymmetry to exploit throughout: every Grammarly value moment carr
|
||||
|
||||
### 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**.
|
||||
- **GrammarGem 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).
|
||||
- Introduce GrammarGem'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.
|
||||
@ -59,18 +59,18 @@ The strategic asymmetry to exploit throughout: every Grammarly value moment carr
|
||||
|
||||
### 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.
|
||||
- **GrammarGem 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.
|
||||
- **GrammarGem 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.
|
||||
- **"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 GrammarGem 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.
|
||||
@ -79,7 +79,7 @@ The strategic asymmetry to exploit throughout: every Grammarly value moment carr
|
||||
|
||||
### 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.
|
||||
- **GrammarGem 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."
|
||||
@ -133,7 +133,7 @@ Wire dismiss / add-to-dictionary (Mechanic 2) back into ranking so repeated fals
|
||||
|
||||
---
|
||||
|
||||
## 4. The first-run "wow" sequence for GrammaGem (original copy)
|
||||
## 4. The first-run "wow" sequence for GrammarGem (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.
|
||||
|
||||
@ -145,7 +145,7 @@ Proposed flow:
|
||||
|
||||
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."
|
||||
4. **The reveal:** "That's GrammarGem. 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.
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ A thin Rust static library that exposes the **Harper** grammar core
|
||||
integration (spec §1, §2, §8).
|
||||
|
||||
The Swift side talks to this through the `GrammarEngine` protocol; today
|
||||
[`HarperEngine.swift`](../Sources/GrammaGem/Grammar/HarperEngine.swift) ships a
|
||||
[`HarperEngine.swift`](../Sources/GrammarGem/Grammar/HarperEngine.swift) ships a
|
||||
small pure-Swift rule stub so the system loop + tests run with zero native deps.
|
||||
|
||||
## Planned shape
|
||||
|
||||
@ -15,7 +15,7 @@ cd "$ROOT"
|
||||
DD=".xcode-build"
|
||||
|
||||
echo "==> xcodebuild (compiles mlx-swift metal kernels — a few minutes)"
|
||||
xcodebuild build -scheme GrammaGem -configuration Release \
|
||||
xcodebuild build -scheme GrammarGem -configuration Release \
|
||||
-destination 'platform=macOS,arch=arm64' \
|
||||
-derivedDataPath "$DD" -skipMacroValidation >/dev/null
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a runnable GrammaGem.app bundle (arm64 / Apple Silicon — the MLX
|
||||
# Build a runnable GrammarGem.app bundle (arm64 / Apple Silicon — the MLX
|
||||
# on-device LLM runtime is Metal-only, so Intel is not supported).
|
||||
# Usage: ./scripts/build.sh (run from the mac/ directory)
|
||||
set -euo pipefail
|
||||
@ -8,7 +8,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
CONFIG="release"
|
||||
APP="GrammaGem.app"
|
||||
APP="GrammarGem.app"
|
||||
DIST="dist"
|
||||
|
||||
echo "==> Building Harper static lib (universal)"
|
||||
@ -24,7 +24,7 @@ echo "==> Assembling ${APP}"
|
||||
rm -rf "${DIST}/${APP}"
|
||||
mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources"
|
||||
|
||||
cp "${BIN}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem"
|
||||
cp "${BIN}/GrammarGem" "${DIST}/${APP}/Contents/MacOS/GrammarGem"
|
||||
cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist"
|
||||
cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns"
|
||||
|
||||
@ -52,8 +52,8 @@ SPARKLE_FW="$(find .build -path '*Sparkle.xcframework/macos-arm64*/Sparkle.frame
|
||||
mkdir -p "${DIST}/${APP}/Contents/Frameworks"
|
||||
cp -R "${SPARKLE_FW}" "${DIST}/${APP}/Contents/Frameworks/"
|
||||
install_name_tool -add_rpath "@executable_path/../Frameworks" \
|
||||
"${DIST}/${APP}/Contents/MacOS/GrammaGem" 2>/dev/null || true
|
||||
"${DIST}/${APP}/Contents/MacOS/GrammarGem" 2>/dev/null || true
|
||||
|
||||
echo "==> Built ${DIST}/${APP}"
|
||||
lipo -info "${DIST}/${APP}/Contents/MacOS/GrammaGem"
|
||||
lipo -info "${DIST}/${APP}/Contents/MacOS/GrammarGem"
|
||||
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env swift
|
||||
// Generates AppIcon.iconset (PNGs) for GrammaGem — a gold pen-nib on the ink
|
||||
// Generates AppIcon.iconset (PNGs) for GrammarGem — a gold pen-nib on the ink
|
||||
// brand square. Run from mac/: swift scripts/make-icon.swift
|
||||
// Then: iconutil -c icns AppIcon.iconset -o AppSupport/AppIcon.icns
|
||||
import AppKit
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cut a GrammaGem release and publish the Sparkle appcast (hosted on grammagem.app).
|
||||
# Cut a GrammarGem release and publish the Sparkle appcast (hosted on grammargem.com).
|
||||
#
|
||||
# ./scripts/release.sh <version> e.g. ./scripts/release.sh 0.2.0
|
||||
#
|
||||
# Steps: stamp version -> build -> sign inside-out (incl. Sparkle) -> notarize +
|
||||
# staple -> zip -> generate the EdDSA-signed appcast.xml. Then upload the .zip and
|
||||
# appcast.xml to grammagem.app so every installed app shows "Update available".
|
||||
# appcast.xml to grammargem.com so every installed app shows "Update available".
|
||||
#
|
||||
# Notarization needs ONE of:
|
||||
# NOTARY_PROFILE (xcrun notarytool store-credentials)
|
||||
@ -17,10 +17,10 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
VERSION="${1:?usage: release.sh <version> e.g. release.sh 0.2.0}"
|
||||
APP="dist/GrammaGem.app"
|
||||
APP="dist/GrammarGem.app"
|
||||
RELDIR="dist/releases"
|
||||
ZIP="${RELDIR}/GrammaGem-${VERSION}.zip"
|
||||
DL_PREFIX="${DL_PREFIX:-https://grammagem.app/releases/}"
|
||||
ZIP="${RELDIR}/GrammarGem-${VERSION}.zip"
|
||||
DL_PREFIX="${DL_PREFIX:-https://www.grammargem.com/releases/}"
|
||||
PB=/usr/libexec/PlistBuddy
|
||||
|
||||
# 1. Stamp the marketing version + bump the monotonic build number.
|
||||
@ -61,11 +61,11 @@ GA="$(find .build/artifacts -name generate_appcast -type f | head -1)"
|
||||
"$GA" --download-url-prefix "$DL_PREFIX" "$RELDIR"
|
||||
|
||||
echo
|
||||
echo "✓ Release ${VERSION} built. Upload these to grammagem.app:"
|
||||
echo "✓ Release ${VERSION} built. Upload these to grammargem.com:"
|
||||
echo " ${ZIP}"
|
||||
echo " -> ${DL_PREFIX}GrammaGem-${VERSION}.zip"
|
||||
echo " -> ${DL_PREFIX}GrammarGem-${VERSION}.zip"
|
||||
echo " ${RELDIR}/appcast.xml"
|
||||
echo " -> https://grammagem.app/appcast.xml (matches Info.plist SUFeedURL)"
|
||||
echo " -> https://www.grammargem.com/appcast.xml (matches Info.plist SUFeedURL)"
|
||||
echo
|
||||
echo " Once uploaded, installed apps show an 'Update available' button within a day"
|
||||
echo " (or immediately via menu bar -> Check for Updates...)."
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Code-sign GrammaGem.app with Developer ID + Hardened Runtime, signing the
|
||||
# Code-sign GrammarGem.app with Developer ID + Hardened Runtime, signing the
|
||||
# embedded Sparkle.framework's nested helpers INSIDE-OUT first (required for a
|
||||
# valid, notarizable signature).
|
||||
#
|
||||
# Usage: ./scripts/sign-app.sh [path/to/GrammaGem.app]
|
||||
# Usage: ./scripts/sign-app.sh [path/to/GrammarGem.app]
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
|
||||
ENTITLEMENTS="AppSupport/GrammaGem.entitlements"
|
||||
APP="${1:-dist/GrammaGem.app}"
|
||||
ENTITLEMENTS="AppSupport/GrammarGem.entitlements"
|
||||
APP="${1:-dist/GrammarGem.app}"
|
||||
|
||||
sign() { codesign --force --timestamp --options runtime --sign "$DEV_ID" "$@"; }
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build (universal), codesign (Hardened Runtime + Developer ID), notarize, and
|
||||
# staple GrammaGem.app. Requires the Apple Developer Program ($99/yr) — a FIXED
|
||||
# staple GrammarGem.app. Requires the Apple Developer Program ($99/yr) — a FIXED
|
||||
# cost, not per-user.
|
||||
#
|
||||
# Credentials are read from the environment or a stored keychain profile — never
|
||||
@ -15,9 +15,9 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
|
||||
APP="dist/GrammaGem.app"
|
||||
ZIP="dist/GrammaGem.zip"
|
||||
ENTITLEMENTS="AppSupport/GrammaGem.entitlements"
|
||||
APP="dist/GrammarGem.app"
|
||||
ZIP="dist/GrammarGem.zip"
|
||||
ENTITLEMENTS="AppSupport/GrammarGem.entitlements"
|
||||
|
||||
echo "==> Building universal bundle"
|
||||
./scripts/build.sh >/dev/null
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user