diff --git a/AppSupport/Info.plist b/AppSupport/Info.plist
index e11b17c..dd71d27 100644
--- a/AppSupport/Info.plist
+++ b/AppSupport/Info.plist
@@ -36,7 +36,7 @@
LSUIElement
NSHumanReadableCopyright
- © 2026 GrammaGem. Built in public.
+ © 2026 FounderGem LLC. Built in public.
NSPrincipalClass
NSApplication
diff --git a/LICENSE b/LICENSE
index 1f39cd7..4caaaf6 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2026 GrammaGem
+Copyright (c) 2026 FounderGem LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 59a9136..c4b5b01 100644
--- a/README.md
+++ b/README.md
@@ -1,125 +1,102 @@
-# GrammaGem (macOS app)
+
-A private, **on-device** writing assistant for macOS — a one-time-purchase
-replacement for Grammarly. Fix grammar, sharpen tone, and rewrite anything in
-**any app** via a global hotkey. Your words never leave your Mac.
+# ✒️ GrammaGem
-Built in public. The grammar core is open-source; the repo is permissively
-licensed. We sell the **signed, notarized, auto-updating build + a multi-device
-license + support** — not code secrecy.
+### Write it once. Get it right.
+
+**The private, on-device writing assistant for Mac.**
+Fix grammar, sharpen tone, and rewrite anything — in every app — for one payment, forever.
+
+
+
+
+
+
+[**⬇️ Download GrammaGem**](https://grammagem.app/#pricing) · [Docs](https://grammagem.app/docs) · [How it works](https://grammagem.app)
+
+
---
-## Architecture (two layers)
+## Stop renting your grammar checker.
-```
-GrammaGem (native menu-bar app, Swift/SwiftUI)
-├─ System integration global hotkey → capture selected text (AX API /
-│ clipboard fallback) → route to engine → replace in place
-├─ Layer 1: GRAMMAR Harper (Rust, Apache-2.0) — spelling/grammar/punctuation,
-│ deterministic, <10ms, offline. Powers live underlines. FREE.
-├─ Layer 2: AI MLX (Apple Silicon) + a small instruct model — rewrite,
-│ tone, "Ask", translate, Mode rewriting. Closes the gap. PAID.
-└─ Entitlements + Licensing free/paid gate · device-cap activation (Lemon Squeezy)
-```
+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**.
-Network is touched **only** for: first-run model download (Hugging Face),
-license activate/validate (Lemon Squeezy), and update checks (Sparkle/GitHub
-Releases). **Writing never requires internet. No telemetry.**
+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**.
-## Repo layout
+> “One payment and it’s mine forever — such a bargain.” 👍
-```
-mac/
-├─ Package.swift SwiftPM executable (Apple-frameworks only → builds out of the box)
-├─ Sources/GrammaGem/
-│ ├─ App/ @main app, menu bar, onboarding, settings, Ask popover, AppState
-│ ├─ System/ hotkey (Carbon), AX capture/replace + clipboard fallback, app detection, permissions
-│ ├─ Grammar/ GrammarEngine protocol + Harper stub + personal dictionary
-│ ├─ AI/ AIEngine protocol + MLX stub + model manager + Writing Modes
-│ ├─ Licensing/ Lemon Squeezy client, device fingerprint, Keychain, license manager
-│ ├─ Entitlements/ free/paid gate + daily AI counter
-│ └─ Core/ config, models, logging
-├─ Tests/GrammaGemTests/ unit tests (entitlements, Harper rules, fingerprint, modes)
-├─ Resources/Modes/ Mode prompt presets (paid ones tuned in release only)
-├─ AppSupport/ Info.plist (LSUIElement) + entitlements
-├─ scripts/ build → sign/notarize → release(Sparkle/GitHub)
-└─ harper-ffi/ Rust↔Swift FFI for the real Harper core (placeholder)
-```
+---
-## Build & run
+## Why people switch
-```bash
-cd mac
-swift build # compiles the app (Apple frameworks only)
-swift test # runs the unit tests
-swift run GrammaGem # runs as a menu-bar (accessory) app
+| | Grammarly | **GrammaGem** |
+|---|:---:|:---:|
+| **Price** | ~$144 / year, forever | **Pay once** |
+| **Your words** | Sent to the cloud | **Never leave your Mac** |
+| **Works offline** | ❌ | ✅ |
+| **Where it works** | Where the extension reaches | **Every app, system-wide** |
+| **Speed** | Round-trip to a server | **Instant, on-device** |
-# Package a .app bundle you can double-click:
-./scripts/build.sh # → dist/GrammaGem.app
-```
+---
-On first launch, grant **Accessibility** when prompted (System Settings →
-Privacy & Security → Accessibility). Without it the app degrades gracefully to a
-manual paste-in window. Default hotkeys: **⌘;** fix selection, **⌘'** Ask.
+## What you get
-> Prefer Xcode? Open `Package.swift` directly in Xcode 26 (File → Open). For a
-> production app target, point it at `AppSupport/Info.plist` + entitlements.
+- ⚡ **Instant corrections** — grammar, spelling, punctuation, and clarity, fixed the moment you ask.
+- 🔒 **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.
+- 📖 **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.
-## What's real vs. stubbed
+---
-This codebase implements the full **architecture and product logic** end-to-end,
-with the heavyweight native integrations behind clean protocol seams so it
-compiles with **zero external dependencies**. Search `TODO(real-integration)`.
+## Buy once. Write forever.
-| Area | Status |
-|------|--------|
-| System-wide capture → transform → replace loop | ✅ Real (AX + clipboard fallback, CGEvent ⌘C/⌘V, restore clipboard) |
-| Global hotkeys | ✅ Real (Carbon `RegisterEventHotKey`) |
-| Active-app detection + Mode mapping | ✅ Real (`NSWorkspace`) |
-| Permissions onboarding | ✅ Real (`AXIsProcessTrusted`, deep-links, auto-advance) |
-| Entitlements gate + daily AI counter (resets local midnight) | ✅ Real |
-| Device fingerprint (IOPlatformUUID → SHA-256) | ✅ Real (IOKit + CryptoKit) |
-| Keychain license storage | ✅ Real (Security framework) |
-| Lemon Squeezy activate/validate/deactivate + hard-verify + offline grace | ✅ Real client (needs your store/product/variant IDs) |
-| Personal dictionary (25 free / unlimited paid) | ✅ Real |
-| Menu bar UI, Settings, Ask popover | ✅ Real (SwiftUI) |
-| **Harper grammar core** | 🔌 Stub: small pure-Swift rule set; swap for the Rust FFI (`harper-ffi/`) |
-| **MLX local LLM** | 🔌 Stub: deterministic transforms; swap for `mlx-swift` + downloaded model |
-| **Model download** | 🔌 Stub: simulated progress; swap for real Hugging Face streaming |
-| KeyboardShortcuts recorder UI / Sparkle auto-update | 🔌 Documented seams (scripts + Package.swift comments) |
+No subscription. No per-seat fees. One payment, lifetime updates.
-### Before shipping
-1. Set real IDs in [`Core/AppConfig.swift`](Sources/GrammaGem/Core/AppConfig.swift):
- Lemon Squeezy `expectedStoreID` / `expectedProductID` / per-tier `variantID`
- (set each variant's **activation limit** to the device cap: Solo 1 / Personal 2 / Studio 4),
- `bundleIdentifier`, `supportEmail`, model portal URL.
-2. Replace the Harper stub with the FFI (`harper-ffi/README.md`).
-3. Replace the MLX stub + model manager with `mlx-swift` and real HF download.
-4. Add `KeyboardShortcuts` + `Sparkle` SwiftPM deps (commented in `Package.swift`).
-5. Codesign + notarize via `scripts/sign-notarize.sh` (needs Apple Developer Program).
+| Plan | Price | Devices |
+|------|:-----:|:-------:|
+| **Solo** | $39 | 1 Mac |
+| **Personal** ⭐ | $59 | 2 Macs |
+| **Studio** | $89 | 4 Macs |
+| **Startup License** | $159 | 10 Macs |
-## Honest gaps (by design)
+*Apple Silicon · macOS 14+ · 14-day money-back guarantee, no questions asked.*
-- **No plagiarism detection.** It needs a server-side corpus + live index, which
- breaks both the $0-cost rule and the privacy promise. Out of scope, not faked.
-- **The gate is intentionally soft.** Because the code is public, a technical
- user could compile a build with the gate removed — that's the VoiceInk model
- and it's fine. The product is the signed build + license + support, not secrecy.
+
-## Cost model — $0 marginal cost per customer
+[**⬇️ Get GrammaGem**](https://grammagem.app/#pricing)
-| Item | Cost |
-|------|------|
-| Server / database | $0 (none exists) |
-| AI inference (on device) | $0 |
-| Model hosting (Hugging Face) | $0 |
-| Update hosting (GitHub Releases) | $0 |
-| License validation (Lemon Squeezy) | $0 (included) |
-| Apple Developer Program | $99 / year (fixed) |
-| Payment processor | ~5% + fee per sale |
+
-## License
+---
-Permissive (MIT/Apache-2.0) to maximize trust and stars. Monetization is the
-signed binary + multi-device license, not the source.
+## FAQ
+
+**Is my writing really private?**
+Yes. Every correction and rewrite runs on your Mac. Your text is never uploaded, logged, or used for training.
+
+**Do I need internet?**
+No. Core features work fully offline.
+
+**Is this a subscription?**
+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.
+
+---
+
+## 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.
+
+
+
+**[grammagem.app](https://grammagem.app)**
+
+© 2026 FounderGem LLC · 1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA
+
+
diff --git a/Sources/GrammaGem/AI/CustomModesStore.swift b/Sources/GrammaGem/AI/CustomModesStore.swift
new file mode 100644
index 0000000..9c15c09
--- /dev/null
+++ b/Sources/GrammaGem/AI/CustomModesStore.swift
@@ -0,0 +1,71 @@
+import Foundation
+
+/// User-created Writing Modes (paid) plus per-app Mode overrides for App-Aware
+/// switching. Persisted locally; the built-in modes live in `ModeRegistry`.
+@MainActor
+final class CustomModesStore: ObservableObject {
+ @Published private(set) var customModes: [WritingMode]
+ /// 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 d = UserDefaults.standard
+
+ init() {
+ if let raw = d.data(forKey: modesKey),
+ let decoded = try? JSONDecoder().decode([WritingMode].self, from: raw) {
+ customModes = decoded
+ } else {
+ customModes = []
+ }
+ appOverrides = (d.dictionary(forKey: mapKey) as? [String: String]) ?? [:]
+ }
+
+ /// Built-in + custom modes (custom ones are always paid).
+ func allModes() -> [WritingMode] { ModeRegistry.all + customModes }
+
+ func addCustom(name: String, prompt: String) {
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ let mode = WritingMode(
+ id: "custom-\(UUID().uuidString.prefix(8))",
+ name: trimmed,
+ systemPrompt: prompt.trimmingCharacters(in: .whitespacesAndNewlines),
+ isPaid: true,
+ lengthCap: nil,
+ autoFormat: "Custom"
+ )
+ customModes.append(mode)
+ persistModes()
+ }
+
+ func removeCustom(id: String) {
+ customModes.removeAll { $0.id == id }
+ appOverrides = appOverrides.filter { $0.value != id }
+ persistModes()
+ persistMap()
+ }
+
+ func setOverride(bundleID: String, modeID: String) {
+ let b = bundleID.trimmingCharacters(in: .whitespaces)
+ guard !b.isEmpty else { return }
+ appOverrides[b] = modeID
+ persistMap()
+ }
+
+ func clearOverride(bundleID: String) {
+ appOverrides.removeValue(forKey: bundleID)
+ persistMap()
+ }
+
+ private func persistModes() {
+ if let raw = try? JSONEncoder().encode(customModes) {
+ d.set(raw, forKey: modesKey)
+ }
+ }
+
+ private func persistMap() {
+ d.set(appOverrides, forKey: mapKey)
+ }
+}
diff --git a/Sources/GrammaGem/App/AppState.swift b/Sources/GrammaGem/App/AppState.swift
index 1ddb00a..fbd5219 100644
--- a/Sources/GrammaGem/App/AppState.swift
+++ b/Sources/GrammaGem/App/AppState.swift
@@ -20,6 +20,10 @@ final class AppState: ObservableObject {
let detector: AppDetector
let hotkeys: HotkeyManager
let dictionary: PersonalDictionary
+ let preferences: Preferences
+ let usage: UsageStats
+ let customModes: CustomModesStore
+ let snippets: SnippetStore
private let capture: TextCapture
let coordinator: TextReplacementCoordinator
@@ -30,6 +34,7 @@ final class AppState: ObservableObject {
private var onboardingWindow: NSWindow?
private var askWindow: NSWindow?
+ private var mainWindow: NSWindow?
private init() {
let harper = HarperEngine()
@@ -43,6 +48,10 @@ final class AppState: ObservableObject {
capture = TextCapture()
hotkeys = HotkeyManager()
dictionary = PersonalDictionary()
+ preferences = Preferences()
+ usage = UsageStats()
+ customModes = CustomModesStore()
+ snippets = SnippetStore()
coordinator = TextReplacementCoordinator(
capture: capture, grammar: harper, ai: ai, gate: gate, detector: detector)
}
@@ -81,16 +90,24 @@ final class AppState: ObservableObject {
}
func runFix() async {
- present(await coordinator.handleFix())
+ let outcome = await coordinator.handleFix()
+ if case .replaced(let text) = outcome { usage.recordCorrection(words: wordCount(text)) }
+ present(outcome)
}
func runAsk() async {
let instruction = askText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instruction.isEmpty else { return }
- present(await coordinator.handleAsk(instruction))
+ let outcome = await coordinator.handleAsk(instruction)
+ if case .replaced(let text) = outcome { usage.recordAIAction(words: wordCount(text)) }
+ present(outcome)
askWindow?.close()
}
+ private func wordCount(_ s: String) -> Int {
+ s.split(whereSeparator: { $0.isWhitespace || $0.isNewline }).count
+ }
+
/// Apply the Mode suggested by the frontmost app (App-Aware, paid).
func runAppAwareRewrite() async {
guard gate.appAwareEnabled else {
@@ -101,7 +118,9 @@ final class AppState: ObservableObject {
lastStatus = "No mode mapped for the current app."
return
}
- present(await coordinator.handleApplyMode(mode))
+ let outcome = await coordinator.handleApplyMode(mode)
+ if case .replaced(let text) = outcome { usage.recordAIAction(words: wordCount(text)) }
+ present(outcome)
}
private func present(_ outcome: ProcessOutcome) {
@@ -145,6 +164,34 @@ final class AppState: ObservableObject {
onboardingWindow?.makeKeyAndOrderFront(nil)
}
+ /// The full management window (devices, modes, settings, insights).
+ func showMainWindow(select section: MainSection? = nil) {
+ if mainWindow == nil {
+ let root = MainWindowView(initialSection: section ?? .dashboard)
+ .environmentObject(self)
+ .environmentObject(license)
+ .environmentObject(gate)
+ .environmentObject(model)
+ .environmentObject(permissions)
+ .environmentObject(dictionary)
+ .environmentObject(preferences)
+ .environmentObject(usage)
+ .environmentObject(customModes)
+ .environmentObject(snippets)
+ let host = NSHostingController(rootView: root)
+ let win = NSWindow(contentViewController: host)
+ win.title = "GrammaGem"
+ win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
+ win.setContentSize(NSSize(width: 940, height: 660))
+ win.minSize = NSSize(width: 820, height: 560)
+ win.isReleasedWhenClosed = false
+ win.center()
+ mainWindow = win
+ }
+ NSApp.activate(ignoringOtherApps: true)
+ mainWindow?.makeKeyAndOrderFront(nil)
+ }
+
func showAsk() {
askText = ""
if askWindow == nil {
diff --git a/Sources/GrammaGem/App/MainWindow/AccountViews.swift b/Sources/GrammaGem/App/MainWindow/AccountViews.swift
new file mode 100644
index 0000000..6304145
--- /dev/null
+++ b/Sources/GrammaGem/App/MainWindow/AccountViews.swift
@@ -0,0 +1,167 @@
+import SwiftUI
+
+// MARK: - General
+
+struct GeneralView: View {
+ @EnvironmentObject private var app: AppState
+ @EnvironmentObject private var prefs: Preferences
+ @EnvironmentObject private var permissions: Permissions
+
+ var body: some View {
+ DetailScaffold(title: "General", subtitle: "Make GrammaGem feel like yours.") {
+ Card {
+ Toggle("Launch at login", isOn: $prefs.launchAtLogin)
+ Divider()
+ Toggle("Show live underlines as you type", isOn: $prefs.liveUnderlines)
+ Divider()
+ Toggle("Restore my clipboard after a fix", isOn: $prefs.restoreClipboard)
+ Divider()
+ Toggle("Play a sound on correction", isOn: $prefs.playSounds)
+ }
+
+ Card {
+ Picker("Appearance", selection: $prefs.appearance) {
+ ForEach(Preferences.Appearance.allCases) { Text($0.label).tag($0) }
+ }
+ Picker("Menu bar", selection: $prefs.menuBarStyle) {
+ ForEach(Preferences.MenuBarStyle.allCases) { Text($0.label).tag($0) }
+ }
+ }
+
+ Card {
+ Text("Permissions").font(.headline)
+ HStack {
+ Image(systemName: permissions.accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
+ .foregroundStyle(permissions.accessibilityTrusted ? GG.emerald : .orange)
+ Text(permissions.accessibilityTrusted ? "Accessibility granted" : "Accessibility needed to fix text in apps")
+ Spacer()
+ if !permissions.accessibilityTrusted {
+ Button("Set up") { app.showOnboarding() }
+ }
+ }
+ }
+ }
+ }
+}
+
+// MARK: - License
+
+struct LicenseView: View {
+ @EnvironmentObject private var app: AppState
+ @EnvironmentObject private var license: LicenseManager
+ @State private var keyField = ""
+
+ private let plans: [(String, Int, String)] = [
+ ("Solo", 39, "1 Mac"),
+ ("Personal", 59, "2 Macs"),
+ ("Studio", 89, "4 Macs"),
+ ("Startup License", 159, "10 Macs"),
+ ]
+
+ var body: some View {
+ DetailScaffold(title: "License", subtitle: "One-time purchase. Lifetime updates.") {
+ Card {
+ HStack {
+ VStack(alignment: .leading) {
+ Text(license.tier.displayName).font(.title3.weight(.semibold))
+ Text(license.isLicensed ? "Lifetime license · active" : "Free plan")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ Image(systemName: license.isLicensed ? "checkmark.seal.fill" : "gift")
+ .font(.title).foregroundStyle(license.isLicensed ? GG.emerald : GG.gold)
+ }
+ }
+
+ if license.isLicensed {
+ Card {
+ Text("Manage").font(.headline)
+ Button(role: .destructive) {
+ Task { await license.deactivateThisDevice() }
+ } label: { Text(license.isWorking ? "Working…" : "Deactivate this device") }
+ .disabled(license.isWorking)
+ Link("Open license portal", destination: AppConfig.modelPortalURL)
+ }
+ } else {
+ Card {
+ Text("Activate a license").font(.headline)
+ HStack {
+ TextField("Paste your license key", text: $keyField).textFieldStyle(.roundedBorder)
+ Button(license.isWorking ? "…" : "Activate") {
+ Task { await license.activate(key: keyField) }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(license.isWorking || keyField.isEmpty)
+ }
+ }
+ Card {
+ Text("Plans").font(.headline)
+ ForEach(plans, id: \.0) { name, price, devices in
+ HStack {
+ Text(name).fontWeight(name == "Personal" ? .semibold : .regular)
+ if name == "Personal" {
+ Text("Popular").font(.caption2.weight(.semibold))
+ .padding(.horizontal, 6).padding(.vertical, 2)
+ .background(GG.gold.opacity(0.18), in: Capsule()).foregroundStyle(GG.gold)
+ }
+ Spacer()
+ Text(devices).font(.caption).foregroundStyle(.secondary)
+ Text("$\(price)").font(.body.weight(.semibold)).frame(width: 52, alignment: .trailing)
+ }
+ if name != plans.last?.0 { Divider() }
+ }
+ Link("Choose a plan", destination: AppConfig.websiteURL)
+ .buttonStyle(.borderedProminent)
+ }
+ }
+
+ if let err = license.lastError {
+ Label(err, systemImage: "exclamationmark.triangle").font(.callout).foregroundStyle(.orange)
+ }
+ }
+ }
+}
+
+// MARK: - About
+
+struct AboutView: View {
+ var body: some View {
+ DetailScaffold(title: "About", subtitle: nil) {
+ Card {
+ HStack(spacing: 14) {
+ Image(systemName: "pencil.and.scribble")
+ .font(.system(size: 34)).foregroundStyle(GG.emerald)
+ .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("Version \(AppConfig.appVersion)").foregroundStyle(.secondary)
+ Text("The private, on-device writing assistant for Mac.")
+ .font(.callout).foregroundStyle(.secondary)
+ }
+ Spacer()
+ }
+ }
+
+ Card {
+ Link(destination: AppConfig.websiteURL) { Label("Website", systemImage: "globe") }
+ Divider()
+ Link(destination: AppConfig.websiteURL.appendingPathComponent("docs")) { Label("Documentation", systemImage: "book") }
+ Divider()
+ Link(destination: URL(string: "mailto:\(AppConfig.supportEmail)")!) { Label("Contact support", systemImage: "envelope") }
+ }
+
+ Card {
+ Text("Open source").font(.headline)
+ Text("GrammaGem is open source under the MIT License — a privacy tool you can inspect.")
+ .font(.callout).foregroundStyle(.secondary)
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(AppConfig.copyright).font(.caption).foregroundStyle(.secondary)
+ Text(AppConfig.companyName + " · " + AppConfig.companyAddress)
+ .font(.caption2).foregroundStyle(.secondary)
+ }
+ }
+ }
+}
diff --git a/Sources/GrammaGem/App/MainWindow/DashboardView.swift b/Sources/GrammaGem/App/MainWindow/DashboardView.swift
new file mode 100644
index 0000000..3b2206b
--- /dev/null
+++ b/Sources/GrammaGem/App/MainWindow/DashboardView.swift
@@ -0,0 +1,132 @@
+import SwiftUI
+
+struct DashboardView: View {
+ @EnvironmentObject private var app: AppState
+ @EnvironmentObject private var license: LicenseManager
+ @EnvironmentObject private var gate: FeatureGate
+ @EnvironmentObject private var usage: UsageStats
+ @EnvironmentObject private var model: ModelManager
+ @EnvironmentObject private var permissions: Permissions
+
+ var body: some View {
+ DetailScaffold(
+ title: "Welcome back",
+ subtitle: "Your private writing assistant, at a glance."
+ ) {
+ // Key metrics
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 158), spacing: 14)], spacing: 14) {
+ StatTile(
+ value: license.tier.displayName,
+ label: license.isLicensed ? "Lifetime plan" : "Free plan",
+ systemImage: "key.fill", tint: license.isLicensed ? GG.emerald : .secondary)
+
+ if gate.entitlements.unlimitedAIActions {
+ StatTile(value: "∞", label: "AI actions today", systemImage: "infinity", tint: GG.emerald)
+ } else {
+ StatTile(
+ value: "\(gate.remainingAIActionsToday)/\(gate.entitlements.dailyAIActionCap)",
+ label: "AI actions left today", systemImage: "bolt.fill", tint: GG.gold)
+ }
+
+ 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)
+ }
+
+ // Setup health
+ Card {
+ Text("Setup").font(.headline)
+ healthRow(
+ ok: permissions.accessibilityTrusted,
+ okText: "Accessibility granted",
+ badText: "Accessibility needed to fix text in apps",
+ action: permissions.accessibilityTrusted ? nil : ("Fix", { app.showOnboarding() }))
+ Divider()
+ healthRow(
+ ok: model.state == .ready,
+ okText: "On-device AI model ready",
+ badText: "AI model not downloaded (grammar still works)",
+ action: model.state == .ready ? nil : ("Set up", { app.showMainWindow(select: .model) }))
+ }
+
+ // Activity
+ Card {
+ HStack {
+ Text("Activity").font(.headline)
+ Spacer()
+ Text("Last 7 days").font(.caption).foregroundStyle(.secondary)
+ }
+ WeeklyBars(points: usage.last7Days())
+ .frame(height: 110)
+ Text("\(usage.data.totalWords) words polished overall — all on this Mac.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+
+ // Quick actions
+ Card {
+ Text("Quick actions").font(.headline)
+ HStack {
+ Button { Task { await app.runFix() } } label: {
+ Label("Fix selection", systemImage: "checkmark.circle")
+ }
+ Button { app.showAsk() } label: {
+ Label("Ask GrammaGem", systemImage: "sparkles")
+ }
+ if !license.isLicensed {
+ Spacer()
+ Button { app.showMainWindow(select: .license) } label: {
+ Label("Upgrade", systemImage: "arrow.up.circle.fill")
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ }
+ }
+ }
+
+ private var timeSaved: String {
+ let m = usage.estimatedMinutesSaved
+ if m >= 60 { return "\(m / 60)h \(m % 60)m" }
+ return "\(m)m"
+ }
+
+ @ViewBuilder
+ private func healthRow(ok: Bool, okText: String, badText: String, action: (String, () -> Void)?) -> some View {
+ HStack(spacing: 10) {
+ Image(systemName: ok ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
+ .foregroundStyle(ok ? GG.emerald : .orange)
+ Text(ok ? okText : badText)
+ Spacer()
+ if let action {
+ Button(action.0, action: action.1)
+ }
+ }
+ }
+}
+
+/// A tiny dependency-free weekly bar chart.
+struct WeeklyBars: View {
+ let points: [(label: String, count: Int)]
+
+ var body: some View {
+ let maxCount = max(points.map(\.count).max() ?? 0, 1)
+ HStack(alignment: .bottom, spacing: 10) {
+ ForEach(Array(points.enumerated()), id: \.offset) { _, point in
+ VStack(spacing: 6) {
+ Spacer(minLength: 0)
+ Text(point.count > 0 ? "\(point.count)" : "")
+ .font(.caption2).foregroundStyle(.secondary)
+ RoundedRectangle(cornerRadius: 5)
+ .fill(point.count > 0 ? GG.emerald : Color.secondary.opacity(0.18))
+ .frame(height: barHeight(point.count, maxCount: maxCount))
+ Text(point.label).font(.caption2).foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ }
+
+ private func barHeight(_ count: Int, maxCount: Int) -> CGFloat {
+ let ratio = CGFloat(count) / CGFloat(maxCount)
+ return max(4, ratio * 78)
+ }
+}
diff --git a/Sources/GrammaGem/App/MainWindow/DataViews.swift b/Sources/GrammaGem/App/MainWindow/DataViews.swift
new file mode 100644
index 0000000..de9c92d
--- /dev/null
+++ b/Sources/GrammaGem/App/MainWindow/DataViews.swift
@@ -0,0 +1,118 @@
+import SwiftUI
+
+// MARK: - Dictionary
+
+struct DictionaryView: View {
+ @EnvironmentObject private var app: AppState
+ @EnvironmentObject private var gate: FeatureGate
+ @EnvironmentObject private var dictionary: PersonalDictionary
+ @State private var newWord = ""
+ @State private var message: String?
+
+ var body: some View {
+ DetailScaffold(
+ title: "Personal Dictionary",
+ subtitle: "Words GrammaGem should never “correct” — names, brands, jargon."
+ ) {
+ Card {
+ HStack {
+ TextField("Add a word…", text: $newWord)
+ .textFieldStyle(.roundedBorder)
+ .onSubmit(add)
+ Button("Add", action: add)
+ .disabled(newWord.trimmingCharacters(in: .whitespaces).isEmpty)
+ }
+ if let message {
+ Label(message, systemImage: "lock").font(.caption).foregroundStyle(.orange)
+ }
+ let cap = gate.entitlements.dictionaryCap
+ Text(cap == Int.max
+ ? "\(dictionary.entries.count) entries · unlimited"
+ : "\(dictionary.entries.count) / \(cap) entries")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+
+ Card {
+ if dictionary.entries.isEmpty {
+ Text("No entries yet.").foregroundStyle(.secondary)
+ } else {
+ ForEach(dictionary.entries, id: \.self) { word in
+ HStack {
+ Text(word)
+ Spacer()
+ Button(role: .destructive) { app.removeDictionaryEntry(word) } label: {
+ Image(systemName: "trash")
+ }.buttonStyle(.borderless)
+ }
+ if word != dictionary.entries.last { Divider() }
+ }
+ }
+ }
+ }
+ }
+
+ private func add() {
+ switch app.addDictionaryEntry(newWord) {
+ case .allowed: newWord = ""; message = nil
+ case .denied(let m): message = m
+ }
+ }
+}
+
+// MARK: - Snippets
+
+struct SnippetsView: View {
+ @EnvironmentObject private var app: AppState
+ @EnvironmentObject private var gate: FeatureGate
+ @EnvironmentObject private var snippets: SnippetStore
+ @State private var trigger = ""
+ @State private var expansion = ""
+
+ var body: some View {
+ DetailScaffold(
+ title: "Snippets",
+ subtitle: "Type a short trigger, expand it into anything. Stored only on this Mac."
+ ) {
+ if gate.snippetsEnabled {
+ Card {
+ HStack(alignment: .top) {
+ TextField("Trigger (e.g. ;addr)", text: $trigger).textFieldStyle(.roundedBorder).frame(width: 160)
+ TextField("Expansion…", text: $expansion, axis: .vertical)
+ .lineLimit(1...4).textFieldStyle(.roundedBorder)
+ Button("Add") {
+ snippets.add(trigger: trigger, expansion: expansion)
+ trigger = ""; expansion = ""
+ }
+ .disabled(trigger.trimmingCharacters(in: .whitespaces).isEmpty
+ || expansion.trimmingCharacters(in: .whitespaces).isEmpty)
+ }
+ }
+ Card {
+ if snippets.snippets.isEmpty {
+ Text("No snippets yet.").foregroundStyle(.secondary)
+ } else {
+ ForEach(snippets.snippets) { s in
+ HStack(alignment: .top) {
+ Text(s.trigger).font(.callout.monospaced()).foregroundStyle(GG.emerald)
+ Text(s.expansion).foregroundStyle(.secondary).lineLimit(2)
+ Spacer()
+ Button(role: .destructive) { snippets.remove(s) } label: {
+ Image(systemName: "trash")
+ }.buttonStyle(.borderless)
+ }
+ if s.id != snippets.snippets.last?.id { Divider() }
+ }
+ }
+ }
+ } else {
+ Card {
+ HStack { Text("Text expansion").font(.headline); UpgradeBadge() }
+ Text("Snippets are part of the lifetime license. Save your common replies, addresses, and boilerplate, and expand them with a keystroke anywhere.")
+ .foregroundStyle(.secondary)
+ Button("Unlock snippets") { app.showMainWindow(select: .license) }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/GrammaGem/App/MainWindow/DevicesView.swift b/Sources/GrammaGem/App/MainWindow/DevicesView.swift
new file mode 100644
index 0000000..d2147f9
--- /dev/null
+++ b/Sources/GrammaGem/App/MainWindow/DevicesView.swift
@@ -0,0 +1,91 @@
+import SwiftUI
+
+struct DevicesView: View {
+ @EnvironmentObject private var app: AppState
+ @EnvironmentObject private var license: LicenseManager
+
+ var body: some View {
+ DetailScaffold(
+ title: "Devices",
+ subtitle: "Manage the Macs your license is active on."
+ ) {
+ if let record = license.record {
+ usageSummary(record)
+ thisDevice(record)
+ otherDevices(record)
+ } else {
+ Card {
+ Label("No active license on this Mac", systemImage: "key.slash")
+ .font(.headline)
+ Text("Activate a license to manage devices. The free plan runs on one Mac.")
+ .foregroundStyle(.secondary)
+ Button("Enter a license key") { app.showMainWindow(select: .license) }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ }
+ }
+
+ private func usageSummary(_ r: LicenseRecord) -> some View {
+ Card {
+ HStack(alignment: .firstTextBaseline) {
+ Text("\(max(r.activationUsage, 1)) of \(r.activationLimit)")
+ .font(.system(.title, design: .rounded).weight(.semibold))
+ Text("devices in use").foregroundStyle(.secondary)
+ Spacer()
+ Text(license.tier.displayName).font(.subheadline).foregroundStyle(GG.emerald)
+ }
+ ProgressView(value: Double(max(r.activationUsage, 1)), total: Double(max(r.activationLimit, 1)))
+ .tint(GG.emerald)
+ Text("Your \(license.tier.displayName) plan covers up to \(r.activationLimit) Mac\(r.activationLimit == 1 ? "" : "s").")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+
+ private func thisDevice(_ r: LicenseRecord) -> some View {
+ Card {
+ HStack(spacing: 12) {
+ Image(systemName: "laptopcomputer")
+ .font(.title2).foregroundStyle(GG.emerald)
+ .frame(width: 34)
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 8) {
+ Text(r.deviceName).font(.headline)
+ Text("This Mac")
+ .font(.caption2.weight(.semibold))
+ .padding(.horizontal, 7).padding(.vertical, 2)
+ .background(GG.emerald.opacity(0.15), in: Capsule())
+ .foregroundStyle(GG.emerald)
+ }
+ Text("Activated \(r.activatedAt.formatted(date: .abbreviated, time: .omitted)) · validated \(r.lastValidated.formatted(.relative(presentation: .named)))")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button(role: .destructive) {
+ Task { await app.license.deactivateThisDevice() }
+ } label: {
+ if license.isWorking { ProgressView().controlSize(.small) }
+ else { Text("Deactivate") }
+ }
+ .disabled(license.isWorking)
+ }
+ if let err = license.lastError {
+ Label(err, systemImage: "exclamationmark.triangle").font(.caption).foregroundStyle(.orange)
+ }
+ }
+ }
+
+ private func otherDevices(_ r: LicenseRecord) -> some View {
+ Card {
+ Text("Other devices").font(.headline)
+ Text("Deactivating a Mac here frees a slot instantly. To rename or remove a Mac you no longer have access to, open the license portal.")
+ .font(.callout).foregroundStyle(.secondary)
+ HStack {
+ Link("Open license portal", destination: AppConfig.modelPortalURL)
+ Spacer()
+ Text("Add a Mac: install GrammaGem there and paste the same key.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ }
+}
diff --git a/Sources/GrammaGem/App/MainWindow/MainWindowView.swift b/Sources/GrammaGem/App/MainWindow/MainWindowView.swift
new file mode 100644
index 0000000..78937cb
--- /dev/null
+++ b/Sources/GrammaGem/App/MainWindow/MainWindowView.swift
@@ -0,0 +1,179 @@
+import SwiftUI
+
+/// Brand accents for the app UI (mirrors the website CI).
+enum GG {
+ static let emerald = Color(red: 0x0E / 255, green: 0x7C / 255, blue: 0x5A / 255)
+ static let gold = Color(red: 0xC9 / 255, green: 0xA2 / 255, blue: 0x4B / 255)
+}
+
+/// Sections of the management window.
+enum MainSection: String, CaseIterable, Identifiable {
+ case dashboard, devices, license
+ case modes, dictionary, snippets
+ case shortcuts, model, privacy, general
+ case about
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .dashboard: return "Dashboard"
+ case .devices: return "Devices"
+ case .license: return "License"
+ case .modes: return "Writing Modes"
+ case .dictionary: return "Dictionary"
+ case .snippets: return "Snippets"
+ case .shortcuts: return "Shortcuts"
+ case .model: return "AI Model"
+ case .privacy: return "Privacy"
+ case .general: return "General"
+ case .about: return "About"
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .dashboard: return "square.grid.2x2"
+ case .devices: return "laptopcomputer"
+ case .license: return "key"
+ case .modes: return "slider.horizontal.3"
+ case .dictionary: return "character.book.closed"
+ case .snippets: return "text.badge.plus"
+ case .shortcuts: return "command"
+ case .model: return "cpu"
+ case .privacy: return "lock.shield"
+ case .general: return "gearshape"
+ case .about: return "info.circle"
+ }
+ }
+
+ /// Sidebar groups, in order.
+ static let groups: [(String, [MainSection])] = [
+ ("Overview", [.dashboard]),
+ ("Account", [.devices, .license]),
+ ("Writing", [.modes, .dictionary, .snippets]),
+ ("App", [.shortcuts, .model, .privacy, .general]),
+ ("", [.about]),
+ ]
+}
+
+/// The full GrammaGem management window: a clean sidebar + detail layout.
+struct MainWindowView: View {
+ @State private var selection: MainSection?
+
+ init(initialSection: MainSection = .dashboard) {
+ _selection = State(initialValue: initialSection)
+ }
+
+ var body: some View {
+ NavigationSplitView {
+ List(selection: $selection) {
+ ForEach(MainSection.groups, id: \.0) { group, items in
+ Section(group) {
+ ForEach(items) { section in
+ Label(section.title, systemImage: section.systemImage)
+ .tag(section)
+ }
+ }
+ }
+ }
+ .navigationSplitViewColumnWidth(min: 198, ideal: 216, max: 260)
+ .listStyle(.sidebar)
+ } detail: {
+ detail(for: selection ?? .dashboard)
+ }
+ .tint(GG.emerald)
+ .frame(minWidth: 820, minHeight: 560)
+ }
+
+ @ViewBuilder
+ private func detail(for section: MainSection) -> some View {
+ switch section {
+ case .dashboard: DashboardView()
+ case .devices: DevicesView()
+ case .license: LicenseView()
+ case .modes: WritingModesView()
+ case .dictionary: DictionaryView()
+ case .snippets: SnippetsView()
+ case .shortcuts: ShortcutsView()
+ case .model: ModelView()
+ case .privacy: PrivacyView()
+ case .general: GeneralView()
+ case .about: AboutView()
+ }
+ }
+}
+
+// MARK: - Reusable layout
+
+/// A scrollable detail page with a title/subtitle header and a max content width.
+struct DetailScaffold: View {
+ let title: String
+ var subtitle: String? = nil
+ @ViewBuilder var content: Content
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 22) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title).font(.largeTitle.bold())
+ if let subtitle {
+ Text(subtitle).font(.body).foregroundStyle(.secondary)
+ }
+ }
+ content
+ }
+ .frame(maxWidth: 720, alignment: .leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(28)
+ }
+ }
+}
+
+/// A soft card container used throughout the window.
+struct Card: View {
+ var padding: CGFloat = 18
+ @ViewBuilder var content: Content
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) { content }
+ .padding(padding)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(Color.primary.opacity(0.07), lineWidth: 1)
+ )
+ }
+}
+
+/// A small labelled metric tile.
+struct StatTile: View {
+ let value: String
+ let label: String
+ var systemImage: String? = nil
+ var tint: Color = .primary
+
+ var body: some View {
+ Card {
+ HStack(spacing: 8) {
+ if let systemImage {
+ Image(systemName: systemImage).foregroundStyle(tint)
+ }
+ Text(value).font(.system(.title, design: .rounded).weight(.semibold)).foregroundStyle(tint)
+ }
+ Text(label).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+}
+
+/// A locked-feature badge used to surface upgrade triggers cleanly.
+struct UpgradeBadge: View {
+ var body: some View {
+ Label("Lifetime", systemImage: "lock.fill")
+ .font(.caption2.weight(.semibold))
+ .padding(.horizontal, 7).padding(.vertical, 3)
+ .background(GG.gold.opacity(0.18), in: Capsule())
+ .foregroundStyle(GG.gold)
+ }
+}
diff --git a/Sources/GrammaGem/App/MainWindow/SystemViews.swift b/Sources/GrammaGem/App/MainWindow/SystemViews.swift
new file mode 100644
index 0000000..a3f9707
--- /dev/null
+++ b/Sources/GrammaGem/App/MainWindow/SystemViews.swift
@@ -0,0 +1,139 @@
+import SwiftUI
+
+// MARK: - Shortcuts
+
+struct ShortcutsView: View {
+ var body: some View {
+ DetailScaffold(
+ title: "Shortcuts",
+ subtitle: "Global hotkeys that work in every app."
+ ) {
+ Card {
+ shortcutRow(name: "Fix selection", keys: ["⌘", ";"], detail: "Grammar + clarity, in place")
+ Divider()
+ shortcutRow(name: "Ask GrammaGem", keys: ["⌘", "'"], detail: "Rewrite with an instruction")
+ }
+ Card {
+ Label("Custom shortcuts", systemImage: "wand.and.stars")
+ .font(.headline)
+ Text("A built-in recorder for fully customizable shortcuts is on the roadmap. For now the defaults above are active system-wide.")
+ .font(.callout).foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ private func shortcutRow(name: String, keys: [String], detail: String) -> some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(name).fontWeight(.medium)
+ Text(detail).font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ HStack(spacing: 4) {
+ ForEach(keys, id: \.self) { k in
+ Text(k)
+ .font(.system(.body, design: .monospaced))
+ .padding(.horizontal, 8).padding(.vertical, 4)
+ .background(Color(nsColor: .controlColor), in: RoundedRectangle(cornerRadius: 6))
+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.12)))
+ }
+ }
+ }
+ }
+}
+
+// MARK: - AI Model
+
+struct ModelView: View {
+ @EnvironmentObject private var app: AppState
+ @EnvironmentObject private var gate: FeatureGate
+ @EnvironmentObject private var model: ModelManager
+
+ var body: some View {
+ DetailScaffold(
+ title: "AI Model",
+ subtitle: "The local model that powers rewrites, tone, and Ask. Runs entirely on your Mac."
+ ) {
+ Card {
+ Picker("Model", selection: $model.selectedRepo) {
+ Text("Qwen2.5-3B · balanced (default)").tag(AppConfig.Model.defaultRepo)
+ Text("Qwen2.5-7B · higher quality").tag(AppConfig.Model.largeRepo)
+ }
+ .disabled(!gate.entitlements.canUseLargeModel)
+
+ if !gate.entitlements.canUseLargeModel {
+ HStack { Image(systemName: "lock.fill").foregroundStyle(GG.gold); Text("The larger model is available on the lifetime license.").font(.caption).foregroundStyle(.secondary) }
+ }
+ Divider()
+ ModelDownloadControls().environmentObject(app)
+ }
+
+ Card {
+ Label("Private by default", systemImage: "lock.shield")
+ .font(.headline)
+ Text("The model is downloaded once from Hugging Face and then runs offline. It is never bundled in the app, and it never sends your text anywhere.")
+ .font(.callout).foregroundStyle(.secondary)
+ }
+ }
+ }
+}
+
+// MARK: - Privacy
+
+struct PrivacyView: View {
+ @EnvironmentObject private var usage: UsageStats
+ @State private var confirmReset = false
+
+ var body: some View {
+ DetailScaffold(
+ title: "Privacy",
+ subtitle: "What touches the network — and what never does."
+ ) {
+ Card {
+ HStack(spacing: 10) {
+ Image(systemName: "checkmark.shield.fill").font(.title2).foregroundStyle(GG.emerald)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("0 bytes of your writing leave this Mac").font(.headline)
+ Text("Every correction and rewrite runs locally. No telemetry is collected.")
+ .font(.callout).foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ Card {
+ Text("The only times GrammaGem 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")
+ Divider()
+ networkRow("App updates", "Signed update checks")
+ Text("None of these ever include the text you write.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+
+ Card {
+ Text("Local data").font(.headline)
+ Text("Your dictionary, snippets, modes, and usage counts live only on this Mac.")
+ .font(.callout).foregroundStyle(.secondary)
+ Button(role: .destructive) { confirmReset = true } label: {
+ Label("Reset usage statistics", systemImage: "arrow.counterclockwise")
+ }
+ .confirmationDialog("Reset all local usage statistics?", isPresented: $confirmReset) {
+ Button("Reset", role: .destructive) { usage.reset() }
+ Button("Cancel", role: .cancel) {}
+ }
+ }
+ }
+ }
+
+ private func networkRow(_ title: String, _ detail: String) -> some View {
+ HStack(spacing: 10) {
+ Image(systemName: "arrow.up.forward.circle").foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(title)
+ Text(detail).font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ }
+ }
+}
diff --git a/Sources/GrammaGem/App/MainWindow/WritingModesView.swift b/Sources/GrammaGem/App/MainWindow/WritingModesView.swift
new file mode 100644
index 0000000..e25b9f2
--- /dev/null
+++ b/Sources/GrammaGem/App/MainWindow/WritingModesView.swift
@@ -0,0 +1,131 @@
+import SwiftUI
+
+struct WritingModesView: View {
+ @EnvironmentObject private var app: AppState
+ @EnvironmentObject private var gate: FeatureGate
+ @EnvironmentObject private var customModes: CustomModesStore
+
+ @State private var newName = ""
+ @State private var newPrompt = ""
+ @State private var newBundleID = ""
+ @State private var newMappedMode = ModeRegistry.polish.id
+
+ private var paid: Bool { gate.entitlements.allModes }
+
+ var body: some View {
+ DetailScaffold(
+ title: "Writing Modes",
+ subtitle: "Tune the tone for every context — or let GrammaGem pick it for you."
+ ) {
+ builtInCard
+ customCard
+ appAwareCard
+ }
+ }
+
+ private var builtInCard: some View {
+ Card {
+ Text("Built-in modes").font(.headline)
+ ForEach(ModeRegistry.all) { mode in
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: mode.isPaid ? "lock.fill" : "checkmark.circle.fill")
+ .foregroundStyle(mode.isPaid && !paid ? Color.secondary : GG.emerald)
+ .frame(width: 18)
+ VStack(alignment: .leading, spacing: 2) {
+ HStack {
+ Text(mode.name).fontWeight(.medium)
+ if mode.isPaid && !paid { UpgradeBadge() }
+ }
+ Text(mode.autoFormat ?? "").font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ }
+ if mode.id != ModeRegistry.all.last?.id { Divider() }
+ }
+ }
+ }
+
+ private var customCard: some View {
+ Card {
+ HStack { Text("Custom modes").font(.headline); if !paid { UpgradeBadge() } }
+ if customModes.customModes.isEmpty {
+ Text("Create your own modes with your own instructions — one per context you write in.")
+ .font(.callout).foregroundStyle(.secondary)
+ } else {
+ ForEach(customModes.customModes) { mode in
+ HStack {
+ VStack(alignment: .leading) {
+ Text(mode.name).fontWeight(.medium)
+ Text(mode.systemPrompt).font(.caption).foregroundStyle(.secondary).lineLimit(2)
+ }
+ Spacer()
+ Button(role: .destructive) { customModes.removeCustom(id: mode.id) } label: {
+ Image(systemName: "trash")
+ }.buttonStyle(.borderless)
+ }
+ Divider()
+ }
+ }
+
+ if paid {
+ TextField("Mode name (e.g. “Newsletter”)", text: $newName).textFieldStyle(.roundedBorder)
+ TextField("Instruction the model should follow…", text: $newPrompt, axis: .vertical)
+ .lineLimit(2...4).textFieldStyle(.roundedBorder)
+ HStack {
+ Spacer()
+ Button("Add mode") {
+ customModes.addCustom(name: newName, prompt: newPrompt)
+ newName = ""; newPrompt = ""
+ }
+ .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)
+ }
+ } else {
+ Button("Unlock custom modes") { app.showMainWindow(select: .license) }
+ }
+ }
+ }
+
+ 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.")
+ .font(.callout).foregroundStyle(.secondary)
+
+ if !customModes.appOverrides.isEmpty {
+ ForEach(customModes.appOverrides.sorted(by: { $0.key < $1.key }), id: \.key) { bundle, modeID in
+ HStack {
+ Text(bundle).font(.callout.monospaced())
+ Image(systemName: "arrow.right").foregroundStyle(.secondary)
+ Text(modeName(modeID)).fontWeight(.medium)
+ Spacer()
+ Button(role: .destructive) { customModes.clearOverride(bundleID: bundle) } label: {
+ Image(systemName: "trash")
+ }.buttonStyle(.borderless)
+ }
+ Divider()
+ }
+ }
+
+ if paid {
+ HStack {
+ TextField("App bundle id (e.g. com.apple.mail)", text: $newBundleID)
+ .textFieldStyle(.roundedBorder)
+ Picker("", selection: $newMappedMode) {
+ ForEach(customModes.allModes()) { m in Text(m.name).tag(m.id) }
+ }.labelsHidden().frame(width: 160)
+ Button("Map") {
+ customModes.setOverride(bundleID: newBundleID, modeID: newMappedMode)
+ newBundleID = ""
+ }
+ .disabled(newBundleID.trimmingCharacters(in: .whitespaces).isEmpty)
+ }
+ } else {
+ Button("Unlock app-aware switching") { app.showMainWindow(select: .license) }
+ }
+ }
+ }
+
+ private func modeName(_ id: String) -> String {
+ customModes.allModes().first { $0.id == id }?.name ?? id
+ }
+}
diff --git a/Sources/GrammaGem/App/MenuBarContent.swift b/Sources/GrammaGem/App/MenuBarContent.swift
index 4dabbad..62ca8ce 100644
--- a/Sources/GrammaGem/App/MenuBarContent.swift
+++ b/Sources/GrammaGem/App/MenuBarContent.swift
@@ -25,6 +25,15 @@ struct MenuBarContent: View {
Divider()
+ actionRow(title: "Open GrammaGem…", shortcut: nil) {
+ app.showMainWindow()
+ }
+ actionRow(title: "Manage devices…", shortcut: nil) {
+ app.showMainWindow(select: .devices)
+ }
+
+ Divider()
+
usageRow
Divider()
diff --git a/Sources/GrammaGem/Core/AppConfig.swift b/Sources/GrammaGem/Core/AppConfig.swift
index 06429d2..a1016a2 100644
--- a/Sources/GrammaGem/Core/AppConfig.swift
+++ b/Sources/GrammaGem/Core/AppConfig.swift
@@ -6,9 +6,16 @@ import Foundation
/// TODO(real-integration): replace every `TODO_*` value with your real IDs.
enum AppConfig {
static let appName = "GrammaGem"
- static let bundleIdentifier = "com.grammagem.app" // TODO(real-integration)
+ 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 appVersion = "0.1.0"
+
+ // Legal entity (shown in About / legal).
+ static let companyName = "FounderGem LLC"
+ static let companyAddress = "1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA"
+ static let copyright = "© 2026 FounderGem LLC"
/// Lemon Squeezy License API (app talks to it directly — no server of ours).
enum LemonSqueezy {
diff --git a/Sources/GrammaGem/Licensing/DeviceFingerprint.swift b/Sources/GrammaGem/Licensing/DeviceFingerprint.swift
index 67f7c2d..be5ae2b 100644
--- a/Sources/GrammaGem/Licensing/DeviceFingerprint.swift
+++ b/Sources/GrammaGem/Licensing/DeviceFingerprint.swift
@@ -32,6 +32,11 @@ enum DeviceFingerprint {
return cf.takeRetainedValue() as? String
}
+ /// A friendly, human-readable name for this Mac (for the Devices screen).
+ static func deviceName() -> String {
+ Host.current().localizedName ?? ProcessInfo.processInfo.hostName
+ }
+
private static func fallbackID() -> String {
let key = "GrammaGem.fallbackDeviceID"
if let existing = UserDefaults.standard.string(forKey: key) { return existing }
diff --git a/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift b/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift
index 6ae024e..0c3da19 100644
--- a/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift
+++ b/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift
@@ -13,7 +13,7 @@ final class LemonSqueezyClient {
// MARK: - Endpoints
- func activate(key: String, instanceName: String) async throws -> (instanceID: String, tier: Tier) {
+ func activate(key: String, instanceName: String) async throws -> ActivationResult {
let res: LSActivateResponse = try await post(
"licenses/activate",
params: ["license_key": key, "instance_name": instanceName])
@@ -23,17 +23,23 @@ final class LemonSqueezyClient {
throw LicenseError.serverMessage("Activation failed.")
}
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key)
- return (instance.id, tier)
+ return ActivationResult(
+ instanceID: instance.id, tier: tier,
+ usage: res.license_key?.activation_usage ?? 1,
+ limit: res.license_key?.activation_limit ?? tier.deviceCap)
}
- func validate(key: String, instanceID: String?) async throws -> (valid: Bool, tier: Tier) {
+ func validate(key: String, instanceID: String?) async throws -> ValidationResult {
var params = ["license_key": key]
if let instanceID { params["instance_id"] = instanceID }
let res: LSValidateResponse = try await post("licenses/validate", params: params)
if let err = res.error { throw mapServerError(err) }
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key)
- return (res.valid, tier)
+ return ValidationResult(
+ valid: res.valid, tier: tier,
+ usage: res.license_key?.activation_usage ?? 0,
+ limit: res.license_key?.activation_limit ?? tier.deviceCap)
}
@discardableResult
diff --git a/Sources/GrammaGem/Licensing/LicenseManager.swift b/Sources/GrammaGem/Licensing/LicenseManager.swift
index cbaec6c..8508d02 100644
--- a/Sources/GrammaGem/Licensing/LicenseManager.swift
+++ b/Sources/GrammaGem/Licensing/LicenseManager.swift
@@ -44,13 +44,15 @@ final class LicenseManager: ObservableObject {
do {
let instanceName = DeviceFingerprint.stableID()
- let (instanceID, tier) = try await client.activate(key: key, instanceName: instanceName)
+ let result = try await client.activate(key: key, instanceName: instanceName)
let rec = LicenseRecord(
- key: key, instanceID: instanceID, tier: tier,
- lastValidated: Date(), activatedDevices: tier.deviceCap)
+ key: key, instanceID: result.instanceID, tier: result.tier,
+ lastValidated: Date(), activatedAt: Date(),
+ deviceName: DeviceFingerprint.deviceName(),
+ activationUsage: result.usage, activationLimit: result.limit)
Keychain.setObject(rec, account: account)
record = rec
- Log.licensing.info("Activated tier \(tier.rawValue, privacy: .public)")
+ Log.licensing.info("Activated tier \(result.tier.rawValue, privacy: .public)")
} catch {
lastError = error.localizedDescription
}
@@ -63,10 +65,12 @@ final class LicenseManager: ObservableObject {
guard force || Date().timeIntervalSince(rec.lastValidated) > interval else { return }
do {
- let (valid, tier) = try await client.validate(key: rec.key, instanceID: rec.instanceID)
- if valid {
+ let result = try await client.validate(key: rec.key, instanceID: rec.instanceID)
+ if result.valid {
rec.lastValidated = Date()
- rec.tier = tier
+ rec.tier = result.tier
+ rec.activationUsage = result.usage
+ rec.activationLimit = result.limit
Keychain.setObject(rec, account: account)
record = rec
} else {
diff --git a/Sources/GrammaGem/Licensing/LicenseModels.swift b/Sources/GrammaGem/Licensing/LicenseModels.swift
index 95146aa..a9196a5 100644
--- a/Sources/GrammaGem/Licensing/LicenseModels.swift
+++ b/Sources/GrammaGem/Licensing/LicenseModels.swift
@@ -6,7 +6,26 @@ struct LicenseRecord: Codable, Equatable {
var instanceID: String // Lemon Squeezy activation instance id
var tier: Tier
var lastValidated: Date
- var activatedDevices: Int
+ var activatedAt: Date // when this Mac was first activated
+ var deviceName: String // friendly name of this Mac
+ var activationUsage: Int // devices currently using the license
+ var activationLimit: Int // device cap for the tier
+}
+
+/// Result of an activate call.
+struct ActivationResult {
+ let instanceID: String
+ let tier: Tier
+ let usage: Int
+ let limit: Int
+}
+
+/// Result of a validate call.
+struct ValidationResult {
+ let valid: Bool
+ let tier: Tier
+ let usage: Int
+ let limit: Int
}
// MARK: - Lemon Squeezy API response shapes (subset we rely on)
diff --git a/Sources/GrammaGem/Preferences/Preferences.swift b/Sources/GrammaGem/Preferences/Preferences.swift
new file mode 100644
index 0000000..9bb7700
--- /dev/null
+++ b/Sources/GrammaGem/Preferences/Preferences.swift
@@ -0,0 +1,55 @@
+import Foundation
+import AppKit
+import ServiceManagement
+
+/// User-facing app preferences, persisted to UserDefaults. Kept separate from
+/// licensing/entitlements so the General settings stay simple.
+@MainActor
+final class Preferences: ObservableObject {
+ enum Appearance: String, CaseIterable, Identifiable {
+ case system, light, dark
+ var id: String { rawValue }
+ var label: String { rawValue.capitalized }
+ }
+
+ enum MenuBarStyle: String, CaseIterable, Identifiable {
+ case icon, monogram
+ var id: String { rawValue }
+ var label: String { self == .icon ? "Icon" : "Monogram" }
+ }
+
+ private let d = UserDefaults.standard
+
+ @Published var launchAtLogin: Bool { didSet { applyLaunchAtLogin() } }
+ @Published var playSounds: Bool { didSet { d.set(playSounds, forKey: "pref.playSounds") } }
+ @Published var liveUnderlines: Bool { didSet { d.set(liveUnderlines, forKey: "pref.liveUnderlines") } }
+ @Published var restoreClipboard: Bool { didSet { d.set(restoreClipboard, forKey: "pref.restoreClipboard") } }
+ @Published var appearance: Appearance { didSet { d.set(appearance.rawValue, forKey: "pref.appearance"); applyAppearance() } }
+ @Published var menuBarStyle: MenuBarStyle { didSet { d.set(menuBarStyle.rawValue, forKey: "pref.menuBarStyle") } }
+
+ init() {
+ launchAtLogin = SMAppService.mainApp.status == .enabled
+ playSounds = d.object(forKey: "pref.playSounds") as? Bool ?? true
+ liveUnderlines = d.object(forKey: "pref.liveUnderlines") as? Bool ?? true
+ restoreClipboard = d.object(forKey: "pref.restoreClipboard") as? Bool ?? true
+ appearance = Appearance(rawValue: d.string(forKey: "pref.appearance") ?? "") ?? .system
+ menuBarStyle = MenuBarStyle(rawValue: d.string(forKey: "pref.menuBarStyle") ?? "") ?? .icon
+ }
+
+ private func applyLaunchAtLogin() {
+ do {
+ if launchAtLogin { try SMAppService.mainApp.register() }
+ else { try SMAppService.mainApp.unregister() }
+ } catch {
+ Log.app.error("Launch-at-login change failed: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+
+ private func applyAppearance() {
+ switch appearance {
+ case .system: NSApp.appearance = nil
+ case .light: NSApp.appearance = NSAppearance(named: .aqua)
+ case .dark: NSApp.appearance = NSAppearance(named: .darkAqua)
+ }
+ }
+}
diff --git a/Sources/GrammaGem/Stats/UsageStats.swift b/Sources/GrammaGem/Stats/UsageStats.swift
new file mode 100644
index 0000000..3495f35
--- /dev/null
+++ b/Sources/GrammaGem/Stats/UsageStats.swift
@@ -0,0 +1,90 @@
+import Foundation
+
+/// Privacy-friendly, fully-local usage insights. Counts *how often* features are
+/// used — never *what* was written. Stored only on this Mac.
+@MainActor
+final class UsageStats: ObservableObject {
+ struct Snapshot: Codable, Equatable {
+ var totalCorrections = 0
+ var totalAIActions = 0
+ var totalWords = 0
+ var perDay: [String: Int] = [:] // "yyyy-MM-dd" -> actions that day
+ }
+
+ @Published private(set) var data: Snapshot
+
+ private let key = "GrammaGem.usageStats"
+ private let d = UserDefaults.standard
+
+ private static let fmt: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd"
+ f.locale = Locale(identifier: "en_US_POSIX")
+ f.timeZone = .current
+ return f
+ }()
+
+ init() {
+ if let raw = d.data(forKey: key),
+ let decoded = try? JSONDecoder().decode(Snapshot.self, from: raw) {
+ data = decoded
+ } else {
+ data = Snapshot()
+ }
+ }
+
+ func recordCorrection(words: Int) {
+ data.totalCorrections += 1
+ data.totalWords += max(0, words)
+ bumpToday()
+ persist()
+ }
+
+ func recordAIAction(words: Int) {
+ data.totalAIActions += 1
+ data.totalWords += max(0, words)
+ bumpToday()
+ persist()
+ }
+
+ /// A playful, clearly-estimated "time saved" figure (local only).
+ var estimatedMinutesSaved: Int {
+ (data.totalCorrections * 8 + data.totalAIActions * 45) / 60
+ }
+
+ /// Counts for the last 7 calendar days, oldest → newest, for a small chart.
+ func last7Days() -> [(label: String, count: Int)] {
+ let cal = Calendar.current
+ let today = Date()
+ return (0..<7).reversed().map { offset in
+ let day = cal.date(byAdding: .day, value: -offset, to: today) ?? today
+ let key = Self.fmt.string(from: day)
+ let short = day.formatted(.dateTime.weekday(.narrow))
+ return (label: short, count: data.perDay[key] ?? 0)
+ }
+ }
+
+ func reset() {
+ data = Snapshot()
+ persist()
+ }
+
+ private func bumpToday() {
+ let k = Self.fmt.string(from: Date())
+ data.perDay[k, default: 0] += 1
+ // Keep only ~60 days of history.
+ if data.perDay.count > 70 {
+ let keep = Set((0..<60).compactMap { off -> String? in
+ guard let day = Calendar.current.date(byAdding: .day, value: -off, to: Date()) else { return nil }
+ return Self.fmt.string(from: day)
+ })
+ data.perDay = data.perDay.filter { keep.contains($0.key) }
+ }
+ }
+
+ private func persist() {
+ if let raw = try? JSONEncoder().encode(data) {
+ d.set(raw, forKey: key)
+ }
+ }
+}
diff --git a/Sources/GrammaGem/System/SnippetStore.swift b/Sources/GrammaGem/System/SnippetStore.swift
new file mode 100644
index 0000000..4d17914
--- /dev/null
+++ b/Sources/GrammaGem/System/SnippetStore.swift
@@ -0,0 +1,51 @@
+import Foundation
+
+/// A text-expansion snippet: type a short trigger, get a longer expansion.
+struct Snippet: Codable, Identifiable, Equatable {
+ var id = UUID()
+ var trigger: String
+ var expansion: String
+}
+
+/// Local store for text-expansion snippets (a paid feature). Persisted to disk;
+/// snippet contents never leave the device.
+@MainActor
+final class SnippetStore: ObservableObject {
+ @Published private(set) var snippets: [Snippet]
+
+ private let key = "GrammaGem.snippets"
+ private let d = UserDefaults.standard
+
+ init() {
+ if let raw = d.data(forKey: key),
+ let decoded = try? JSONDecoder().decode([Snippet].self, from: raw) {
+ snippets = decoded
+ } else {
+ snippets = []
+ }
+ }
+
+ func add(trigger: String, expansion: String) {
+ let t = trigger.trimmingCharacters(in: .whitespaces)
+ let e = expansion.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !t.isEmpty, !e.isEmpty else { return }
+ snippets.append(Snippet(trigger: t, expansion: e))
+ persist()
+ }
+
+ func remove(_ snippet: Snippet) {
+ snippets.removeAll { $0.id == snippet.id }
+ persist()
+ }
+
+ /// Resolve a trigger to its expansion (used by the snippet hotkey path).
+ func expansion(for trigger: String) -> String? {
+ snippets.first { $0.trigger == trigger }?.expansion
+ }
+
+ private func persist() {
+ if let raw = try? JSONEncoder().encode(snippets) {
+ d.set(raw, forKey: key)
+ }
+ }
+}