Notes & Decision Log
Format: YYYY-MM-DD — context — decision/finding.
Decisions
- 2026-04-05 — Foundation: a translator that doesn’t break reading flow. Audited existing tools (DeepL desktop, Apple Translate, Translate Tab, PopClip, built-in Sequoia Translate). All require leaving the sentence being read. The pain isn’t translation quality, it’s the context switch.
- 2026-04-05 — Input gesture audit. Force Click sits unused on every Mac since 2015 (Apple uses it only for Look Up). Reframing it as Translate Selection: one-handed, zero-modal, invisible until needed. Picked as primary trigger; ⌘⌥T chord as fallback for non-trackpad cases.
- 2026-04-05 — Kill the window. Initial sketch had a window with history + source-language picker. Killing the window forced three constraints: ≤280 char output, no history UI, no source-language picker. Less surface = more focus.
- 2026-04-05 (P0) — Swift + AppKit NSStatusItem over Electron. Native single binary, no runtime, sub-100 MB RAM idle.
- 2026-04-05 (P0) — DeepL Free API over Google Translate or Apple Translate framework. VN↔EN quality materially better; free tier 500K chars/month covers 40 translations/day with 12% utilization.
- 2026-04-06 (P1) — ⌘⌥T global chord first (easier than CGEventTap), end-to-end smoke test path. NSEvent.addGlobalMonitorForEvents(matching: .keyDown) works trivially.
- 2026-04-06 (P2) — Accessibility API over simulated ⌘C. Doesn’t clobber the user’s clipboard.
AXFocusedUIElement→AXSelectedTextworks in Mail, Notes, Slack, Safari, Messages, PDFs. - 2026-04-12 (P3) — Force Click via CGEventTap, not NSEvent. NSEvent global monitor silently doesn’t fire for
.pressureevents. CGEventTap onleftMouseDraggedreadingkCGMouseEventPressure+ a 5px movement filter (to distinguish from press-and-drag) works. - 2026-04-12 (P3) — Pressure threshold 0.7 (Apple stage-2 is ~0.5). Higher threshold reduces false positives during regular clicks.
- 2026-04-12 (P3) — 800 ms debounce after fire. One physical Force Click generates many
leftMouseDraggedevents as pressure ramps 0 → 1; without debounce the same press fires translation 5–8 times. - 2026-04-13 (P4) — NSPanel over NSWindow. NSPanel is borderless, no Dock tile, doesn’t steal focus. Position =
NSEvent.mouseLocation + (12, -8), clamp insideNSScreen.visibleFrame. - 2026-04-13 (P4) — Dismiss on any keypress (not just Escape). Reasoning: if user resumes typing, the HUD is in the way. Escape is the explicit dismiss; any-key is the implicit “I’ve read it, let me get back to work.”
- 2026-04-19 (P5) — Auto-detect source language via DeepL’s
detected_source_languageresponse field. Rule: EN → VI, VI → EN, else → EN. No picker. - 2026-04-19 (P5) — ≤280 char output or truncate with ”…”. Selections longer than this almost always mean the user wanted a real translator, not a HUD.
- 2026-05-20 (P6) — Permission startup guard.
AXIsProcessTrustedWithOptions+IOHIDCheckAccess. LogAX = false/InputMonitoring = deniedon launch — the entire monitoring system. Console.app + filter is enough. - 2026-05-20 — Last verify pending: one full clean rebuild → tccutil reset → re-grant → restart → smoke test cycle still needs to be done before declaring P6 done.
Gotchas
- P3 —
NSEvent.addGlobalMonitorForEvents(matching: .pressure)silently doesn’t fire. Force Click pressure events are NOT delivered to NSEvent global monitors. Burned 4 hours of “why isn’t my handler called” before discovering this. The fix is CGEventTap onleftMouseDraggedwithkCGMouseEventPressure+ movement filter. - P3 —
leftMouseDraggedfires constantly during press-and-drag (selecting text). Without a movement filter (<5 px since mouseDown), every drag past pressure 0.7 triggers translation. The cumulative-movement test cleanly distinguishes Force Click from drag-select. - P3 —
.cgSessionEventTapvs.cghidEventTap: session is per-user, HID is system-wide. Session is correct for a user app. - P3 —
.listenOnlyvs.defaultTap: defaultTap can be auto-disabled by macOS if your callback is slow..listenOnlyis read-only and immune. Always use.listenOnlywhen you don’t need to consume events. - P3 — One physical Force Click spans 50+
leftMouseDraggedevents as pressure ramps. Without an 800 ms debounce, one click fires translation 5–8 times. - P6 — AX / Input Monitoring / Screen Recording grants reset on every ad-hoc rebuild. The codehash (cdhash) changes on every build, and macOS treats the new hash as a different app — silently revoking all previously-granted permissions. You don’t get an error. You get a working build that just doesn’t see global events anymore. Burned 2 evenings before figuring this out.
- P6 — Fix workflow:
tccutil reset Accessibility com.your.app→ retoggle in System Settings → restart the app. - P6 — Watchers registered while AX=false stay dead silently after AX granted. Granting Accessibility in System Settings doesn’t retroactively activate event monitors that were created when AX was false. Must explicitly Cmd-Q and re-launch.
- P6 — Corollary: don’t rebuild between “grant permission” and “test.” The cycle “grant → rebuild → test” looks like the permission was never granted. Always run the same binary you granted the permission to.
- P6 —
LSUIElement=truein Info.plist hides both the Dock tile AND the window-switcher entry. Correct for a menubar-only utility. - P4 — NSPanel needs
becomesKeyOnlyIfNeeded = trueANDworksWhenModal = trueto not steal focus when other apps have modal dialogs. Otherwise the HUD shows but the underlying app’s modal panel disappears. - P2 — Accessibility API returns nil for selected text in some Electron/Chromium apps (varies by version). Silent bail (no HUD, no error) is the right UX — don’t pop an error for unsupported apps.
Reference links
- CGEventTap docs: https://developer.apple.com/documentation/coregraphics/quartz_event_services
kCGMouseEventPressure: https://developer.apple.com/documentation/coregraphics/cgeventfield/kcgmouseeventpressure- Accessibility API constants: https://developer.apple.com/documentation/applicationservices/axuielement_h
AXIsProcessTrustedWithOptions: https://developer.apple.com/documentation/applicationservices/1462089-axisprocesstrustedwithoptions- DeepL Free API: https://www.deepl.com/pro-api
tccutilreset:man tccutil—tccutil reset Accessibility <bundle-id>- NSPanel: https://developer.apple.com/documentation/appkit/nspanel
- LSUIElement: https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/LaunchServicesKeys.html
Working-session log
| Date | Hours | What | Outcome |
|---|---|---|---|
| 2026-04-05 | ~2 h | Foundation: JTBD, alternatives audit, gesture decision | Force Click picked as primary, window killed |
| 2026-04-05 evening | ~3 h | P0: menubar scaffold + DeepL client | NSStatusItem + on/off toggle + API client class |
| 2026-04-06 | ~2 h | P1: ⌘⌥T global chord → HUD smoke test | End-to-end translation works via keyboard |
| 2026-04-06 evening | ~3 h | P2: Accessibility API selected-text extraction | Works in Mail, Notes, Slack, Safari, Messages, PDFs |
| 2026-04-12 morning | ~4 h | P3 attempt 1: NSEvent .pressure monitor | Wasted — pressure events don’t reach global monitors |
| 2026-04-12 afternoon | ~3 h | P3 attempt 2: CGEventTap | Force Click fires correctly with movement + debounce filters |
| 2026-04-13 | ~3 h | P4: NSPanel HUD + cursor positioning + dismiss | Floating HUD shows next to cursor, Escape/any-key dismisses |
| 2026-04-19 | ~2 h | P5: auto-detect language + 280-char truncate | EN↔VI flow auto, longer selections truncate |
| 2026-05-15 evening | ~2 h | P6 attempt 1: AX/IM permission debug | Wasted — kept rebuilding between grant and test |
| 2026-05-16 evening | ~2 h | P6 attempt 2: still rebuilding too eagerly | Wasted — same trap, different symptom |
| 2026-05-20 | ~2 h | P6: permission guard + restart ritual documented | Startup log line catches grant loss; ritual written down |
| Total | ~28 hours | All 6 phases shipped | Daily-driver since week 2 |
Of those 28 hours, ~10 hours were burned on macOS gotchas (NSEvent vs CGEventTap, permission rebuild trap, dead watchers). If skipping those: the actual product is ~18 hours of work. The blog post exists to save the next builder those 10 hours.