feat: frame-to-select, tree navigation, Escape, and inline icon actions - #1
Conversation
The pencil no longer doubles as the exit affordance. Annotate mode now renders Copy / Export / Clear / X, with the X in the conventional far-right "close this mode" slot and drawn as a plain action (the expanded pill and the live catcher already signal the mode, so the old permanently-lit toggle glyph was redundant). Idle is unchanged: a lone pencil that enters the mode. Unlike the note actions the X is never gated on `hasNotes` — with zero notes everything else is inert, so it must stay live. Drops the now-dead `pencilOff` glyph and `PillButton.isActive` (no remaining call site) per the no-dead-code rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds MarqueeTargetRule, the rect counterpart to AnnotationTargetRule: a drawn frame binds to the LARGEST meaningful element it surrounds (>=85% of that element's own area), else to the TIGHTEST element enclosing the frame, else nil so the session can fall back to a region note. Largest-wins is deliberate: a click means "this exact spot" and descends, a box means "this whole thing" and must ascend past the labels it swallowed. Ties break on seeding first (the coextensive .axCardSurface leaf carries the identifier that locates code), then depth, then lowest index -- via an explicit strict-improvement fold, because max(by:)/min(by:) make no first-wins promise and a total tie must not resolve by iteration detail. Also declares the optional MarqueeTargetSource capability (declaration only; platform adapters land separately) and documents the rule in DECISIONS.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`AnnotationSession.select(inAXRect:)` is the marquee counterpart to `select(atAXPoint:)`. It delegates to `MarqueeTargetSource.marqueeLadder(in:)`, whose ladder contract is identical to `ComponentLadderSource`'s, so widening and the note's `component` field keep working untouched. A drag that resolves to nothing degrades to a REGION note anchored at the frame's centre, mirroring the no-hit click path but carrying the drawn frame instead of a point. The drawn frame is held ABSOLUTE (`selectedMarqueeRect`) and relativized only at `addNote`, because `widenSelection()` can rebind the note to an enclosing element AFTER the drag — a frame relativized at selection time would describe a box the note no longer names. Synthetic regions measure from `marqueeRegionOrigin` (the anchor) since their own frame IS the drawn rect and would trivially be (0, 0). Both new fields carry the exact stale-state hazard 7993a67 fixed for `selectedRegionOffset`: the `selected` didSet only clears on nil, never on replacement, so they are also cleared at the top of BOTH select entry points. Tests cover the regression in both directions (marquee -> click must not leak `regionRect`; region-click -> marquee must not leak `regionOffset`). `AnnotationNote.regionRect` follows the additive-optional pattern of `regionOffset` (doc comment, CodingKeys entry, trailing defaulted init param), so old JSON decodes to nil and click notes serialize unchanged. The formatter emits `**Region**: framed WxH at (x: X, y: Y) from the top-left of <selector>` as an `else if` against the point-offset line — the two locators are mutually exclusive by construction and the chain documents that. 86 -> 99 tests, all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e0.6) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Widening always rebinds to a real element, so a stale anchor origin would measure the persisted frame from the wrong box. Unreachable today (regions get no ladder) but it is the 7993a67 class of bug, so close it by construction rather than by invariant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Rect:) A press that never moved returns nil here; the gesture recognizer owns routing it to the point path. Names the predicted symptom so 'clicking does nothing in annotate mode' is searchable from the API it traces to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds AXIntrospection.marqueeLadder(for:) with a single depth-consistent subtree walk, MarqueeTargetSource conformance on both macOS sources, and a live-AX probe phase. Extracts the geometric container scan shared with the point path, and excludes container roots from it: the chain climbs to AXApplication, whose children include our own overlay panel, so every widening ladder was topped by AnnotKit's own UI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…T-cne0.7) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collects every identifier in the overlay panel's subtree and intersects it against all resolved ladders, with a spanning check so the assertion cannot go vacuous. Guards a role-based check's blind spot: stray identified DESCENDANTS of the overlay rather than the panel node. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the catcher's SpatialTapGesture with a single DragGesture (minimumDistance: 0) that branches at release: travel above the slop threshold routes to select(inAXRect:), anything at or below it routes to select(atAXPoint:) at the press's startLocation. The decision lives in a new pure MarqueeDrag type (ComposerPlacement pattern) rather than inside the gesture closure, because it is the part that silently breaks the mode when wrong: select(inAXRect:) returns nil for a zero-area rect, so a click routed into the marquee path makes clicking do nothing, and a jitter-drag is not zero-area at all — it resolves to whatever container the pointer sat in and plants a note the user never framed. One recognizer, not a composition: exclusive/simultaneous gestures make the recognizers negotiate, and the case that loses is the plain click. Also draws the in-progress band (dashed accent stroke, lighter fill) in place of the element highlight, and suppresses hover resolution while a band is live. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Port marquee selection to iOS: `IOSElementSource` now conforms to `MarqueeTargetSource`, walking the UIView tree once from a single window root to build `[MarqueeCandidate]` and resolving with the shared, pure `MarqueeTargetRule` — so an identical drag over an identical layout resolves identically on both platforms by construction, not by two implementations being kept in step by hand. Returns the same target-first, broadest-last ladder `componentLadder(at:)` produces, so the session's widening and the note's `component` field work unchanged. Also excludes AnnotKit's own overlay from `IOSElementSource.windows()` by `PassThroughWindow` TYPE identity. Unlike macOS (a separate NSPanel already unreachable via kAXWindows), the iOS overlay is a UIWindow in the HOST's scene sharing its pid, and its chrome is genuinely identified and meaningful — a marquee spans the area it draws across, so a large overlay surface could win the rule's first pass outright and bind the user's note to our own UI. Filtering in the shared helper makes snapshot, hitTest, keyWindow, componentLadder and the marquee path agree. Verified: `xcrun --sdk iphoneos swiftc -typecheck -target arm64-apple-ios17.0 -swift-version 6` clean (no warnings), and a Mac Catalyst build (the os(iOS) path) succeeds. macOS unaffected: 99 tests green, AnnotKitOverlayProbe all-PASS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Frame drawing becomes a MODE the user picks rather than a threshold the gesture infers. The pill's annotate row gains a two-button tool segment (point, frame) with the active one lit, split from the note actions by a hairline divider. Strict separation: in frame mode a plain click does nothing, and in point mode no press can produce a frame -- removing the implicit design's real hazard, where a jittery click crossed the travel threshold and silently planted a framed note. The threshold survives with a narrower job: telling a real drag from a wobble INSIDE frame mode. MarqueeDrag becomes SelectionGesture: it now owns the press->outcome decision for both tools, so the old name described half of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… button Navigation is now bidirectional. Ascending is the old widenSelection(); descending prefers HISTORY (walk back down the path you climbed) and only queries the source for children when already at the deepest known rung, prepending the child so a re-ascent returns to the target and a re-descent replays to the same child rather than re-running the heuristic under a live UI. Fixes a silent corruption prepending would otherwise introduce: the note's component was path[1], which held only while the path was strictly upward (every upward rung is seeded). With a child at index 0, index 1 is the original target, which may be unseeded -- and an unseeded Element.id is a slash-joined path, so the note would have exported a grep target matching nothing while looking plausible. It now skips to the first SEEDED rung above the bound one. Pinned by a mutation-verified test. Adds ChildNavigationSource with a shared pure ordering rule, implemented on both macOS sources and iOS. canSelectChild reads a cache, never the source, so SwiftUI rendering cannot trigger an AX walk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Frame mode was only half a mode: underneath, it still behaved like point
mode. Two distinct wrongs, both visible in one dogfooding screenshot of a
whole dashboard card lit up with its name tag while the user was in frame
mode and had drawn nothing.
1. During the gesture it hover-highlighted. `hover(atAXPoint:)` gated only
on `mode == .annotating`, so every pointer motion in frame mode ran an
AX hit-test and advertised a point-selection that mode can never make.
Now gated on `tool == .point` in the SESSION (the view keeps its
narrower during-the-drag guard as defence in depth), which also removes
one cross-process AX query per motion event. `setTool` additionally
clears `hovered`, so a highlight resolved in point mode no longer
survives the switch; the rest of its "touch nothing else" contract --
open composer, selection, pending notes -- is unchanged.
2. After the gesture the drawn frame was thrown away visually: every anchor
derived from `selected.frame`, so the highlight snapped from the swept
rectangle to the resolved element and the composer and pin followed it.
`selectionAnchorFrame` now exposes the drawn rect (absolute AX
coordinates, like `Element.frame`) while it is still the truth of the
selection, and the highlight, `composerPlacement` and the pin anchor all
prefer it. The committed frame renders SOLID (the in-progress band stays
dashed) with no name tag; the resolved element is named in the composer
header instead ("Frame -> Dashboard.Today"), so a note is never captured
against a target the user could not see, and no second rectangle appears.
Pressing Parent/Child is the user explicitly asking WHICH element, so the
anchor drops to that element and the drawn frame stays on screen dimmed
beneath it -- it is still what the note records. The anchoring flag is
cleared at the top of both `select` paths, in the `selected` didSet nil
clear, and in `bindCurrentRung` (the single funnel both navigation
directions pass through), because a frame -> navigate -> click sequence
that missed any one of them would anchor the next note to a stale
rectangle. The region-fallback branch deliberately gets no flag: its
synthetic element's frame IS the drawn rect.
The note payload is untouched -- `regionRect` plus `selector`/`component`
round-trip exactly as before, no new fields.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-mijf.2) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A perfectly axis-aligned drag (dy == 0) clears the gesture layer's travel threshold, so select(inAXRect:) really does receive a zero-height rect in normal use. Rejecting it after the per-selection clears silently stripped an open frame selection's anchor -- re-anchoring a composer mid-typing -- and did so without assigning selected, so the @published change driving the re-render never fired and the overlay kept stale geometry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r them in the probe DECISIONS.md gains two rows and two sections: why Parent/Child replaced the one-way "Widen", why descent replays history instead of re-querying a live tree, why prepending the frontier child is what makes the round trip hold both ways, the `component` consequence that follows from prepending (first SEEDED rung above the BOUND one, read from the identifier — an unseeded id is a slash-joined path that greps to nothing), and the open question F1 left about whether the parent chain stays seeded-only. Frame anchoring records why the drawn box outranks the resolved element visually, why the element is named in the composer rather than drawn, why navigating reveals it, and why the hover gate lives in the session. PARITY.md gains rows for child navigation (one shared pure ChildNavigationRule on all three adapters; each supplies only candidates) and for frame-mode anchoring / hover gating (shared session + shared view, so no code asymmetry — but the live-tree probe is macOS-only, recorded as a verification gap). AnnotKitOverlayProbe grows Phase 8: round trip up (climb 3, descend 3, land on the original), round trip down through a real ChildNavigationSource query, history-not-re-query (proved by the path depth NOT growing on a second descent), the component fix against the live tree (unseeded child, unseeded parent, component resolves to the seeded grandparent with no slash), an inert frame-mode hover on a point proven live in point mode, and the frame anchor surviving until navigation drops it while the drawn rect persists. Behaviour is unchanged; the probe reports nine phases, all PASS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…igation (VRT-mijf.3) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Update: epic
|
The `A`/`a` branch drew a straight line to the arc endpoint. That survived only while every arc in the set was a 1-2 unit corner round; Lucide `undo-2` is a 5.5-radius semicircular loop whose endpoint sits directly below its start, so the shortcut collapses the whole glyph to a vertical line. Implements SVG 1.1 F.6.5 endpoint -> centre parameterisation, emitted as cubics split at 90 degrees (k = 4/3 tan(theta/4)), with x-axis-rotation applied to the control points, both flags honoured, F.6.6.1 radius abs, F.6.6.2 radius scale-up so undersized radii still land on the authored endpoint, and F.6.2 degenerate handling. The parser's make-progress-or-bail contract is unchanged. Adds 18 geometric tests (sampled along the curves, not on bounding rects that may include control points), including a pinned 5.5-radius semicircle that fails against the old straight-line branch. Side effect: pencil, mouse-pointer-2 and square-dashed carry `a` commands and now render their true curves for the first time — the pencil's eraser butt is the visible one (r=1 across a 5.6-unit chord, scaled up per F.6.6.2). All stay inside the 24-unit grid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Escape previously did almost nothing: the only handler was `.onExitCommand` on the shared card, which fires only when the overlay PANEL is key — and the panel is made key solely by a card focusing its text field. In annotate mode with nothing open (the state a user most wants to leave) the HOST window is key, so no view in the panel ever saw the keystroke. - `EscapeRule` (pure, platform-free, unit-tested): drag -> card -> mode, and pass-through when idle so the host's own Escape is untouched. Drag beats card because the catcher stays live behind an open composer, so the two coexist. `EscapeAction.consumesEvent` pins the swallow contract: AnnotKit is in-process with its host, so a handled-and-forwarded Escape would be acted on twice. - `AnnotationSession` gains `isDrawingFrame` / `frameDragGeneration` / `hasOpenCard` so the handler, which lives outside the view, can see an in-flight gesture. Only the FLAG moves: the band's rect stays window-local `@State`, since everything in the session is AX screen space. - `OverlayController` owns an `NSEvent` local key monitor, installed in `start()` and removed in BOTH `stop()` and `unmount()` — a leaked monitor would keep eating the host app's Escape for the life of the process. - The composer draft is now cleared by the composer CLOSING rather than by the Cancel button, so a dismissal from anywhere no longer leaves a half-typed comment to reappear over the next selection. `.onExitCommand` is deleted, leaving Escape exactly one owner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dp47.1) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tating session unmount() removes the monitor but the session's mode outlives the panel, so a host that unmounts and re-mounts a live overlay came back annotating with Escape silently dead -- the one state with no other keyboard way out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g scroll Reported from dogfooding: "on scrollable screens, the menu in the bottom right disappears." Reproduced in AnnotKitOverlayProbe (new Phase 9) before fixing: * A tall/scrollable host is a window taller than the display. AppKit constrains a window's TOP under the menu bar but never lifts its bottom, so its bottom edge ends up below `visibleFrame` — and BOTH overlay modes anchored the toolbar to that bottom edge, drawing the pill under the Dock or off the display entirely. Measured: `windowNumber(at: pill center) == 0`, i.e. not clickable at all. * Worse, the expanded catcher swallowed every wheel event: it covers the host with `ignoresMouseEvents = false`, and an event no view handles walks the PANEL's responder chain, never the window beneath. The host could not be scrolled AT ALL while annotating, so nothing below the fold could be reached on exactly the screens the report is about. Placement now goes through `OverlayPlacement`, which narrows both modes to the intersection of the host frame and `host.screen.visibleFrame` (falling back to the primary display, and keeping the unclamped frame on an EMPTY intersection so an off-display host is not collapsed to nothing). The idle panel is ANCHORED to that region's bottom-right at full size rather than intersected down to it, since shrinking it would clip the pill it exists to carry. `axOrigin` and `surfaceSize` are re-derived from the CLAMPED panel frame, not the host: `OverlayView`'s contract is that they describe the surface it draws into, so a host-derived origin would offset every click, highlight and card by exactly the clipped amount. Clamping the bottom leaves the origin alone (it hangs off the frame's top edge) and only shrinks the surface, which is right — the composer should clamp cards to the VISIBLE region. Clamping the TOP really does move the origin, so Phase 9d builds a host tucked under the menu bar and asserts a click still resolves there; unfixed, that same click resolves to nil. `KeyablePanel` now forwards an unconsumed `scrollWheel` to the view under the pointer in the host, re-aimed through screen space (the panel is no longer the host's frame once clamped). Only events that reached the WINDOW are forwarded, so overlay chrome that legitimately scrolls still keeps its own wheel. swift build: no warnings. swift test: 181 (was 172). Probe: all phases PASS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(VRT-dp47.2) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both cards showed their actions as two rows of TEXT buttons — a Parent/Child navigation row above a Cancel/Add note footer — because four labelled buttons do not fit across the card's 260pt. Four 28pt Lucide glyphs do, so the split row was a symptom of the labels rather than of the grouping. - Four glyphs added to `LucideIcon` from lucide.dev's current `d` strings: `arrow-up`, `arrow-down`, `undo-2`, `send`. `undo-2` ships as two chained 5.5-radius quarter arcs, which only render as a loop because of the arc support added earlier in this epic; `LucideArcTests` now pins the SHIPPED glyph's 45-degree points, since a flattened arc keeps its endpoints and would pass every other check. - `PillButton` becomes `IconButton`, parameterised by an `IconButtonPalette`. One implementation of the interaction logic (hover wash, disabled dim, press scale, tooltip + matching accessibility label) now serves the dark pill and the `.regularMaterial` cards; the pill's palette reproduces its previous colours exactly, so its appearance is unchanged. - Composer: [arrow-up] [arrow-down] … [undo-2] [send]. Editor: [trash] … [send]. Icon-only, each with the full label in both a tooltip and an `accessibilityLabel`. The commit action keeps its prominence through an accent tint now that `.borderedProminent` is gone. Every disabled rule, the destructive treatment, the `⏎ save · ⇧⏎ newline` hint, and the session-driven draft clearing are unchanged. - `AnnotationCard` loses its `navigation` slot: it existed only to hold the second row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Update 2: epic
|
Two multipliers behind 'the menu disappears when I hover over it and then off it, on scrollable screens'. AXIntrospection.appElement() WROTE AXEnhancedUserInterface on every call, and the catcher calls it from the hover hit-test at up to 60Hz. That attribute announces an assistive client, and AppKit answers by re-evaluating and relaying out its windows -- so a moving pointer drove a resize storm in the host. Hovering ONTO the pill stops the writes (the pill consumes hover, so the catcher sees .ended and queries nothing) and moving OFF restarts them, which is exactly the reported trigger. Scrollable screens surfaced it first because a large scroll view is a large AX tree, so materialising it costs a real layout pass. It is now set once per process and the element is cached. Each resulting resize then pushed a fresh SwiftUI root view, tearing the pill down and rebuilding it mid-hover. syncFrameAndOrigin() now skips that when neither axOrigin nor surfaceSize changed, while still recording the host frame for the settle poll and still rebuilding on a REAL change. Both pinned by mutation-verified tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-follow THE vanishing-toolbar root cause, measured against a live host rather than reasoned about. AppKit repositions a CHILD window to preserve its offset from its parent, and it does so AFTER the didMove notification the controller reacts to. So placement computed the correct visible-frame-clamped rect, applied it, and was then silently dragged back a runloop turn later. With forensics on: host=(221,-200,1291,889) computed=(1272,60,240,104) afterSet=(1272,60,240,104) next-turn panel=(1272,-200,240,104) clobbered=true On a host whose bottom hangs below the display -- a tall scrollable window, which is what 'on scrollable screens' meant -- that parks the pill under the Dock or off the display entirely. Verified end to end: moving the host down 260pt now leaves the pill at y=818 (on screen) instead of 1078 (off it). Also fixes the scroll hand-off. Forwarding the NSEvent object into the host's view tree engaged NSScrollView's responsive-scroll event tracking against event.window -- the PANEL -- and that cross-window tracking wedged the panel's event delivery and display: the overlay stopped rendering AND stopped hit-testing while its window sat there, which is why a plain mouse wheel never triggered it but a trackpad always did. The panel now drives the enclosing scroller's clip directly by the deltas; the event never crosses. Both mutation-verified. Panel forensics kept behind ANNOTKIT_PANEL_FORENSICS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…or vanish The pill was drawn inside the catcher's ZStack, which made its position a function of the catcher panel's frame. That frame changes size on every open/close, is narrowed to the visible screen, and is dragged around by AppKit whenever the host moves -- three ways for a control that must never move to move -- and anything that stalled the catcher (a wedged scroll, a rebuilt SwiftUI root) took the pill down with it. The toolbar now has its own permanently mounted panel: fixed size, pinned to the host's bottom-right, re-placed only when the host moves, resizes or changes screen. Opening the menu creates a SEPARATE catcher panel over the host and closing it tears that panel down. The menu is now genuinely just open or closed, and the toolbar itself is untouched by the transition. Ordering matters and is not free: re-adding an existing child window does NOT re-stack it (measured -- childWindows still ended with the catcher, and the catcher sat on top, eating the click that closes the menu). The toolbar is detached and re-added, then ordered explicitly above the catcher. Probe updated for the two-panel world: childWindows.first is no longer "the overlay", and BOTH panels carry the AX identifier -- correctly, since the point query must skip both -- so 7g now picks the panel that actually spans the host rather than the first match. 191 tests, probe 11/11, iOS cross-compile clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements epic
VRT-cne0. Two user-requested changes to the annotation overlay.1. Exit annotate mode with an X
The strikethrough-pencil toggle is replaced by a Lucide X on the far right of the pill — the conventional "close this mode" position.
Idle is unchanged (a lone pencil). Annotating is now
point · frame │ copy · export · clear · ✕.The X is deliberately not gated on
hasNotes, unlike copy/export/clear: with zero notes every other control is inert, so gating the exit would leave a row of dead buttons with no live way out.pencilOffand the then-unusedPillButton.isActivewere deleted.2. Draw a frame to select an element
Press-drag a rectangle around what you mean; the note binds to the element that frame best identifies. This exists because hitting the one pixel that resolves to a composite component (a card, a row, a cluster) is a game of chance.
Frame selection is an explicit MODE, not an inferred gesture. The pill carries a two-button tool selector with the active tool lit:
.rectSelectionso the inert click is self-explanatory before you make it.An earlier revision branched implicitly on drag distance. That was replaced because a jittery click could cross the threshold and silently plant a framed note bound to whatever container the pointer happened to sit in. The threshold survives with a narrower job: telling a real drag from a wobble inside frame mode.
How a frame resolves
A pure, platform-free rule (
MarqueeTargetRule), two passes over the drawn rect:Largest-wins is the deliberate inverse of the point rule's deepest-wins: a click means "this exact spot", a box drawn around a card means "I mean this whole thing", so it must ascend past the labels the box also swallowed. 85% rather than strict containment because hand-drawn rects clip corners, and demoting those would bind the note to the enclosing panel — the exact failure the feature removes.
Ties break on seeded-beats-unseeded, which exists for one real pattern:
.axCardSurface(id)hangs a card's identifier on aColor.clearbackground leaf exactly coextensive with the card's content group. Both get surrounded equally; only the seeded one locates code.The drawn rect rides along on the note as
regionRect(element-relative, additive/optional — old files decode tonil), relativized at capture rather than selection so pressing Widen after framing re-measures it against whatever element the note finally names.Fixes a shipped bug
The widening ladder was topped by AnnotKit's own overlay window on every selection (
#Spec.Card → #Spec.Section → #com.annotkit.overlay-window). The geometric container scan climbs toAXApplication, whose children are the app's windows, including our overlay panel — which carries an identifier and encloses every point in the host. Widening enough times bound the user's note to the annotation tool itself, and the note looked plausible rather than failing loudly.Pre-existing on the click path; latent because nothing asserted ladder contents against a live tree. Fixed and pinned by a permanent probe assertion. (
VRT-cne0.9)Verification
AnnotKitOverlayProbe8/8 phases against a live accessibility tree, including a new Phase 7 proving a frame around a seeded card binds to the card, not its text childNot yet verified — needs a human (
VRT-cne0.10)Follow-ups filed:
VRT-ahfa(iOS coordinate double-conversion, latent until a window is offset),VRT-sted(iOS tap-widen vs frame-widen ladder asymmetry).🤖 Generated with Claude Code