Authored before the iOS path (F5) is built, per the cli repo's
.claude/checklists/parallel-path-parity.md. Every observable capability has a
row; each asymmetry is closed by code or has a tracked mitigation.
| Capability | macOS adapter | iOS adapter | Asymmetry / mitigation |
|---|---|---|---|
| Default element source | AX hierarchy (own pid) | UIView tree (clean-room walk) | iOS defaults to the view-tree walk; it still surfaces SwiftUI accessibilityIdentifier (set on the backing UIView), so a separate AX source is not needed |
| Opt-in element source | NSView tree | (the default already walks the view tree) | macOS adds a view-tree opt-in alongside its AX default; iOS only needs the one source |
accessibilityIdentifier |
via AX | via UIView.accessibilityIdentifier |
both surface SwiftUI identifiers |
| Concrete view class name | via NSView opt-in source | via the default UIView walk | none |
| Hit test primitive | AXUIElementCopyElementAtPosition + NSView hitTest |
UIView.hitTest(_:with:) |
iOS has no global AX point query; uses view hitTest. Tracked: F5.2 |
| Annotation target rule | shared AnnotationTargetRule over an AX candidate chain |
shared AnnotationTargetRule over a UIView candidate chain |
none — both build a [TargetCandidate] chain and apply the SAME rule (deepest actionable, else deepest meaningful). Closes the earlier split (macOS "deepest meaningful" vs iOS "nearest identified"), cli-got28.2 |
| Component widening | ComponentLadderSource (AX chain) |
ComponentLadderSource (UIView chain) |
none — same ladder (target, then enclosing identified components) |
| Marquee selection (drawn frame → element) | MarqueeTargetSource: shared MarqueeTargetRule over [MarqueeCandidate] read from the AX tree |
MarqueeTargetSource: shared MarqueeTargetRule over [MarqueeCandidate] read from the UIView tree |
none — the DECISION is one pure rule (largest ≥85%-surrounded element, else the tightest enclosing one); the adapters differ only in how they read candidates out of their own tree. Both do ONE walk from a single root so depth (the rule's tie-break) is numbered comparably, both collect the subtree WHOLE (an intersects-the-frame filter would discard the enclosing-pass candidates), and both return the SAME target-first, broadest-last ladder as ComponentLadderSource, so the session's widening and component field work unchanged from a framed selection |
| Child navigation (select child) | ChildNavigationSource over the AX tree; the opt-in view-tree source implements it over NSView |
ChildNavigationSource over the UIView tree |
none — the ORDERING is the one pure ChildNavigationRule (contains the gesture's hint, then seeded, then larger area, then lowest index) on all three adapters, which supply only [ChildCandidate]. All three also share the cost shape the protocol mandates: re-find the bound element by descending its containing window along its own frame centre (bounded by tree DEPTH, not tree size), then collect the NEAREST meaningful descendants, each branch stopping at its first meaningful node — so a SwiftUI host's unidentified layout wrappers are descended THROUGH rather than offered as children, which is what keeps the Child control alive under pure SwiftUI on both platforms. macOS additionally skips window chrome and its own overlay window during that descent; UIKit has no chrome, and the overlay is already excluded by the shared window lookup |
| Frame-mode anchoring + hover gating | shared AnnotationSession (selectionAnchorFrame, the tool == .point hover gate, setTool clearing hovered) rendered by the shared OverlayView |
same | none in the code — all of it is session-level and platform-free, and one SwiftUI view renders it. ASYMMETRIC VERIFICATION, recorded as a gap rather than closed: AnnotKitOverlayProbe Phase 8 drives navigation, the note's component, the hover gate and the frame anchor against a REAL accessibility tree, and it is macOS-only (#if os(macOS), AppKit + AXUIElement), so the iOS adapter's live behaviour is covered only by unit tests over the pure rules. Mitigated, not fixed, by the fact that everything Phase 8 asserts about anchoring and hover lives in the shared session; what remains unverified on iOS is the ADAPTER's candidate collection. Note the hover gate is also moot on touch-only iOS — hover exists there only with a trackpad or pencil — so the reported symptom cannot arise without a pointer |
| Marquee drag threshold | cursor slop (a mouse does not move on a deliberate click) | larger touch slop | ASYMMETRIC BY DESIGN, owned by the drag UI, not the adapters: a finger rolls several points on a deliberate tap, so the macOS threshold on iOS would turn taps into marquees. Below the threshold both platforms route the gesture to the point path (select(atAXPoint:)), per the caller contract on select(inAXRect:) |
| Escape (back out one level) | NSEvent.addLocalMonitorForEvents(matching: .keyDown) owned by OverlayController, resolving the shared pure EscapeRule (drag → card → mode, pass-through when idle) |
none — no Escape key exists on iOS | ASYMMETRIC BY THE HARDWARE, not by the code: a touch device has no Escape key, so there is nothing to bind. The DECISION is platform-free and unit-tested (EscapeRule), so an iOS back-out affordance (a swipe, a hardware-keyboard binding on iPad) can adopt it without re-deriving the precedence; only the macOS delivery mechanism is #if os(macOS). The mechanism is a LOCAL KEY MONITOR rather than a SwiftUI modifier (.onExitCommand) because a panel-scoped modifier only fires while the overlay panel is KEY, and the panel is made key solely by a card focusing its text field — so in annotate mode with nothing open, the state a user most wants to leave, the HOST window is key and no view in the panel ever sees the keystroke. A local monitor works precisely because AnnotKit is in-process with its host: the Escape headed for the host window passes through it first, and it can swallow what it acted on (EscapeAction.consumesEvent), which a global monitor cannot. It is installed in start() and removed in BOTH stop() and unmount() — a monitor outliving the overlay would keep eating the host app's own Escape for the life of the process |
| Toolbar panel footprint | sized to the pill (OverlayPlacement.toolbarFrame(hostFrame:visibleFrame:panelSize:), measured from a fresh hosting view) |
n/a — the iOS overlay is a single pass-through UIWindow, and the pill is not carried in a window of its own |
ASYMMETRIC BY THE HOSTING MODEL. On macOS the pill lives in its own NSPanel, and a panel covers what it covers: macOS does not route mouse events through a window's transparent parts, so the panel's SIZE is the host app's clickable area. iOS has no equivalent problem — PassThroughWindow forwards touches outside the overlay's interactive subviews, which is the pass-through macOS does not offer |
| The first click on the overlay acts | FirstMouseHostingView (acceptsFirstMouse -> true) on both panels' content views |
n/a | ASYMMETRIC BY THE PLATFORM, not by the code: "a mouse-down in a non-key window is discarded unless the view accepts first mouse" is an AppKit window-activation rule with no UIKit equivalent — iOS has no key window a press must first buy, and a touch on the pass-through UIWindow is delivered on the first tap. Nothing to mirror; the fix lives entirely in the macOS host |
| Live selection follows the content on scroll | translateSelection(by:within:), driven by the same measurement KeyablePanel.scrollWheel already makes |
none | SAME asymmetry and the same reason as the row for captured notes below: the correction is affordable on macOS only because the overlay panel intercepts every wheel event and drives the clip itself. The receiving side is shared and unit-tested (translateSelection is pure geometry over session state), so an iOS host that observes its own scrollers can adopt it unchanged |
| Recallable selection marks (a captured note's geometry comes back on hover) | shared: AnnotationNote.anchorRect/drawnRect snapshotted in AnnotationSession.addNote, PinAttentionRule + RecalledMark as pure rules, rendered by the shared AnnotationMarks |
same | none — every part of it is platform-free. The two rects are WINDOW-LOCAL, which is the one place a platform could have crept in: macOS subtracts the overlay panel's axOrigin, iOS passes .zero because its view-tree frames are already view-local, and addNote takes that origin as a parameter rather than knowing which platform it is on. Both fields are UI-only and out of CodingKeys, so the exported record is identical on both |
| Pin VIEW inert in frame mode / hover-to-edit gated to point mode | shared AnnotationPins (allowsHitTesting(session.tool == .point), and the same condition on the hover that opens the editor) |
same | none in the code. ASYMMETRIC VERIFICATION, recorded rather than closed: AnnotKitOverlayProbe phase 11c posts a real press at a pin's own coordinates in each mode and is macOS-only, so on iOS the fix is covered only by the unit tests over the session and the rules. Note the hover half is largely moot on touch-only iOS — hover exists there only with a trackpad or pencil |
| Pin CLICK-TO-EDIT in both tools (VRT-u209) | shared: SelectionGesture.resolve(…pins:) consults PinAttentionRule.pressedNote on a press that did not travel, and the shared catcher in OverlayView opens the editor for .editNote |
same | none — the whole decision is a pure rule over the notes' stored window-local anchors, so both hosts route a tap identically, and iOS gets it from the one catcher it already shares. The TRAVEL gate is the only platform-sensitive part and it is already parameterized (SelectionGesture.minimumTravel is 10 on iOS vs 6 on macOS, because a finger rolls several points on a deliberate tap) — so a touch that would have been a sloppy tap still opens the pin's editor rather than being read as a drag. Verification is the same asymmetry as the row above: probe 11c is macOS-only; iOS relies on the shared unit tests |
| Recall survives a scroll | KeyablePanel.scrollWheel measures the translation it applied to the host's NSScrollView and feeds AnnotationSession.translateNotes(by:within:) |
none | ASYMMETRIC BY THE MECHANISM: the correction is affordable on macOS only because the overlay panel already intercepts every wheel event and drives the clip itself, so the exact translation is known at the moment it is applied. iOS does not intercept UIScrollView, so nothing there knows a scroll happened; a note's rects stay where they were captured, which is the pre-existing behaviour AnnotationPins has always accepted for the pin. The RECEIVING side is shared and unit-tested (translateNotes is pure bookkeeping over pending), so an iOS host that later observes its own scrollers can adopt it without re-deriving anything |
| Overlay excluded from element lookup | AX window identifier (AXIntrospection.overlayWindowIdentifier) filtered out of every kAXWindows read |
PassThroughWindow TYPE identity filtered out of IOSElementSource.windows() |
ASYMMETRIC BY NECESSITY — the hosts are different window kinds. macOS's overlay is a separate NSPanel matched by the identifier the controller stamps on it; iOS's is a UIWindow in the HOST's scene sharing its pid, so no pid/scene filter separates it and a type check (internal to the module) cannot drift the way an identifier convention can. Both filter in the shared window lookup, so snapshot / hit-test / region-anchor / marquee agree; leaving it in would let a marquee bind the user's note to AnnotKit's own UI |
| Env-configured embedding (destinations, world context, event stream) | Annotation.install reads AnnotationEnvironment and builds NotesFileSink/JSONFileSink/JSONLEventSink |
same | none in the code — the environment parse, the sink fan-out, the per-capture context snapshot and the JSONL diff are all platform-free, and both install overloads go through one makeSession. ASYMMETRIC VERIFICATION, recorded rather than closed: AnnotKitEnvProbe (and the AgentLoopE2ETests that launch two of it) mounts a real overlay on an NSWindow, so it is macOS-only for the same reason AnnotKitProbe is. What that leaves unverified on iOS is only the MOUNT; every file the contract produces is written by shared code covered by AnnotationEnvironmentTests, EventStreamTests and SinkTests |
| Coordinate space | Cocoa bottom-left to AX top-left flip | UIKit top-left native | iOS needs no flip; shared ScreenSpace used only on macOS |
| Screenshot | ScreenCaptureKit / cacheDisplay |
UIGraphicsImageRenderer + drawHierarchy |
both capture own hierarchy only; no cross-window or secure overlays |
| Overlay host | resizing NSPanel (toolbar corner idle, full screen annotating) |
pass-through UIWindow |
both interactive; selection via the shared SwiftUI catcher, not a global monitor |
| Overlay AX-exclusion | mark window non-accessibility | mark window non-accessibility | none |
| Selected-text capture | responder / NSText |
UIResponder / UITextInput |
symmetric concept |
| Install API | Annotation.install() + SwiftUI modifier |
same | none |
| Sinks (file / clipboard / MCP) | shared | shared | none |
| Mac Catalyst | n/a | builds as the iOS path (os(iOS) true) |
AppKit-only pieces do not apply; UIKit adapter's concern |
Selector generation, the selector engine, and the note/sink layer are
platform-independent and shared (see Sources/AnnotKit).