Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat(frame-mode): no hover highlight, and the drawn frame is the anchor
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>
  • Loading branch information
angusbezzina and claude committed Aug 9, 2026
commit 862a1a01539b1b508ef6674469bdf4ec6fae03db
96 changes: 81 additions & 15 deletions Sources/AnnotKit/Overlay/AnnotationSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,16 @@ public final class AnnotationSession: ObservableObject {
@Published public private(set) var pending: [AnnotationNote] = []
@Published public private(set) var hovered: Element?
@Published public private(set) var selected: Element? {
// The region offset, the drawn marquee frame, and the navigation path only
// make sense while their selection is alive; clearing the selection
// (capture, cancel, stop, pin editing) must never leave a stale offset,
// frame, or path for the NEXT note.
// The region offset, the drawn marquee frame, the frame-anchoring flag and
// the navigation path only make sense while their selection is alive;
// clearing the selection (capture, cancel, stop, pin editing) must never
// leave a stale offset, frame, anchor or path for the NEXT note.
didSet {
if selected == nil {
selectedRegionOffset = nil
selectedMarqueeRect = nil
marqueeRegionOrigin = nil
anchorsToDrawnFrame = false
selectionPath = []
pathIndex = 0
cachedChildren = []
Expand All @@ -77,6 +78,34 @@ public final class AnnotationSession: ObservableObject {
/// selections, which measure from the selected element's origin.
private var marqueeRegionOrigin: CGPoint?

/// Whether the overlay should anchor to the frame the user DREW rather than to
/// the element that frame resolved to. Set ONLY where a drag resolves to a real
/// element — the one branch where the drawn rectangle would otherwise VANISH on
/// release, the highlight snapping to a box the user never swept. The region
/// fallback deliberately has no flag: its synthetic element's `frame` IS the
/// drawn rect, so anchoring to `selected.frame` there already anchors to the
/// frame, and a second mechanism would be two sources of truth for one rect.
///
/// Like ``selectedMarqueeRect``, deliberately NOT `@Published`: it is only ever
/// mutated alongside an assignment to `selected`, which IS published, so every
/// change is delivered to the UI by that emission. A future mutation that does
/// NOT coincide with a `selected` assignment would change the anchor without
/// redrawing — the composer would stay glued to the wrong rectangle.
private var anchorsToDrawnFrame = false

/// The rect the overlay should anchor to: the frame the user DREW, while it is
/// still the truth of the selection; nil once the binding is an element the user
/// chose (a click, or a navigated frame selection), in which case the UI anchors
/// to `selected.frame` as it always has.
///
/// ABSOLUTE AX screen coordinates — the same space as ``Element/frame`` — so
/// every consumer applies the identical `- axOrigin` transform it already
/// applies to the selected element. Deliberately not a new coordinate space:
/// the in-progress band is window-local and the two would be trivially
/// confusable, which on a secondary display means a frame drawn in the right
/// place and rendered off by the window's screen origin.
public var selectionAnchorFrame: CGRect? { anchorsToDrawnFrame ? selectedMarqueeRect : nil }

/// The BIDIRECTIONAL navigation path for the current selection, and the index
/// of the bound rung. Ordering is a CONVENTION the whole file depends on:
/// index 0 is the deepest rung known so far, ascending indices are
Expand Down Expand Up @@ -151,13 +180,23 @@ public final class AnnotationSession: ObservableObject {

public func start() { mode = .annotating }

/// Switch which gesture the catcher interprets. Deliberately touches NOTHING
/// else: an open composer, the current selection and the retained notes all
/// survive, because changing how you will pick the NEXT target says nothing
/// about the note you are in the middle of writing. Discarding a half-typed
/// comment because the user reached for the other tool would be the kind of
/// data loss nobody reports — they just stop using the picker.
public func setTool(_ tool: SelectionTool) { self.tool = tool }
/// Switch which gesture the catcher interprets. Deliberately touches nothing
/// beyond the hover highlight: an open composer, the current selection and the
/// retained notes all survive, because changing how you will pick the NEXT
/// target says nothing about the note you are in the middle of writing.
/// Discarding a half-typed comment because the user reached for the other tool
/// would be the kind of data loss nobody reports — they just stop using the
/// picker.
///
/// `hovered` is the ONE exception, and it is not housekeeping: hover is
/// point-mode-only, so a highlight resolved a moment before the switch would
/// otherwise SURVIVE into frame mode and sit there — a lit-up element with its
/// name tag, advertising a click-selection frame mode will never make — until
/// the pointer happened to leave the catcher. That is the reported symptom.
public func setTool(_ tool: SelectionTool) {
self.tool = tool
hovered = nil
}

public func stop() {
mode = .idle
Expand All @@ -171,8 +210,17 @@ public final class AnnotationSession: ObservableObject {

/// Update the hover highlight for a screen point (AX top-left coordinates).
/// Throttled to ~60fps so rapid hover events do not flood the point query.
///
/// POINT MODE ONLY. Frame mode makes its selection from a swept rectangle, so a
/// hover highlight there promises a point-selection that no press in that mode
/// can produce — the dogfooding report was a whole dashboard card lit up with
/// its name tag while the user was in frame mode and had drawn nothing. Gated
/// HERE rather than in the view (which has its own guard for the narrower
/// during-the-drag case) so no UI path can reintroduce it, and so it is
/// testable without a window. It is also a real cost, not just a visual one:
/// this skips one cross-process AX hit-test per pointer-motion event.
public func hover(atAXPoint point: CGPoint) {
guard mode == .annotating else { return }
guard mode == .annotating, tool == .point else { return }
let now = Date()
guard now.timeIntervalSince(lastHover) >= hoverInterval else { return }
lastHover = now
Expand All @@ -198,15 +246,18 @@ public final class AnnotationSession: ObservableObject {
// Any catcher tap dismisses an open pin editor: a tap on empty space is a
// click-away close, and a tap on an element hands the stage to the composer.
editingNoteID = nil
// Every selection starts offset-free, frame-free and path-free: a
// region -> element or marquee -> click re-selection (the catcher stays
// Every selection starts offset-free, frame-free, anchor-free and path-free:
// a region -> element or marquee -> click re-selection (the catcher stays
// active behind an open composer) must not leak the previous region's
// offset, the previous drag's drawn frame, or a stale navigation path onto
// the next note — the didSet only clears them when `selected` becomes nil,
// not on replacement.
// not on replacement. `anchorsToDrawnFrame` rides with the frame for the
// same reason: a click that landed after a drag would otherwise place its
// composer and pin against the rectangle drawn for the PREVIOUS note.
selectedRegionOffset = nil
selectedMarqueeRect = nil
marqueeRegionOrigin = nil
anchorsToDrawnFrame = false
resetNavigation(hint: point)
selected = source.hitTest(point)
// Seed the navigation path for a real element selection. The ladder's first
Expand Down Expand Up @@ -277,6 +328,7 @@ public final class AnnotationSession: ObservableObject {
selectedRegionOffset = nil
selectedMarqueeRect = nil
marqueeRegionOrigin = nil
anchorsToDrawnFrame = false
// The hint is set below, once the rect is normalized — a backwards drag's
// raw `midX`/`midY` are still correct, but deriving it from the standardized
// rect keeps one definition of "the centre of what was drawn".
Expand Down Expand Up @@ -311,6 +363,11 @@ public final class AnnotationSession: ObservableObject {
selectionHint = CGPoint(x: normalized.midX, y: normalized.midY)
refreshChildCache()
selectedMarqueeRect = normalized
// The frame is the truth of THIS selection until the user says
// otherwise: they drew a box, so the box is what the overlay
// anchors to. Set only here — the region branch below needs no
// flag, because its synthetic element's frame already IS this rect.
anchorsToDrawnFrame = true
return selected
}
}
Expand Down Expand Up @@ -407,6 +464,15 @@ public final class AnnotationSession: ObservableObject {
// rect from the wrong box, and that is exactly the class of bug `7993a67`
// was.
marqueeRegionOrigin = nil
// Pressing Parent or Child is the user EXPLICITLY asking which element the
// note is filed against, so the answer stops being implied and becomes the
// anchor: dropping this flag re-points the highlight, composer and pin at
// the chosen element (the drawn frame stays on screen, dimmed, because it
// is still what the note records). Clearing here rather than in the two
// callers is deliberate — every navigation in both directions funnels
// through this method, so the two can never drift into clearing different
// state, which is exactly how a stale-anchor bug gets in.
anchorsToDrawnFrame = false
// `selectedMarqueeRect` is deliberately KEPT: it is absolute, so it is
// still the frame the user drew whichever rung is now bound, and `addNote`
// re-relativizes it against that rung.
Expand Down
Loading