Skip to content

Latest commit

 

History

History
40 lines (37 loc) · 13.4 KB

File metadata and controls

40 lines (37 loc) · 13.4 KB

macOS / iOS parity matrix (F0.4)

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).