Skip to content

Commit 3b85756

Browse files
angusbezzinaclaude
andcommitted
feat(resolver): positional specificity + region-fallback annotations
The resolver now implements pure positional specificity: the DEEPEST meaningful node under the cursor wins (button at the button, card surface at the card padding, section at the section padding) - there is deliberately no depth-walking UI; cursor position IS the level selector. - deepestMeaningful replaces nearestIdentified + deepestNonContainer. The meaningful predicate counts AXValue: plain SwiftUI Text materializes AXStaticText with its string in AXValue and EMPTY title/description, so the old title-only notion of labeled walked past every text leaf and resolved its identified ancestor (the whole page) - the confirmed root cause of hovering card content highlighting the page. - Equal-area descent ties break toward CONTENT over seeded surface leaves (surfaces present as AXUnknown and are the least specific by construction). - REGION fallback: a click where NO node exists (decoration, dividers, gaps) is no longer dropped - AnnotationSession captures a synthetic region selection anchored to the nearest meaningful element (new RegionAnchorSource capability + AXIntrospection.regionAnchor), and AnnotationNote persists the new optional regionOffset (old files decode unchanged; element notes serialize unchanged). Markdown/JSON formatters render the region locator. Probe: +Phase 6 (specificity transitions incl. the previously-uncovered value-only text case + region fallback end-to-end). Tests: +region capture, +no-region-on-element-notes, +formatter region line, +old-shape decode round-trip (51 total). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bdeb24b commit 3b85756

9 files changed

Lines changed: 392 additions & 36 deletions

File tree

