CloseUp is an open-source macOS app that overlays window controls (close / minimize / zoom / hide / quit) onto the native macOS Mission Control, adds keyboard control, and is fully localized. Menu-bar (accessory) app, Developer-ID distribution, Sparkle self-update, GPL-3.0.
This file is authoritative. CLAUDE.md is a symlink to it. When a convention is
decided or a hard-won bug class is found, write it here immediately as a hard
rule.
- Phases end test-green: build with zero warnings + tests pass before moving
on. Red→green TDD for logic in
CloseUpKit. - Spec-first, verify reality: research unknowns against official docs and shipped source before building; flag deviations from the spec with evidence.
- Two targets (
project.yml, XcodeGen — the generated.xcodeprojis a disposable artifact, gitignored, regenerated bymake gen):CloseUpKit(Sources/CloseUpKit) — static library, all testable logic, no UI deps. System facilities (AX, CGWindowList, workspace) sit behind protocols returning semantic values so the logic is unit-testable with mocks.CloseUp(Sources/CloseUp) — thin app shell: SwiftUI/AppKit + Sparkle + KeyboardShortcuts + PermissionFlow.CloseUpTests(Tests/CloseUpKitTests) — hostless unit tests + i18n guards.
- Command interface (
make):gen / build / run / test / archive / dmg / clean+update-test-*. Humans, agents, and CI use these identically. - Dev vs release identity: Debug is
com.oomol.CloseUp.dev/ "CloseUp Dev", ad-hoc signed, no hardened runtime — a different app to the OS so a local build never collides with the installed release (login item, defaults). Ad-hoc re-hashes every build, so the Accessibility TCC grant is orphaned after eachmake build— painful when iterating on the overlay (which needs AX). Opt in tomake dev-cert(creates a per-machine self-signed identity in the login keychain;make buildauto-detects it viafind-identity— no-v, the cert is an untrusted root — and forcesCODE_SIGN_STYLE=Manualso the override doesn't make the SPM deps demand a team) and the grant survives rebuilds. Nothing secret is committed; a fresh clone builds ad-hoc with zero setup. A missing AX grant fails SILENTLY and misleadingly: the CGEvent tap can't be created so no click is ever handled (overlay buttons dead, click-to-dismiss dead), yet the lights still render via the no-AX fallback below — so it looks like a code bug, not a permission one. Tell from the log:tap create failed (Accessibility not granted?)andobserver armed (… trusted=n). A grant bound to a prior (ad-hoc) signature is orphaned when you switch to the dev-cert, and toggling the stale Accessibility row won't re-bind it —tccutil reset Accessibility com.oomol.CloseUp.dev, then re-grant once (it persists across dev-cert rebuilds thereafter). Amake buildrun from a SANDBOXED context (e.g. some sub-agents' Bash) can't read the login keychain, sosecurity find-identityreturns nothing and the Makefile SILENTLY falls back to AD-HOC signing — which re-hashes the binary and orphans the grant (trusted=n,tap create failed) even though you have the dev-cert. So when the AX grant must survive (any click/button/tap verification), do the dev-cert build + relaunch from the MAIN shell (non-sandboxed) — therefind-identitysees the cert and the build re-matches the existing grant (trusted=y, no re-grant). Confirm withcodesign -dvv <app>: it must showAuthority=CloseUp Dev Self-Signed, neverSignature=adhoc.log show --debugdoes NOT see the hot-path.debuglines (hover,overlay show,MC state) — they are not persisted to the store, solog showreturns nothing for them even with--debug; only.notice+ lines (session begin/end,layout settled,observer armed) come back. This trap reads as "the overlay never fires" while a screenshot proves it does. To observe the hot-path events during a repro you MUST capture a live/usr/bin/log stream … --debug(see the diagnostics rule below) —log showpost-hoc is fine only for the.noticelifecycle lines. - Per-architecture binaries (arm64 + x86_64), macOS 14.0 floor — CloseUp
ships one app PER ARCHITECTURE, never a universal binary, valuing a smaller
per-CPU download. A post-build phase thins the prebuilt fat Sparkle
xcframework to the built arch (
scripts/thin-embedded-frameworks.sh, wired inproject.yml; CI's export step asserts the main binary AND embedded Sparkle are single-slice). Each arch is its own product line with its own symmetric Sparkle feed —appcast-arm64.xml/appcast-x86_64.xml, each pinned at COMPILE time inUpdaterDelegate.feedURLString(for:)via#if arch(both return their own explicit URL — no Info.plist fallback) so no CI misconfig can cross-wire feeds, and cross-arch updates are unsupported by design. There is NO backward-compat with the universal 0.1.0: it bakedSUFeedURL=appcast.xmland the per-arch feeds are new files, so 0.1.0 is an intentional orphan (cannot auto-update) — do not re-introduce a compatibility shim.project.ymlpinsARCHS: arm64for local builds; CI overrides per pass (ARCHS=x86_64 ONLY_ACTIVE_ARCH=NO), andmake build ARCH=x86_64cross-builds the Intel app locally. Reach newer APIs only behind#availablewith a fallback (seeDS.dsGlass*ButtonStyle).generate_appcastderives the output appcast FILENAME from the app'sSUFeedURLbasename (it can't run the app, so it readsInfo.plist), soInfo.plist'sSUFeedURLMUST stay…/appcast.xml. The CI's LOCAL working file in everybuild/dist-$ARCHisappcast.xml(the arch already lives in the directory name); the per-arch feed name is applied ONLY at the gh-pages boundary — seed fromorigin/gh-pages:appcast-$ARCH.xmlintodist-$ARCH/appcast.xml, then publishdist-$ARCH/appcast.xmlback toappcast-$ARCH.xml. This mirrors LockIME. IfSUFeedURLis ever changed toappcast-arm64.xml(as #3 did),generate_appcastwritesappcast-arm64.xmlin EVERY dist dir, so the localtest -f .../appcast.xml/ publishcpmiss the file → "appcast step exits 1 right afterWrote 1 new update" (the log line…in appcast-arm64.xmlis the DERIVED name from the basename, NOT a per-arch feed).SUFeedURLis a runtime no-op (both arches pin their feed explicitly infeedURLString), so it is free to carry the canonicalappcast.xmlbasename. Do NOT use-oto force a per-arch local name — and do NOT, like LockIME, let arm64's feed BEappcast.xml: LockIME's legacy was arm64-only, but CloseUp's universal 0.1.0 also runs on Intel, so the feeds stay symmetric (appcast-arm64.xml/appcast-x86_64.xml) andappcast.xmlis a frozen 0.1.0 orphan that is never written. - Non-sandboxed, Hardened Runtime. The Accessibility / CGEvent-tap / private API path the core feature needs cannot run in the App Sandbox — the entitlements file is intentionally empty.
The Mission Control overlay relies on undocumented/private APIs (all widely used in notarized Developer-ID apps; they bar Mac App Store distribution, which is out of scope). Reference blueprints: established GPL-3.0 Developer-ID apps that drive Mission Control over this same private-API path.
- Mission Control is drawn by the Dock (
com.apple.dock). Detect open with anAXObserveron the Dock pid forAXExposeShowAllWindows/AXExposeShowFrontWindows. - Do NOT use
AXExposeExitto detect close — it is unreliable. A 3-finger trackpad swipe firesAXExposeExitwhile Mission Control stays open (even a swipe that changes no Space), so tearing the session down on it makes the lights vanish on every window for the rest of the MC session until MC is reopened. Detect close by polling a reliable signal: the Dock owns an exposé surface at window layer 18 (kCGWindowLayer == 18) only while MC is visible — match it by the Dock pid (kCGWindowOwnerPID; the owner name is localized, e.g. "程序坞"), debounce a few misses, thenendSession. (Polling beats the notifications: the AX expose events are unreliable under trackpad swipes, so a notification-driven design — which mirrors our old approach — has the same latent bug the layer-18 poll avoids.) - The Dock observer must self-heal — it is NOT fire-and-forget. Two hard-won
failure modes silently kill the overlay on every desktop until relaunch:
(a) the
AXObserverbinds to the Dock pid captured at arm time, so a Dock relaunch/crash leaves it bound to a dead pid forever; (b) it stops delivering after a Space transition into a full-screen app. So re-arm (stop + re-start, which re-reads the live Dock pid): onactiveSpaceDidChange(the full-screen-wedge case), and via a ~2 s pid-diff health poll for a Dock relaunch. Do NOT useNSWorkspace.didLaunchApplication/didTerminateto detect the Dock — verified: those never fire forcom.apple.dock(it's an agent/LSUIElement), so a pid diff (MissionControlObserver.armedDockPIDvscurrentDockPID()) is the only reliable signal. Keep the space observer + poll alive for the whole engine lifetime, not just per-session. Never assume the Dock pid is stable. - Reconcile open/close idempotently; never de-dup the edges. The Dock
coalesces/drops the expose notifications (a missed
AXExposeExitacross a Space switch must not be able to wedge every later open), and re-arming re-fires an open. The lifecycle is driven by the layer-18 poll gated on a singlesessionActivebool → begin / resync / end; a repeat-open re-syncs, never stacks a second set of timers/tap. (The oldMissionControlSessionreducer is gone — the poll + bool is the authority.) - Diagnose live via
Log(os.Logger, subsystemcom.oomol.CloseUp) — capture with/usr/bin/logBY FULL PATH. In zsh, barelogis a SHELL BUILTIN (the csh-compat watch-list printer), solog stream …dies withtoo many argumentsand silently captures zero lines — the real cause of the "app emits no logs" trap (it is NOT word-splitting). Use, from a script file:/usr/bin/log stream --predicate 'subsystem == "com.oomol.CloseUp"' --info --style compact. - Enumerate window frames with
CGWindowListCopyWindowInfo(.optionOnScreenOnly), keepkCGWindowLayer == 0, drop the Dock. Map a frame to its AX window by CGWindowID via private_AXUIElementGetWindow. - Act via the window's AX buttons (
kAXCloseButton/kAXMinimizeButton/kAXZoomButton+kAXPressAction); quit viaNSRunningApplication.terminate(). - Show a traffic-light only for a control the window actually has — resolved via
AX, so display now needs Accessibility.
CGWindowListcan't tell a real window from a popover/sheet/chrome-less panel, so filtering on it alone lit up auxiliary windows (the "lights on popups" bug). Resolve the hovered window's AX element (AXUIElementCreateApplication→ match by_AXUIElementGetWindow) and keep only the actions whose AX button exists; a window with none → no overlay.WindowCapabilities/WindowCapabilityResolving(CloseUpKit, tested) is the pure intersection; the liveAccessibilityCapabilityResolverreturnsnilwhen AX is untrusted so the overlay falls back to showing all enabled actions (legacy no-AX behaviour) instead of vanishing. Accessibility is required precisely because the filter is AX-tree-driven: only the AX tree distinguishes a real, closable window from a chrome-less auxiliary surface. (The rest of the engine — aCGEventtap for input + anNSTimer-style poll for lifecycle,CGWindowListfor geometry, noAXObserver— needs no AX; the role/button filtering is what does.) - Show NO overlay on a native-full-screen window — gate it on the
AXFullScreenattribute, NOT on button presence. A full-screen app lives in its own Space; in Mission Control its thumbnail sits in the top Spaces strip (or App Exposé enumerates it at near-full display size), so the straddling cluster anchors atminY - buttonSize/2 - clusterPadding— off the top of the screen, the user's "lights at the wrong position" report. Traffic-light controls make no sense on a Space tile anyway, so CloseUp shows none on a full-screen app — the behaviour the user asked for. The trap: a full-screen window's AX tree STILL exposeskAXCloseButton/kAXMinimizeButton/kAXZoomButton, so the button-presence filter alone lights it up; you MUST check full-screen explicitly.AXWindow.isFullScreenreads the undocumented-but-stable"AXFullScreen"window attribute (no publickAX…constant exists; the de-facto signal yabai/Amethyst use);AccessibilityCapabilityResolverreturnsWindowCapabilities.nonewhen it is true, sorepositionOverlayorders the overlay out. Verified the signal directly: a real native-full-screen window (entered via⌃⌘F) readsAXFullScreen == truethrough the resolver's exact match-by-windowID path, while every normal window readsfalse(so they are never suppressed). NB on a multi-display setup a native-full-screen app is NOT enumerated as akCGWindowLayer == 0window during a Mission Control session at all (it only shows in the top strip, which is not a CGWindowList window) — App Exposé is where it appears near-full withAXFullScreen == true; CloseUp sessions only on Mission Control, so reproducing the on-screen bug is config-dependent, but the AX guard is correct for every path that can enumerate the window. - The traffic-light cluster STRADDLES the thumbnail's top edge (half above the
window), so hover resolution must be STICKY to the cluster — never re-resolve the
hovered window by
frontmost(containing:)alone while the cursor is over the current cluster. SinceclusterFrameCG.minY == windowFrame.minY - buttonSize/2 - clusterPadding(≈16px above the top edge), the outer half of every button sits outside the window rect.frontmost(containing:)only tests the window rect, so the instant the cursor reaches the off-thumbnail half it returns nil/another window →hoveredflips →repositionOverlaytears the lights down: the "lights vanish on the outside half" field bug, probabilistic because whether another window underlies that 16px band varies. Fix intrackMouse:let overCurrentCluster = geometry?.clusterFrameCG.contains(location) ?? falseand keephoveredwhenoverCurrentCluster && hovered != nil, elsefrontmost.geometryis non-nil only while the hovered window's lights show and always describeshovered, so the sticky region is exactly that window's own buttons. (This mirrors how a native title bar feels — its controls hang off the thumbnail and stay lit on the outer half.) Verified on-screen: cursor parked on a button's outer half (CG y above the window top) keeps the lights; moving above the whole cluster logshover W→0and correctly releases. - Size the overlay
NSWindowto the cluster BEFORE mounting theNSHostingViewcontent — never mount-then-resize, or the lights "fly in from the top-left". The overlay window is created atcontentRect: .zero. IfrepositionOverlaysets the SwiftUI content first and grows the window after, the hosting view lays theHStackout inside a 0×0 bounds — all buttons collapsed at the top-left origin — and the subsequentsetFrameis seen by SwiftUI as an animatable layout change from that collapsed state, so the buttons animate out to the row from the corner (probabilistic: only when the collapsed first layout gets composited before the resize lands). Correct order:window.setFrame(geo.nsWindowFrame, display: false)(defer redraw so no stale frame paints at the new size) →setOverlayContent(...)(a FRESH hosting view is born already at the final bounds, so its first layout is the final one — no 0→size transition) →window.orderFront(nil). Holds for every show path because they all route throughrepositionOverlay(hover-change reuse, make-before-break reanchor, re-tile recreate). NB the per-button hover lift (scaleEffect+DS.Motion.overlay) is NOT the cause —scaleEffectis a render transform and never reflows siblings; the fly-in is purely the hosting view's 0→size first layout. Verified frame-by-frame at 120fps: pre-fix ~8 frames of corner-collapse→spread on appear; post-fix the cluster appears atomically at its final position in one frame. - Overlay = borderless
NSWindowat.screenSaverlevel (1000); a globalCGEventtap swallows only clicks/keys hitting a button and passes everything else through, so Mission Control keeps its own Esc/arrow handling. Re-enable the tap on.tapDisabledByTimeout/.tapDisabledByUserInput. - Overlay
collectionBehaviormust NOT include.transient. The system hides transient windows during Mission Control gestures, so a 3-finger swipe — even one that changes no Space (e.g. swiping past the last desktop) — hides the overlay for the rest of the MC session; lights vanish on every window until MC is reopened. Use[.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle](an overlay that sets none of these is immune). The trigger is the gesture, not the Space change — reproduce with the real trackpad swipe;⌃→/⌃←keyboard Space-switches do NOT trigger it. - Rebuild the overlay
NSWindowon every MC re-tile — detect it by thumbnail frame churn, NOT byactiveSpaceDidChange. A window ordered-in before MC re-tiles stays composited BELOW the Dock's rebuilt exposé surface — even at.screenSaverlevel and still reportingisVisible == true— so the lights vanish; only a window ordered-in after the re-tile lands above it.activeSpaceDidChangeis NOT a sufficient trigger: the worst case is a swipe past the last desktop that changes no Space — it fires noactiveSpaceDidChangeand does not even bump the exposé-surface window number, yet it re-tiles and sinks the overlay (feature dies until you switch Spaces). The one signal that catches every re-tile (Space switch, boundary swipe, full-screen transition) is the thumbnail frames shifting:refreshWindowsdiffs per-window-id frames each tick and, once the churn settles (held still ~2 ticks),recreateOverlayWindow()(orderOut+close()+nil+ensureOverlayWindow) once and re-anchors — which also fixes the lights lagging the moved thumbnail (the "misaligned lights"). Rebuild on settle, not during churn, or it flickers through the animation. Corollaries that cost hours: (1)visible=yin the log is a LIAR — the sunk window believes it is visible; confirm on a real screen, never the log. (2) Keyboard⌃→/⌃←does NOT reproduce the sink (same-windoworderFrontrecovers for keyboard but not the trackpad re-tile), so it is only verifiable with the real trackpad gesture, never synthetic input. - Gate the overlay's display on the layout being SETTLED — never show lights
while Mission Control is still entering (or re-tiling). The same thumbnail-frame
churn signal that drives the re-tile rebuild (above) also gates the first
appearance:
trackMouseearly-returns whilelayoutSettled == false, so the lights stay hidden until the frames hold still forsettleTicks(~200 ms), at which point the overlay window is rebuilt + re-anchored and shown. Without this the lights appeared the instant MC began opening and chased the thumbnails across the screen — as MC tiles, different windows sweep under the fixed cursor, sofrontmost(containing:)keeps changing and the overlay jumps from frame to frame. Barely visible on a fast swipe (windows are already at rest by the first refresh → no churn → shows once at the final spot), glaring on a slow one. The lights must appear only once MC has finished entering, for exactly this reason.layoutSettledis false at everybeginSession/resyncSessionand whenever churn resumes (which alsoorderOuts the overlay); the settle detector flips it true. The fast path must NOT special-case "no churn → show now": a fast enter still resolves through the 2-stable-tick wait (~200 ms, imperceptible) so there is one uniform rule. Verified withdockswipe mission-control --steps 60 --interval 16000(~1 s slow enter) + livelog stream: pre-fix 3overlay showat moving positions during the enter; post-fix 0 during the enter, one at the settled position. - Gate display on cursor MOVEMENT too — the lights appear only when the pointer
MOVES, never on a stationary cursor. This is the load-bearing one; frame-settle
alone is NOT enough. The mechanism: drive the overlay off a poll whose show path
early-outs when the cursor has not moved since the last tick (a plain
CGPointinequality onlastMouseLocation) — only (re)resolve/show the cluster when the cursor actually moved. TheCGEventtap is kept minimal —{LeftMouseUp, KeyDown, OtherMouseUp}, i.e. clicks + hotkeys ONLY, no mouseMoved/scroll/gesture API — so the engine has NO way to read the trackpad gesture phase, by design; the cursor-movement check is what keeps the lights from appearing early. The consequence: a Mission Control swipe never moves the cursor, so while MC is entering — or when the user pauses a 3/4-finger swipe halfway (fingers down, thumbnails momentarily still) — the cursor is stationary and the lights stay hidden; they appear only once the user moves the pointer, by which point MC has finished entering. Frame-stability (layoutSettled) alone CANNOT tell a paused gesture from a settled layout — it fired on the pause (the user's bug report); cursor movement is the missing signal. CloseUp implements this intrackMousevialastMouseLocation, and the two gates compose:layoutSettledsuppresses a cursor that moves during the enter animation (no chasing); the cursor-movement gate suppresses a stationary cursor through the enter/pause. Verified on-screen: a slowdockswipeenter with the cursor PARKED over a window shows 0 lights even after the layout settles; a tinycliclickmove then lights exactly that window. (The primary gate is frame-stability — an EXACT, no-tolerance whole-set identity of{CGRect, windowNumber, ownerName}across two consecutive poll ticks, keying MC-open on the Dock-owned exposé surface atkCGWindowLayer == 18; the cursor-move check is the secondary re-show guard.) - On a fast single-window enter the settle-time show can fail to PAINT — recover
it with a post-settle re-anchor watch, NOT by detecting a sink. Field bug #3:
current Desktop has one near-full-screen window, cursor inside it, a FAST 3-finger
swipe up — probabilistically no traffic lights, and (unlike a normal miss) moving
the cursor within the same window does not bring them back; only moving out and
back does. Per-tick diagnostic logging during a real-trackpad repro proved the
cause:
repositionOverlayruns once at settle but the overlay never lands on screen — it is leftisVisible=false, or ordered front before the Dock finished compositing its layer-18 surface so it is stacked visually underneath. Because the show is gated on the hovered windowID changing, a stationary cursor (an MC swipe never moves it) and even in-window movement never re-resolve, so it stays missing until the hovered window changes (the user's "move out and back"). A z-order /isVisiblesink check CANNOT catch this — a window you justorderFront'd reads as front-most ANDisVisible=truein CGWindowList while still visually underneath (the diagnostic'ssunk=never once fired in a failing repro). Fix: a post-settle watch (sinkWatchTicks, ~30 ticks ≈1.8 s, reset at every session boundary, set at settle) during whichtrackMouseat a few FORCED ticks ({6,14,22,28}) UNCONDITIONALLY re-anchors a FRESH overlay window via make-before-break (overlayWindow=nil; ensureOverlayWindow(); repositionOverlay(); old.orderOut; old.close()— show the new one before closing the old → blink-free, user-confirmed at 4 re-anchors/enter with no flicker). A fresh window ordered-in after the Dock has finished compositing lands above the surface; the spread-out forced ticks cover the range of finish times, so a stationary cursor self-heals with no movement. An A/B across builds proved the unconditional forced re-anchor is what fixes it (a detection-gated build still failed, since the sink check never fired). A click must END the watch:hideOverlay()setssinkWatchTicks=0, else a forced tick re-shows the lights on a window the user just hit a traffic-light button on (button hits keephoveredand deliberately don't suppress) — the "button hit re-shows only on the next fresh hover" rule. As with the sink/re-tile bugs, synthetic input (dockswipe) does NOT reproduce this no-paint race — a 4-round dockswipe run showed every enter succeeding; only the real trackpad does. - Once a session is open, IGNORE the live AX expose notification — do not resync
on it. The Dock's
AXExposeShowAllWindowsis laggy (observed arriving ~1 s after the layer-18 poll already began the session); feeding it toresyncSessionwhilesessionActiveforced a redundant same-position hide/re-show blink right as the lights settled.handleStateChangenowguard !sessionActive else { return }thenbeginSession— the poll + churn detector are authoritative for a running session, and a genuine Space change still re-syncs viahandleActiveSpaceChange. The AX notification is now purely a fast-path for the initial open. - Drop windows reported off-screen during the MC re-tile. While Mission
Control re-tiles after a Space switch,
CGWindowListtransiently reports a window at a garbage off-screen position; if it covers the cursor the overlay anchors off-screen.WindowInfo.anchoredOnScreen(displays:inset:)(vsCGDisplayBounds) filters these inrefreshWindows; refresh at 100 ms (not 250) to track the animation. - Coordinate flip: CGWindowList/CGEvent are top-left origin;
NSWindowis bottom-left —convertedY = screenHeight - cgY - height. The #1 bug here. Pick the screen by frame containment, neverNSScreen.screens.first. - In-Mission-Control action shortcuts (⌘W/⌘M/⌘H/⌘Q, ⌥-batch) are intercepted by
the tap only while MC is open — they are NOT global hotkeys (⌘W globally
would hijack every close). Customizable via
KeyboardShortcuts.Recorder+KeyboardShortcuts.disable()(suppress global firing) +getShortcut(for:)matched in the tap. True global hotkeys are reserved for safe actions. - Any left click while MC is open dismisses the overlay instantly — then
suppress re-show through the exit with a SELF-CLEARING flag, never a session-long
latch.
handleClickorderOuts the overlay on every click (a button hit performs its action first, then hides); relying on the close-poll'sendSession(~600 ms) instead leaves the lights lingering after the click. A pass-through click (thumbnail / empty space → MC is exiting) additionally setssuppressOverlayReshow, because MC's exit animation churns thumbnail frames andtrackMousewould otherwise re-show the overlay at a transient garbage top-left frame for a tick or two beforeendSession— the "post-click flash" on the desktop. Clear the flag on session begin/end plus a ~2 s safety timer (sized to outlast the exit + the ~600 ms close-poll, so it never fires mid-exit and re-flashes) — the timer is the fallback for a click that leaves MC open (empty space), so the lights recover on the next hover. Do NOT hold it until the nextbeginSession(a session-long latch): that reintroduces the documented "lights dead for the rest of the MC session" class for any click that doesn't dismiss MC. A button hit must NOT suppress — MC stays open, so the lights re-show on the next hover for back-to-back window management.
en (source), zh-Hans, zh-Hant, ja, fr, de, es, pt (Brazilian house style), ru.
Switching is live (no restart): the chosen \.locale is injected at every scene
root with .id(localeIdentifier) to rebuild the subtree (localized(with:)).
- Never display text localized by someone else's bundle. Framework/3rd-party
localizedDescriptionresolves against the system language → mixed UI. Map errors to your own catalog keys; log the original. A deliberate non-UI use opts out with ani18n-exemptcomment. - Native-bridging surfaces (status-bar menu, window titles, alerts,
.help,.navigationTitle) bypass the injected locale — resolve them throughappState.loc(...)/AppKitStrings, never a bareLocalizedStringKey. - Re-inject
.environment(\.locale, appState.locale)at every.sheet(and popover/separate scene) — they reset to the system locale. KeyboardShortcutsresource bundle is redirected to the app language viaThirdPartyBundleLocalization(re-classing the SPM bundle); a guard test pins its name + per-language coverage.- Coverage is per-change: every user-facing string gets a finished translation for all 8 non-English languages in the same commit. Scripted catalog edits round-trip-assert and produce addition-only diffs (never reorder keys). Cross-boundary machine-facing text (error codes) stays English.
- Guard tests (
Tests/CloseUpKitTests/LocalizationGuardTests.swift) enforce the above; keep them green and extend them as new surfaces/packages appear.
- Zero build warnings; Swift strict concurrency
complete(new warnings block). - Native-and-consistent beats clever-and-custom; semantic system colors only;
design tokens in
DS(UI/DesignSystem.swift) — never inline literals. - TCC permission state is observed by polling
AXIsProcessTrusted()(macOS posts no grant notification); re-attach observers on grant, recreate on revoke. - Visual work is unverified until seen on a real screen — flag "needs on-screen confirmation".
Sparkle 2.9.0 (update) · KeyboardShortcuts 1.10.0 (hotkeys) · PermissionFlow 2.5.0 (AX permission).