Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
fe0dca4
feat(overlay): exit annotate mode via a far-right X control
angusbezzina Aug 8, 2026
42de397
feat(overlay): exit annotate mode via a far-right X control (VRT-cne0.4)
angusbezzina Aug 8, 2026
00fa0de
feat(marquee): pure frame-selection resolution rule
angusbezzina Aug 8, 2026
413d56d
feat(marquee): pure frame-selection resolution rule (VRT-cne0.5)
angusbezzina Aug 8, 2026
c40a4d2
chore: ignore agent worktree directories
angusbezzina Aug 8, 2026
5e99ca6
feat(marquee): session entry point + persisted frame on notes
angusbezzina Aug 8, 2026
c221644
feat(marquee): session entry point + persisted frame on notes (VRT-cn…
angusbezzina Aug 8, 2026
997577a
fix(marquee): clear the region measuring origin when widening
angusbezzina Aug 8, 2026
7ffbba3
docs(marquee): state the click-routing caller contract on select(inAX…
angusbezzina Aug 8, 2026
53dca4f
feat(marquee): macOS adapters resolve a drawn frame to an element
angusbezzina Aug 8, 2026
c273dde
feat(marquee): macOS adapters resolve a drawn frame to an element (VR…
angusbezzina Aug 8, 2026
33d9822
test(marquee): probe sub-phase 7g pins the overlay out of every ladder
angusbezzina Aug 8, 2026
d94920d
test(marquee): probe sub-phase 7g pins the overlay out of every ladder
angusbezzina Aug 8, 2026
2ea10f5
feat(overlay): marquee drag gesture with a tested click/frame threshold
angusbezzina Aug 8, 2026
09ac045
docs: README covers frame selection
angusbezzina Aug 8, 2026
2a0c40e
feat(marquee): draw a frame on the overlay to select (VRT-cne0.7)
angusbezzina Aug 8, 2026
5da04a4
feat(ios): marquee frame selection + exclude AnnotKit's overlay window
angusbezzina Aug 8, 2026
73d0102
feat(marquee): iOS port + parity (VRT-cne0.8)
angusbezzina Aug 8, 2026
66425a2
feat(overlay): explicit point/frame selection modes with a tool selector
angusbezzina Aug 8, 2026
ecc1706
feat(overlay): explicit point/frame selection modes (VRT-cne0.11)
angusbezzina Aug 8, 2026
9f963e4
feat(overlay): Select Parent / Select Child replace the one-way Widen…
angusbezzina Aug 9, 2026
45b31bb
feat(overlay): Select Parent / Select Child navigation (VRT-mijf.1)
angusbezzina Aug 9, 2026
862a1a0
feat(frame-mode): no hover highlight, and the drawn frame is the anchor
angusbezzina Aug 9, 2026
4554f49
feat(overlay): frame mode shows only the frame and anchors to it (VRT…
angusbezzina Aug 9, 2026
d7cc0eb
fix(marquee): reject a degenerate drag before touching any state
angusbezzina Aug 9, 2026
752e9b5
docs(navigation): record selection navigation + frame anchoring, cove…
angusbezzina Aug 9, 2026
26c3d4c
docs(marquee): DECISIONS/PARITY entries + probe Phase 8 selection nav…
angusbezzina Aug 9, 2026
fd9a00a
feat(icons): real elliptical arcs in the Lucide d-string parser
angusbezzina Aug 9, 2026
61151ea
feat(escape): back out one level at a time via a local key monitor
angusbezzina Aug 9, 2026
34d5964
feat(icons): real elliptical arcs in the Lucide path parser (VRT-dp47.3)
angusbezzina Aug 9, 2026
c6d1c39
feat(overlay): Escape backs out of AnnotKit one level at a time (VRT-…
angusbezzina Aug 9, 2026
516aa11
fix(overlay): re-arm the Escape monitor when re-mounting into an anno…
angusbezzina Aug 9, 2026
fc7c701
fix(overlay): clamp the pill to the visible screen and stop swallowin…
angusbezzina Aug 9, 2026
e158ca9
fix(overlay): keep the pill on screen and forward scroll to the host …
angusbezzina Aug 9, 2026
bb92fe3
feat(cards): one icon row per note card, shared with the pill
angusbezzina Aug 9, 2026
3cb5eee
feat(overlay): inline icon action row in both note cards (VRT-dp47.4)
angusbezzina Aug 9, 2026
8d3091b
fix(overlay): stop the hover-driven relayout storm that hid the pill
angusbezzina Aug 10, 2026
3267df7
fix(overlay): re-assert the clamped panel frame after AppKit's parent…
angusbezzina Aug 10, 2026
be624b4
feat(overlay): the toolbar gets its own window, so it can never move …
angusbezzina Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.build/
.swiftpm/
.claude/worktrees/
*.xcodeproj
*.xcworkspace
DerivedData/
Expand Down
143 changes: 143 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Resolves the open decisions from the plan (planning/annotkit in the cli repo). P
| Versioning | SemVer, 0.x pre-1.0 | Breaking changes allowed while 0.x; 1.0 marks a stable public API. |
| Default element source | Accessibility hierarchy | The only strategy that surfaces SwiftUI `accessibilityIdentifier` values. |
| Annotation target rule | Deepest actionable, else deepest meaningful; anchor the selector to the nearest identifier | One rule on both platforms. Supersedes the earlier macOS "deepest meaningful" and iOS "nearest identified" split. See below. |
| Marquee target rule | Largest meaningful element ≥85% surrounded; else the tightest element enclosing the drawn frame | Rect selection, the deliberate inverse of the point rule's deepest-wins. See below. |
| Selection navigation | Bidirectional Parent/Child over one path; descent replays history and only queries the source at the deepest rung | Replaces the one-way "Widen". Prepending the frontier child shifts every rung, so a note's `component` is the first SEEDED rung above the BOUND one. Whether the parent chain stays seeded-only is OPEN. See below. |
| Frame mode anchoring | The frame the user DREW anchors the overlay until they navigate; the resolved element is NAMED in the composer, not drawn on the canvas | Hover is point-mode-only, gated in the session rather than the view. See below. |
| Opt-in element source | View tree (NSView/UIView) | Surfaces concrete view class names; richer for AppKit/UIKit hosts. Collapses to hosting views in pure SwiftUI. |
| `pathname` mapping | Host-supplied route, inferred fallback | A native app has no URL routes; the host sets a route, else infer from the key window title or identifier. |
| Overlay coverage | Primary screen (MVP) | The overlay covers the primary display; SwiftUI-local points map to AX screen coordinates there. Full multi-display placement is deferred (cli-a99qm.4.2). |
Expand Down Expand Up @@ -70,6 +73,146 @@ Selector *anchoring* (`#Card >> …`) still requires a true ancestor because the
selector may be positional or text-based while the `component` field still names
the card, so the note locates the right code either way.

## Marquee target rule (VRT-cne0.5)

The user press-drags a rectangle around what they mean. Given every element the
adapter can see (a flat array, not an ancestor chain — a marquee sweeps across
siblings and unrelated subtrees), the note binds by two passes over the
*standardized* rect, using the same eligibility as the point rule (never the
window, the application, chrome, or a window-spanning ghost group, and never a
zero-area frame):

1. **Surrounded** — every eligible element the frame covers to ≥85% of that
element's OWN area; the **largest** wins.
2. **Enclosing** — nothing was surrounded, so the frame was drawn inside
something: every eligible element whose frame contains the whole rect; the
**smallest** wins.
3. Neither — nil, and the session falls back to a region note anchored near the
frame, exactly as a point that hit-tests to nothing does.

Ties inside a pass: seeded beats unseeded, then shallower (surrounded) / deeper
(enclosing), then lowest index. Areas compare with exact `==`, no epsilon.

**Why largest-wins**, when the point rule is deepest-wins? Because the gestures
mean opposite things. A click means "this exact spot", so it descends. Drawing a
box around a card means "I mean this *whole* thing", so it must ascend past the
labels and buttons the box also swallowed. Same tree, opposite intent — hence a
separate rule rather than a mode flag on `AnnotationTargetRule`.

**Why 0.85 and not strict containment.** A hand-drawn rect clips edges. Users
drag roughly around a card and routinely shave a corner or slice through a
trailing chevron; at 1.0 that silently demotes to the enclosing fallback and
binds the note to the panel instead of the card — the exact failure marquee
exists to remove. 0.85 absorbs that sloppiness and still sits far above the
coverage a neighbouring card picks up when a drag merely overlaps its edge.

**Why seeded-beats-unseeded exists at all.** It is not a general preference for
identified elements (that is the mistake the point rule documents above). It is
narrowly for the coextensive case: `.axCardSurface(id)` hangs the card's
identifier on a clear `Color.clear` background leaf that is *exactly* the same
frame as the card's content group. Both are surrounded identically, and only the
seeded one carries the identifier that locates code. `AXIntrospection.deepestChild`
already resolves this same pattern by exact equal-area comparison, which is why
no epsilon is used here — the two frames come from one layout computation, so the
arithmetic is bit-identical, and an epsilon would instead start collapsing
genuinely different elements into a seeding decision.

**Why the enclosing fallback.** It is the rect generalization of the point-region
note (rule 5 above): a scribble over a card's padding surrounds nothing, and
dropping it would be the same lost-click bug `RegionAnchorSource` was added to
fix. Smallest-wins there because the tightest enclosure is the most specific — a
scribble inside a card must not resolve to the window-spanning panel that also
contains it.

Depth and index are determinism-only tie-breaks; they exist so the same drag
always resolves to the same element. The pure decision lives in
`MarqueeTargetRule` and is unit-tested independent of AX; adapters expose it via
the optional `MarqueeTargetSource` capability, which returns a component-widening
ladder identical in contract to `ComponentLadderSource`, so widening and the
note's `component` field work unchanged.

## Selection navigation (VRT-mijf.1)

The composer's one-way "Widen" button is replaced by Parent and Child over a
single path: index 0 is the deepest rung known so far, ascending indices are
progressively broader, and one index marks the rung the note is bound to.

The rename is not cosmetic. "Widen" named the MECHANISM — the highlighted area
gets bigger — when the act is choosing which component the note is filed against.
A button that makes things bigger implies no inverse, so a user who overshot, or
whom the target rule bound coarser than they meant, had nothing to press.

**Descent prefers HISTORY over re-querying.** Above the deepest rung, Child just
steps the index back down what the user climbed. Only AT the deepest rung does it
ask the source for children, and it then PREPENDS the one it takes, so index 0
still means "deepest known rung". Re-querying on every press would be less code
and wrong: the source's answer is a heuristic over a LIVE tree, so a hover state
resolving or a list reflowing between two presses makes the same key produce a
different result. Prepending is what makes the round trip hold in BOTH
directions — after descending to child C, Parent returns to the original target
and Child returns to C ITSELF rather than re-running the heuristic against a tree
that has moved on.

**The consequence that bit us.** Prepending shifts every existing rung up one, so
"the note's `component` is the rung above the target" stopped being true: index 1
is now the ORIGINAL target, which is frequently unseeded. `component` is
therefore the first SEEDED rung strictly above the BOUND rung, and it is read
from that rung's IDENTIFIER, never from its `Element.id`. An unseeded element's
`id` is a slash-joined path (`AXWindow[0]/AXGroup[0]/AXStaticText[1]`); exported
as a `component` it hands the consuming agent a grep target that matches nothing
while looking entirely plausible in the note — a silent miss, not a visible one.
The same path is rooted differently depending on which entry point produced the
element (`snapshot()` roots at the window, the hit-test and marquee paths at the
application), so the id is not even stable for one node, which is a second reason
it can never be a code locator.

**Open, pending dogfood: should the parent chain stay seeded-only?** It is today —
every rung above the target is an identified component, so every rung locates
code and no press can bind a note to something that names nothing. The cost is
that it skips structural levels the user can SEE: a row inside an unseeded stack
offers no rung for the stack, so Parent jumps from the row straight to the card
and the level the user was aiming at is unreachable. Admitting unseeded rungs
would fix the navigation and degrade the notes. Which failure is worse is not
decidable from the design; it needs real use, so this is recorded as unresolved
rather than settled.

## Frame mode anchoring (VRT-mijf.2)

When a drawn frame resolves to a real element, the overlay anchors its highlight,
composer and pin to the FRAME the user drew — not to the element — until the user
presses Parent or Child, at which point the bound element becomes the anchor and
the frame stays on screen, dimmed.

**Why the frame outranks the resolved element.** The user drew a box, so the box
is the truth of the selection until they say otherwise. Anchoring to the
resolution instead makes the rectangle vanish the instant the mouse comes up and
the highlight snap to a card that was never swept, which reads as the tool having
ignored the gesture.

**Why the element is NAMED rather than DRAWN.** A note must never be captured
against a target the user could not see, so the binding has to appear somewhere.
But a second rectangle on the canvas is exactly what "show me only the frame I
drew" rules out, and two boxes of different shapes leave it ambiguous which one
the note records. The composer header carries the name behind a `Frame →` prefix,
so it reads as what the frame RESOLVED to rather than as a label for the
rectangle, and the prefix disappears the moment navigation puts a named element
back on the canvas — the name is never qualified in two places at once.

**Why navigating reveals the element.** Pressing Parent or Child IS the question
"which element is this filed against?", so the answer has to become visible;
moving the binding while the highlight stays on the drawn rect would give no
feedback at all. The frame survives, weaker, because it is still what the note
records (`regionRect`) even once it no longer decides the binding.

**Why hover is gated in the SESSION, not the view.** Frame mode selects from a
swept rectangle, so a hover highlight there advertises a click-selection no press
in that mode can produce — the dogfooding report was a whole card lit up with its
name tag while nothing had been drawn. The view keeps its own guard for the
narrower during-the-drag case; the MODE gate belongs one level down because there
it is unit-testable without a window, no future UI path can reintroduce it, and
it removes a cross-process AX hit-test per pointer-motion event. It is a cost
decision as much as a visual one.

## IP hygiene (carried into the F7 legal gate)

- Do not copy original Agentation source (PolyForm Shield 1.0.0, non-compete). Only the `AGENTATION_NOTES.md` file format is reused, reimplemented clean-room.
Expand Down
6 changes: 6 additions & 0 deletions PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ row; each asymmetry is closed by code or has a tracked mitigation.
| 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 |
| 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 |
| 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 |
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# AnnotKit

Native in-app annotation for AI coding agents. Click a UI element in your own
macOS or iOS app, attach a note, and emit an agent-readable, code-locating
annotation. The native analogue of the web Agentation tool.
macOS or iOS app — or drag a frame around it — attach a note, and emit an
agent-readable, code-locating annotation. The native analogue of the web
Agentation tool.

A click becomes a stable selector, an element path, a screenshot, and your
The gesture becomes a stable selector, an element path, a screenshot, and your
comment, so an AI coding agent can locate the exact view instead of guessing
from a verbal description.

Expand Down Expand Up @@ -47,6 +48,12 @@ Annotation.install(sink: ClipboardSink(format: .json))
non-identified target to its nearest seeded `accessibilityIdentifier`
(`#Settings.Models >> @Save`), so it round-trips a resolver and points an agent
at the right component's code. See `DECISIONS.md`.
- **Drawing a frame** instead of clicking inverts that rule on purpose: a click
means "this exact spot" and descends, while a box drawn around a card means "I
mean this *whole* thing", so the **largest** element the frame surrounds wins
and the labels inside it do not. A frame drawn *inside* something binds to the
tightest element enclosing it, and the drawn rect rides along on the note.
Saves hunting for the one pixel that hit-tests to a composite component.
- Notes are written in the `AGENTATION_NOTES.md` format that the
`process-agentation-notes` skill consumes, or copied to the clipboard.

Expand Down
Loading
Loading