Sources/AnnotKit/AnnotationSink.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ public struct AnnotationNote: Sendable, Hashable, Identifiable, Codable {
4747
/// ``CodingKeys`` so the on-disk JSON store and the MCP payload stay
4848
/// byte-for-byte unchanged, and older files still decode (`anchor` -> nil).
4949
public var anchor: CGPoint?
50+
/// REGION note (decoration/gaps with no AX node): offset of the annotated
51+
/// POINT from the top-left of the anchor element named by `selector`.
52+
/// PERSISTED, unlike `anchor` — it is the locator agents need ("22pt below
53+
/// the top-left of #Dashboard.Today"). Optional, so element notes serialize
54+
/// unchanged and old files decode (nil).
55+
public var regionOffset: CGPoint?
5056

5157
/// Explicit keys that OMIT `anchor`: `JSONFileSink` and the MCP
5258
/// `FileNotesStore` encode/decode `[AnnotationNote]` directly, so a naked
@@ -55,6 +61,7 @@ public struct AnnotationNote: Sendable, Hashable, Identifiable, Codable {
5561
/// unchanged.
5662
private enum CodingKeys: String, CodingKey {
5763
case id, route, selector, elementPath, selectedText, comment, screenshot, timestamp
64+
case regionOffset
5865
}
5966

6067
public init(
@@ -66,7 +73,8 @@ public struct AnnotationNote: Sendable, Hashable, Identifiable, Codable {
6673
comment: String,
6774
screenshot: CapturedImage? = nil,
6875
timestamp: String,
69-
anchor: CGPoint? = nil
76+
anchor: CGPoint? = nil,
77+
regionOffset: CGPoint? = nil
7078
) {
7179
self.id = id
7280
self.route = route
@@ -77,5 +85,6 @@ public struct AnnotationNote: Sendable, Hashable, Identifiable, Codable {
7785
self.screenshot = screenshot
7886
self.timestamp = timestamp
7987
self.anchor = anchor
88+
self.regionOffset = regionOffset
8089
}
8190
}

Sources/AnnotKit/ElementSource.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,14 @@ public protocol ElementSource {
3030
/// Capture a PNG of an element, or the key window when `element` is nil.
3131
func screenshot(of element: Element?) async throws -> CapturedImage
3232
}
33+
34+
/// Optional element-source capability: resolve the nearest MEANINGFUL element
35+
/// to a point that hit-tests to NOTHING (decoration, dividers, gaps beyond any
36+
/// container's frame), so the session can capture a REGION note anchored to
37+
/// that element instead of dropping the click. Sources that cannot offer this
38+
/// simply don't conform; the session degrades to the old drop-the-click
39+
/// behavior.
40+
@MainActor
41+
public protocol RegionAnchorSource {
42+
func regionAnchor(at point: CGPoint) -> Element?
43+
}

Sources/AnnotKit/Overlay/AnnotationSession.swift

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,15 @@ public final class AnnotationSession: ObservableObject {
2222
/// mutating it, so the same set survives repeated copy/export.
2323
@Published public private(set) var pending: [AnnotationNote] = []
2424
@Published public private(set) var hovered: Element?
25-
@Published public private(set) var selected: Element?
25+
@Published public private(set) var selected: Element? {
26+
// The region offset only makes sense while its synthetic selection is
27+
// alive; clearing the selection (capture, cancel, stop, pin editing)
28+
// must never leave a stale offset for the NEXT note.
29+
didSet { if selected == nil { selectedRegionOffset = nil } }
30+
}
31+
/// Offset of a REGION selection's point from its anchor element's top-left
32+
/// (see ``select(atAXPoint:)``); nil for ordinary element selections.
33+
public private(set) var selectedRegionOffset: CGPoint?
2634
/// The id of the retained note whose in-overlay edit card is open, or nil when
2735
/// no editor is showing. UI-only: drives which pin's edit card the overlay
2836
/// renders. Mutually exclusive with ``selected`` (the composer) — opening one
@@ -84,13 +92,40 @@ public final class AnnotationSession: ObservableObject {
8492
}
8593

8694
/// Select the element under a screen point (AX top-left coordinates).
95+
///
96+
/// When nothing resolves (decoration, dividers, gaps beyond any container's
97+
/// frame) the click is NOT dropped: if the source can name a nearby anchor
98+
/// (``RegionAnchorSource``), a synthetic REGION selection is made — a small
99+
/// marker at the point, annotated relative to the nearest meaningful
100+
/// element ("22pt below the top-left of #Dashboard.Today").
87101
@discardableResult
88102
public func select(atAXPoint point: CGPoint) -> Element? {
89103
guard mode == .annotating else { return nil }
90104
// Any catcher tap dismisses an open pin editor: a tap on empty space is a
91105
// click-away close, and a tap on an element hands the stage to the composer.
92106
editingNoteID = nil
93107
selected = source.hitTest(point)
108+
if selected == nil,
109+
let anchorSource = source as? RegionAnchorSource,
110+
let anchorElement = anchorSource.regionAnchor(at: point) {
111+
let offset = CGPoint(
112+
x: (point.x - anchorElement.frame.minX).rounded(),
113+
y: (point.y - anchorElement.frame.minY).rounded()
114+
)
115+
let anchorName = anchorElement.label.isEmpty ? anchorElement.id : anchorElement.label
116+
selected = Element(
117+
id: anchorElement.id,
118+
role: "AXRegion",
119+
type: "Region",
120+
label: "Region (\(Int(offset.x)), \(Int(offset.y))) in \(anchorName)",
121+
value: "",
122+
frame: CGRect(x: point.x - 8, y: point.y - 8, width: 16, height: 16),
123+
isVisible: true,
124+
isActionable: false,
125+
path: anchorElement.path
126+
)
127+
selectedRegionOffset = offset
128+
}
94129
return selected
95130
}
96131

@@ -118,7 +153,8 @@ public final class AnnotationSession: ObservableObject {
118153
comment: comment,
119154
screenshot: screenshot,
120155
timestamp: timestamp(),
121-
anchor: anchor
156+
anchor: anchor,
157+
regionOffset: selectedRegionOffset
122158
)
123159
pending.append(note)
124160
selected = nil

Sources/AnnotKit/Sinks/AnnotationFormatter.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ public enum AnnotationFormatter {
2929
lines.append("## [\(note.id)] \(route) - \(note.selector)")
3030
lines.append("**Timestamp**: \(note.timestamp)")
3131
lines.append("**Element Path**: \(note.elementPath)")
32+
if let region = note.regionOffset {
33+
lines.append("**Region**: (x: \(Int(region.x)), y: \(Int(region.y))) from the top-left of \(note.selector)")
34+
}
3235
if let selected = note.selectedText, !selected.isEmpty {
3336
lines.append("**Selected Text**: \"\(selected)\"")
3437
}
@@ -54,6 +57,8 @@ public enum AnnotationFormatter {
5457
let selectedText: String?
5558
let comment: String
5659
let timestamp: String
60+
let regionOffsetX: Int?
61+
let regionOffsetY: Int?
5762
let screenshotPixelWidth: Int?
5863
let screenshotPixelHeight: Int?
5964

@@ -65,6 +70,8 @@ public enum AnnotationFormatter {
6570
selectedText = note.selectedText
6671
comment = note.comment
6772
timestamp = note.timestamp
73+
regionOffsetX = note.regionOffset.map { Int($0.x) }
74+
regionOffsetY = note.regionOffset.map { Int($0.y) }
6875
screenshotPixelWidth = note.screenshot?.pixelWidth
6976
screenshotPixelHeight = note.screenshot?.pixelHeight
7077
}

Sources/AnnotKit/macOS/AXIntrospection.swift

Lines changed: 104 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -262,14 +262,72 @@ enum AXIntrospection {
262262
.contains(where: { isChrome($0) && frameScreen(of: $0).contains(point) }) {
263263
return nil
264264
}
265-
// Never fall back to the window or application container: escalating to
266-
// AXWindow is what made a background click resolve to the whole app.
267-
guard let target = nearestIdentified(in: chain) ?? deepestNonContainer(in: chain) else {
265+
// Pure positional specificity: the DEEPEST meaningful node at the point
266+
// is the answer. Never the window or application container — escalating
267+
// to AXWindow is what made a background click resolve to the whole app.
268+
guard let target = deepestMeaningful(in: chain) else {
268269
return nil
269270
}
270271
return element(for: target, ancestorChain: chain)
271272
}
272273

274+
/// REGION anchor (cli-vtrvt.2): the nearest MEANINGFUL element to a point
275+
/// that hit-tests to nothing (decoration, dividers, gaps beyond any
276+
/// container's frame). Walks the containing window's tree collecting
277+
/// meaningful nodes (same predicate as the hit-test, chrome and
278+
/// window-ghost groups excluded) and returns the one whose frame is
279+
/// closest to the point — smaller frame wins a distance tie, mirroring the
280+
/// specificity rule.
281+
static func regionAnchor(for point: CGPoint) -> Element? {
282+
let app = appElement()
283+
let windows = elementArray(app, kAXWindowsAttribute).filter { !isOverlayWindow($0) }
284+
guard let window = windows.first(where: { frameScreen(of: $0).contains(point) }) else {
285+
return nil
286+
}
287+
let windowFrame = frameScreen(of: window)
288+
var best: (element: AXUIElement, distance: CGFloat, area: CGFloat)?
289+
collectMeaningful(in: window, windowFrame: windowFrame, depth: 0) { candidate in
290+
let frame = frameScreen(of: candidate)
291+
let d = distance(from: point, to: frame)
292+
let a = frame.width * frame.height
293+
if best == nil || d < best!.distance || (d == best!.distance && a < best!.area) {
294+
best = (candidate, d, a)
295+
}
296+
}
297+
guard let best else { return nil }
298+
return element(for: best.element, ancestorChain: ancestorChain(from: best.element))
299+
}
300+
301+
private static func collectMeaningful(
302+
in element: AXUIElement,
303+
windowFrame: CGRect,
304+
depth: Int,
305+
_ visit: (AXUIElement) -> Void
306+
) {
307+
guard depth < maxDepth else { return }
308+
for child in elementArray(element, kAXChildrenAttribute) {
309+
if isChrome(child) { continue }
310+
let role = string(child, kAXRoleAttribute) ?? ""
311+
let ghost = role == "AXGroup"
312+
&& (string(child, kAXIdentifierAttribute) ?? "").isEmpty
313+
&& labelText(child).isEmpty
314+
&& frameScreen(of: child).contains(windowFrame.insetBy(dx: 8, dy: 8))
315+
if !ghost, isMeaningful(child, role: role) {
316+
let frame = frameScreen(of: child)
317+
if frame.width > 0, frame.height > 0 { visit(child) }
318+
}
319+
collectMeaningful(in: child, windowFrame: windowFrame, depth: depth + 1, visit)
320+
}
321+
}
322+
323+
/// Euclidean distance from `point` to the nearest edge of `rect` (0 when
324+
/// the rect contains the point).
325+
private static func distance(from point: CGPoint, to rect: CGRect) -> CGFloat {
326+
let dx = max(rect.minX - point.x, 0, point.x - rect.maxX)
327+
let dy = max(rect.minY - point.y, 0, point.y - rect.maxY)
328+
return (dx * dx + dy * dy).squareRoot()
329+
}
330+
273331
/// Geometric hit-test beneath the overlay. The native point query cannot see
274332
/// past our own full-window overlay panel, so descend the frontmost
275333
/// non-overlay window whose frame contains `point` to the deepest descendant
@@ -298,7 +356,15 @@ enum AXIntrospection {
298356
let frame = frameScreen(of: $0)
299357
return frame.width > 0 && frame.height > 0 && frame.contains(point)
300358
}
301-
.min { area(of: $0) < area(of: $1) }
359+
.min { lhs, rhs in
360+
let (lhsArea, rhsArea) = (area(of: lhs), area(of: rhs))
361+
guard lhsArea == rhsArea else { return lhsArea < rhsArea }
362+
// Equal-area tie (e.g. a materialized region coextensive with a
363+
// card's seeded surface): CONTENT beats the surface — seeded
364+
// surfaces present as AXUnknown leaves and are by construction
365+
// the LEAST specific thing at their level.
366+
return surfaceRank(of: lhs) < surfaceRank(of: rhs)
367+
}
302368
guard let candidate else { return element }
303369
return deepestChild(of: candidate, containing: point, depth: depth + 1)
304370
}
@@ -308,6 +374,10 @@ enum AXIntrospection {
308374
return frame.width * frame.height
309375
}
310376

377+
private static func surfaceRank(of element: AXUIElement) -> Int {
378+
string(element, kAXRoleAttribute) == "AXUnknown" ? 1 : 0
379+
}
380+
311381
/// Climb `kAXParentAttribute` from `element` up to the window, returning the
312382
/// chain ordered root-first.
313383
private static func ancestorChain(from element: AXUIElement) -> [AXUIElement] {
@@ -352,33 +422,21 @@ enum AXIntrospection {
352422
isWindowChrome(subrole: string(element, kAXSubroleAttribute) ?? "")
353423
}
354424

355-
private static func nearestIdentified(in rootFirstChain: [AXUIElement]) -> AXUIElement? {
356-
for element in rootFirstChain.reversed() {
357-
let role = string(element, kAXRoleAttribute) ?? ""
358-
if role == "AXWindow" || role == "AXApplication" { continue }
359-
let identifier = string(element, kAXIdentifierAttribute) ?? ""
360-
let label = labelText(element)
361-
let actionable = actionNames(element).contains(kAXPressAction as String)
362-
|| actionableRoles.contains(role)
363-
if actionable || !identifier.isEmpty || (!label.isEmpty && role != "AXGroup") {
364-
return element
365-
}
366-
}
367-
return nil
368-
}
369-
370-
/// Deepest element in the chain that is still a plausible target — anything
371-
/// that is not the window or application container. Used only when no
372-
/// identified/actionable ancestor exists, so a click on a plain leaf resolves
373-
/// to that leaf rather than escalating to the whole window (which would then
374-
/// show the window title in the composer header).
375-
private static func deepestNonContainer(in rootFirstChain: [AXUIElement]) -> AXUIElement? {
376-
// A structural, unidentified group that spans (nearly) the whole window is
377-
// the window in disguise — NSHostingView's root AXGroup covers the full
378-
// window frame, so falling back to it is the same "background click
379-
// highlights the whole app" bug the window/application skip guards
380-
// against. Detect it geometrically: the group's frame swallows the
381-
// window's frame minus a small inset.
425+
/// The MOST SPECIFIC annotation target at the hit: walk the chain
426+
/// deepest-first and return the first MEANINGFUL node (pure positional
427+
/// specificity — the button on a card wins at the button, the card's
428+
/// surface at the card's padding, the section at the section's padding;
429+
/// there is deliberately NO depth-walking UI).
430+
///
431+
/// A meaningless wrapper between content and its meaningful ancestor is
432+
/// passed through: by containment, that ancestor is still the most
433+
/// specific meaningful node at the point. Skipped entirely: the window and
434+
/// application containers (a background click must never resolve to the
435+
/// whole app) and structural, unidentified, content-less groups spanning
436+
/// (nearly) the whole window — NSHostingView's root AXGroup is the window
437+
/// in disguise, detected geometrically because its frame swallows the
438+
/// window's frame minus a small inset.
439+
private static func deepestMeaningful(in rootFirstChain: [AXUIElement]) -> AXUIElement? {
382440
let windowFrame = rootFirstChain.first { string($0, kAXRoleAttribute) == "AXWindow" }
383441
.map(frameScreen(of:))
384442
for element in rootFirstChain.reversed() {
@@ -391,11 +449,25 @@ enum AXIntrospection {
391449
frameScreen(of: element).contains(windowFrame.insetBy(dx: 8, dy: 8)) {
392450
continue
393451
}
394-
return element
452+
if isMeaningful(element, role: role) { return element }
395453
}
396454
return nil
397455
}
398456

457+
/// Whether a node is an annotation target in its own right: an identifier,
458+
/// a title/description label, a string VALUE, or an action. Counting
459+
/// AXValue is load-bearing: plain SwiftUI `Text` materializes AXStaticText
460+
/// with the string in AXValue and EMPTY title/description, so a
461+
/// title-only notion of "labeled" walks straight past every text leaf and
462+
/// resolves its identified ancestor (the whole page) instead.
463+
private static func isMeaningful(_ element: AXUIElement, role: String) -> Bool {
464+
if !(string(element, kAXIdentifierAttribute) ?? "").isEmpty { return true }
465+
if !labelText(element).isEmpty { return true }
466+
if !(string(element, kAXValueAttribute) ?? "").isEmpty { return true }
467+
return actionNames(element).contains(kAXPressAction as String)
468+
|| actionableRoles.contains(role)
469+
}
470+
399471
/// Build a public ``Element`` for `target`, computing its path from the
400472
/// supplied root-first ancestor chain (with same-role sibling indices).
401473
private static func element(for target: AXUIElement, ancestorChain rootFirst: [AXUIElement]) -> Element {

Sources/AnnotKit/macOS/MacElementSource.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,10 @@ public final class MacElementSource: ElementSource {
2727
try AXScreenshot.capture(of: element)
2828
}
2929
}
30+
31+
extension MacElementSource: RegionAnchorSource {
32+
public func regionAnchor(at point: CGPoint) -> Element? {
33+
AXIntrospection.regionAnchor(for: point)
34+
}
35+
}
3036
#endif

0 commit comments

Comments
 (0)