← Back to project
● Shipped P2 Size S Mac utility

Mac-Translator — PRD

Product spec, JTBD, single-purpose constraints, scope, and success metrics for Mac-Translator.

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:

MetricTargetAchievedNote
Time-to-translation (gesture → HUD visible)<500 ms220 msIncludes AX text extraction + DeepL round-trip + HUD render
Median DeepL backend latency<300 ms180 msDeepL Free API region routing
Daily usage (steady state)≥20~40Measured in-app counter, month 2
Force Click vs ⌘⌥T splitgesture primary78% / 22%Validates gesture-first product decision
Cost$0/month$0/monthDeepL Free tier at 40/day well under 500k char/month

3. User journey

  1. User is reading an English email / Vietnamese chat / PDF / Slack message.
  2. User selects a phrase with the trackpad (drag) or mouse.
  3. User Force Clicks anywhere on the trackpad (firm press past the click threshold).
  4. CGEventTap fires → app pulls selected text via Accessibility API → POSTs to DeepL.
  5. HUD appears next to the cursor with the translation, ≤280 chars.
  6. 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”:

CutWhy
No windowA window means “an app you visit.” The product is the moment of translation, not the archive.
No history UINeed to revisit? Copy from the HUD before dismissing. History UI implies a different product (a translator workspace).
No source-language pickerAuto-detect or fail loud. Pickers are a “give me a menu instead of an answer” anti-pattern.
≤280 char outputAnything 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

LayerConsideredPickedReason
LanguageSwift / Objective-C / ElectronSwift 5Native macOS APIs, single binary, no runtime
UISwiftUI / AppKitAppKit NSPanel for HUD, SwiftUI for menubarNSPanel gives borderless floating window with no Dock tile; SwiftUI for menubar simplicity
Force Click detectionNSEvent global monitorCGEventTapPressure events are NOT delivered to NSEvent global monitors — only CGEventTap on leftMouseDragged + kCGMouseEventPressure works
Selected textNSPasteboard (force ⌘C)Accessibility API (AXFocusedUIElementAXSelectedText)Doesn’t clobber the user’s clipboard
Translation backendGoogle Translate / Apple Translate / LLMDeepL Free APIQuality > Google for VN↔EN, free tier covers 40/day easily, predictable latency
SigningDeveloper IDAd-hocPersonal app, no distribution outside operator’s Mac
DistributionApp Store / DMGDrop-in .app bundleNo 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

PhaseWhat shipped
P0Menubar scaffold (NSStatusItem + SwiftUI menu), DeepL API client class, on/off toggle
P1Global ⌘⌥T chord via NSEvent.addGlobalMonitorForEvents → fetch selection → DeepL → HUD
P2Accessibility API path: AXUIElementCopyAttributeValue(kAXSelectedTextAttribute) working in Mail, Notes, Slack, PDFs
P3CGEventTap on leftMouseDragged with kCGMouseEventPressure ≥ 0.7 + movement-distance filter to distinguish from press-and-drag
P4NSPanel HUD positioned at cursor (NSEvent.mouseLocation), Escape monitor, any-keypress dismiss via local key monitor
P5DeepL auto-detect source language, ≤280 char truncate with ”…” indicator
P6Startup 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

ItemFree tier?Actual usage
DeepL Free API✅ 500K chars/month~60K chars/month (~12%)
Apple Developer IDn/aAd-hoc signed, no cost
Hostingn/aLocal .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