Mac-Translator — PRD
Size S · P2 · Mac utility Status: ✅ Shipped 2026-05-20 (P0–P6) — last verification pending Originally planned: 1 weekend / Actual: ~2 weekends concentrated work
1. Problem
Translating a single phrase mid-reading on macOS costs a context switch every time:
- DeepL desktop app: ⌘C ⌘C (double-copy), app steals focus
- Apple Translate / built-in Sequoia Translate: right-click → Services → Translate (4 clicks, modal panel)
- Translate Tab: browser-only — doesn’t work in Mail, Slack, Notes, PDFs
- PopClip + extension: selection → popup menu → click translate (extra hop, popup obscures the text)
Pain: every translator UI demands the user leave what they’re reading. For 6-word phrases the round-trip cost > the value of the translation.
Why now: Force Click (firm press on a Force Touch trackpad) has been sitting unused on every Mac since 2015. Apple uses it for Look Up. Reframing it as Translate Selection is a one-handed, zero-modal, invisible-until-needed input gesture that no other translator surface has.
2. Goal & Success Metrics
Goal: Select text → Force Click → see translation in floating HUD next to cursor → dismiss with Escape. Zero windows, zero clicks, zero context switch.
Metrics — actual achieved:
| Metric | Target | Achieved | Note |
|---|---|---|---|
| Time-to-translation (gesture → HUD visible) | <500 ms | 220 ms | Includes AX text extraction + DeepL round-trip + HUD render |
| Median DeepL backend latency | <300 ms | 180 ms | DeepL Free API region routing |
| Daily usage (steady state) | ≥20 | ~40 | Measured in-app counter, month 2 |
| Force Click vs ⌘⌥T split | gesture primary | 78% / 22% | Validates gesture-first product decision |
| Cost | $0/month | $0/month | DeepL Free tier at 40/day well under 500k char/month |
3. User journey
- User is reading an English email / Vietnamese chat / PDF / Slack message.
- User selects a phrase with the trackpad (drag) or mouse.
- User Force Clicks anywhere on the trackpad (firm press past the click threshold).
- CGEventTap fires → app pulls selected text via Accessibility API → POSTs to DeepL.
- HUD appears next to the cursor with the translation, ≤280 chars.
- User reads, dismisses with Escape or any keypress, returns to flow.
Fallback: if the user is on a Magic Mouse / external mouse (no Force Touch), ⌘⌥T does the same thing.
4. Scope (MoSCoW)
Must — DONE:
- ✅ Force Click anywhere triggers translation of OS selection
- ✅ ⌘⌥T global chord as fallback trigger
- ✅ Floating HUD next to cursor (not centered, not modal)
- ✅ Escape dismisses; any keypress dismisses
- ✅ Auto-detect source language
Should — DONE:
- ✅ ≤280 char output or auto-truncate
- ✅ Menubar icon with on/off toggle + quit
- ✅ Permission startup-check (logs “AX = false” if grant lost)
Could — partial:
- ⏸️ Translation history panel — dropped on purpose (see §5)
- ⏸️ Source-language picker — dropped on purpose (see §5)
- ⏸️ Multi-target language (currently English ↔ Vietnamese only)
- ⏸️ Offline fallback (Apple Translate API) — deferred
Won’t — kept:
- No window (the product is the HUD, not an app you visit)
- No installer (single .app bundle in
/Applications) - No analytics, no telemetry
- No subscription, no in-app purchase
- No cloud sync of anything
5. Single-purpose product decisions
Killing surface area is what made this product. Three constraints, each kept against the temptation to add a “small feature”:
| Cut | Why |
|---|---|
| No window | A window means “an app you visit.” The product is the moment of translation, not the archive. |
| No history UI | Need to revisit? Copy from the HUD before dismissing. History UI implies a different product (a translator workspace). |
| No source-language picker | Auto-detect or fail loud. Pickers are a “give me a menu instead of an answer” anti-pattern. |
| ≤280 char output | Anything longer means the user wanted a real translator. Truncate + show ”…” — the HUD is for moments, not documents. |
Less surface = more focus on the one thing. Each of these was tempting to add (“just a small panel”); each would have made the product worse.
6. Tech Stack — final choices
| Layer | Considered | Picked | Reason |
|---|---|---|---|
| Language | Swift / Objective-C / Electron | Swift 5 | Native macOS APIs, single binary, no runtime |
| UI | SwiftUI / AppKit | AppKit NSPanel for HUD, SwiftUI for menubar | NSPanel gives borderless floating window with no Dock tile; SwiftUI for menubar simplicity |
| Force Click detection | NSEvent global monitor | CGEventTap | Pressure events are NOT delivered to NSEvent global monitors — only CGEventTap on leftMouseDragged + kCGMouseEventPressure works |
| Selected text | NSPasteboard (force ⌘C) | Accessibility API (AXFocusedUIElement → AXSelectedText) | Doesn’t clobber the user’s clipboard |
| Translation backend | Google Translate / Apple Translate / LLM | DeepL Free API | Quality > Google for VN↔EN, free tier covers 40/day easily, predictable latency |
| Signing | Developer ID | Ad-hoc | Personal app, no distribution outside operator’s Mac |
| Distribution | App Store / DMG | Drop-in .app bundle | No installer overhead |
Cost posture: DeepL Free covers 500K chars/month. At ~40 translations/day × ~50 chars avg = ~60K chars/month (~12% of quota).
7. Milestones — actual
| Phase | What shipped |
|---|---|
| P0 | Menubar scaffold (NSStatusItem + SwiftUI menu), DeepL API client class, on/off toggle |
| P1 | Global ⌘⌥T chord via NSEvent.addGlobalMonitorForEvents → fetch selection → DeepL → HUD |
| P2 | Accessibility API path: AXUIElementCopyAttributeValue(kAXSelectedTextAttribute) working in Mail, Notes, Slack, PDFs |
| P3 | CGEventTap on leftMouseDragged with kCGMouseEventPressure ≥ 0.7 + movement-distance filter to distinguish from press-and-drag |
| P4 | NSPanel HUD positioned at cursor (NSEvent.mouseLocation), Escape monitor, any-keypress dismiss via local key monitor |
| P5 | DeepL auto-detect source language, ≤280 char truncate with ”…” indicator |
| P6 | Startup AX/Input Monitoring check + log line; documented permission rebuild ritual |
Definition of Done passed:
- ✅ Force Click anywhere → translation in <500ms (actual 220ms)
- ✅ Works in Mail, Slack, Notes, Safari (read-tier), PDFs, Messages
- ✅ Permission grant lost after rebuild → startup log line catches it
- ✅ 2 consecutive weeks daily use without reaching for DeepL desktop app
8. Cost & Quota
| Item | Free tier? | Actual usage |
|---|---|---|
| DeepL Free API | ✅ 500K chars/month | ~60K chars/month (~12%) |
| Apple Developer ID | n/a | Ad-hoc signed, no cost |
| Hosting | n/a | Local .app, no server |
Total monthly: $0.
9. Risks & open questions
Original risks:
- Force Click not firing globally → confirmed on Day 1 (NSEvent monitor doesn’t see pressure events). Resolved P3 via CGEventTap.
- AX permission lost on rebuild → confirmed. Documented ritual + startup check.
- DeepL free quota exhaustion → modeled; well under cap at current usage.
New risks:
- Apple removes Force Touch from future trackpads (already partially happened on M3 MBA) → ⌘⌥T fallback covers it
- DeepL Free API region rate-limit changes → swap to Apple Translate offline (deferred)
- Last verify still pending — need 1 clean rebuild-cycle test before declaring P6 done
Open questions:
- Q1: Multi-target language UI? → Deferred until anyone other than operator uses it
- Q2: Offline fallback? → Apple Translate framework as backup, deferred
- Q3: Distribute to friends? → Would need Developer ID + notarization, out of scope
10. Definition of Done
Shipped (2026-05-20):
- ✅ P0–P6 all working
- ✅ 2 weeks daily use validates gesture + HUD design
- ⏳ Last verify pending (one full rebuild → permission ritual → smoke test)
See also
- Implementation — technical deep-dive (CGEventTap, AX API, permission model)
- Architecture — component diagram, event flow
- Notes — chronological decision log + macOS gotchas
- Blog post — PM framing, JTBD, alternatives audit