Skip to content

feat: frame-to-select, tree navigation, Escape, and inline icon actions - #1

Merged
angusbezzina merged 39 commits into
mainfrom
feat/marquee-frame-selection
Aug 10, 2026
Merged

feat: frame-to-select, tree navigation, Escape, and inline icon actions#1
angusbezzina merged 39 commits into
mainfrom
feat/marquee-frame-selection

Conversation

@angusbezzina

Copy link
Copy Markdown
Contributor

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. pencilOff and the then-unused PillButton.isActive were 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:

  • Point mode (default) — clicks select, exactly as before. A drag is a sloppy click and selects where you pressed. No press can produce a frame.
  • Frame mode — drags draw a band and resolve; a plain click does nothing, and the pointer becomes .rectSelection so 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:

  1. Surrounded — candidates the frame covers to ≥85% of their own area; the largest wins.
  2. Enclosing — nothing surrounded, so the frame was drawn inside something; the tightest container wins.
  3. Neither — falls back to the existing region-anchor path.

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 a Color.clear background 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 to nil), 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 to AXApplication, 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

  • Clean build, zero warnings
  • 120 tests (74 baseline + 46 new)
  • AnnotKitOverlayProbe 8/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 child
  • iOS module type-checks clean against the iPhoneOS SDK in Swift 6 mode

Not yet verified — needs a human (VRT-cne0.10)

  • Dogfood: 10 framed annotations on real VirgilHUD components, agent locating ≥9
  • Real mouse: release outside the window, right-/modifier-clicks, band repaint with many pins
  • iOS on device: the port is compiled, never run. The 10pt touch threshold is a reasoned default, not measured.

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

angusbezzina and others added 27 commits August 8, 2026 14:46
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>
@angusbezzina angusbezzina changed the title feat: frame-to-select mode + X close control feat: frame-to-select mode, tree navigation, and X close control Aug 9, 2026
@angusbezzina

Copy link
Copy Markdown
Contributor Author

Update: epic VRT-mijf folded in (selection navigation + frame-mode anchoring)

Two issues surfaced by dogfooding the original commits are now fixed on this branch, rather than stacked as a second PR that would conflict on the same lines.

Select Parent / Select Child replace "Widen"

Widen was one-way — overshoot the level you wanted and there was no way back, and no way to reach a child at all — and the name described the mechanism ("make the area bigger") rather than the act (choose which component the note is filed against).

Navigation is now bidirectional over a selectionPath. Descending prefers history (walk back down what you climbed) and only queries the source for children 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. Re-running the heuristic would make the same key produce different results on consecutive presses under a live UI. Both buttons are always present and disable at the range ends — the old button appeared and disappeared, which is much of why it was unlearnable.

This fixed a silent corruption prepending would otherwise have introduced. The note's component — the string an agent greps to find your view — was derived as "the rung above the target". That held only while the path was strictly upward, because every upward rung is seeded by construction. Insert a child at index 0 and index 1 becomes the original target, which may be unseeded — and an unseeded Element.id is a slash-joined path. The note would have exported AXWindow[0]/AXGroup[0] as the thing to grep for and looked entirely plausible doing it. It now skips to the first seeded rung above the bound one, pinned by a mutation-verified test.

Frame mode shows only the frame, and the note ties to it

Frame mode was still hover-highlighting whatever the pointer passed over: hover() gated only on annotate mode and knew nothing about the selection tool. The gate is now in the session, so it is unit-testable, no UI path can reintroduce it, and it removes a cross-process AX hit-test per pointer-motion event. setTool also clears a standing hover, fixing a stranded highlight when switching point → frame.

The drawn rectangle is now the visual anchor for the highlight, composer and pin (solid once committed, dashed while drawing, no element name tag). The resolved element is named in the composer header as Frame → <element> rather than drawn, so a note is never captured against a target the user could not see without putting a second rectangle on screen. Pressing Parent/Child reveals the bound element and dims the frame — that press is the question "which element?", so the answer has to become visible.

Also fixed

A reachable degenerate-drag bug: a perfectly horizontal drag clears the gesture threshold but yields a zero-height rect, and the rejection ran after the per-selection clears — so a stray horizontal drag stripped an open frame selection's anchor, re-anchoring a composer mid-typing, and did it without assigning selected, so the re-render never fired and the overlay kept stale geometry. Mutation-verified test.

Verification

Clean build (zero warnings) · 134 tests · AnnotKitOverlayProbe 9/9 phases against a live accessibility tree, including a new Phase 8 covering climb/descend round-trips, history-not-re-query, the component fix, frame-mode hover inertness, and frame anchoring · iOS module type-checks clean against the iPhoneOS SDK in Swift 6.

The probe fixtures needed extending: the existing ones are AX leaves or children: .ignore surfaces, so the descent assertions would have been vacuously green against them.

Still needs a human (VRT-mijf.4)

Dogfood on VirgilHUD; whether the seeded-only parent chain skips structural levels a user can see (recorded as unresolved in DECISIONS.md); whether the header alone is enough when a frame and its bound element disagree; and the visual treatments, which have no headless witness. Nothing here has run on iOS — that is a verification gap, documented as such in PARITY.md, not a capability gap.

Follow-up filed: VRT-bjtp — an unseeded Element.id differs between snapshot() and the hit-test paths (pre-existing; a second independent argument for the component fix above).

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>
angusbezzina and others added 8 commits August 8, 2026 22:19
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>
@angusbezzina angusbezzina changed the title feat: frame-to-select mode, tree navigation, and X close control feat: frame-to-select, tree navigation, Escape, and inline icon actions Aug 9, 2026
@angusbezzina

Copy link
Copy Markdown
Contributor Author

Update 2: epic VRT-dp47 folded in (Escape, pill visibility, inline icon actions)

Three more dogfooding issues, fixed on this branch. Two of them turned out to be worse than reported.

Escape backs out one level

Escape previously did almost nothing, and could not be made to work where it lived: .onExitCommand needs the overlay panel to be key, and the panel is only key while a card's text field has focus. In annotate mode with nothing open — the state you'd want to leave — the host window is key and no panel-scoped handler ever sees the keystroke.

It's now an NSEvent local monitor (correct because AnnotKit is in-process with its host), driven by a pure EscapeRule: cancel an in-flight frame drag → else close the open card → else exit annotate mode → else pass through untouched. A handled Escape is consumed, so the host never double-acts on one press. The composer draft now clears whenever the composer closes for any reason, not just via the Cancel button — otherwise dismissing with Escape left the draft to reappear over the next selection.

The pill was not "hidden" — it was unreachable

Reproduced in the probe before any fix: AppKit's own windowNumber(at: pillCentre) returned 0. Cause confirmed — constrainFrameRect pins a window's top below the menu bar but never lifts its bottom, so a host taller than the display hangs below visibleFrame, and both modes anchor the toolbar exactly there. The Dock alone (61pt) is enough.

Placement now clamps to the screen's visible frame, and axOrigin/surfaceSize are re-derived from the clamped panel, not the host. That second half matters: probe sub-phase 9d shows a host-derived origin would query the wrong point and resolve to nil on a menu-bar-tucked window — a fix that made the pill reachable while breaking every hit-test would have been worse than the bug.

Scroll-while-annotating was also dead. Measured: a wheel over the catcher reached the panel and died at NSWindow's default, so the host received nothing — you could not scroll to anything below the fold. The panel now forwards to the host.

Two other candidates were ruled out with evidence: scroll-driven relayout (scrolling moves a content offset, not the window frame) and the host auto-picker (wrong window would show the pill elsewhere, not nowhere).

Real elliptical arcs, then inline icon actions

The glyph parser drew every SVG arc as a straight line to its endpoint — fine for the pencil's corner rounds, fatal for undo-2, whose 5.5-radius loop is the icon. It now implements SVG 1.1 F.6.5 endpoint→centre conversion emitting ≤90° cubics, with rotation, both flags, and the spec's degenerate cases.

That work was cross-checked against an independent implementation of the spec before any expected value was pinned, and mutation-verified: 10 of 18 arc tests fail against the old parser. The undo-2 test has teeth — a bounding-box check alone would survive flattening (a chord shares its endpoints), so it asserts the chord midpoints sit >1.5 units off the drawn curve.

Both cards now show one inline row of icon-only actions (composer ↑ ↓ … ↺ ➤, editor 🗑 … ➤), each with a tooltip and an accessibility label — icon-only controls are unusable to VoiceOver with a tooltip alone. send keeps its prominence through accent colour rather than a filled label. PillButton was generalised to IconButton(palette:) so the pill and the cards share one implementation; the pill's palette restates its previous literals exactly, so it is unchanged.

Side effect worth knowing: three shipped glyphs now render their true curves for the first time. The pencil is the visible one — its eraser end was an authored round cap the old parser flattened, so it moves ~2.8 units and finally looks rounded.

Verification

Ran the exact three commands CI runs, plus the probe: swift build -Xswiftc -warnings-as-errors (zero warnings) · swift test182 tests · iOS simulator cross-compile · AnnotKitOverlayProbe 10/10 phases, including the new Phase 9 that reproduces the pill bug on a host hanging off the visible screen.

Still needs a human

Glyph legibility at 16pt (send is a stroked dart; undo-2's loop is mathematically confirmed but "reads as undo" is a visual call), whether accent-tinted send still reads as primary under a non-blue system accent (graphite is close to .secondary), real trackpad scrolling and momentum through the new forward, and multi-display / Dock-position clamping. Escape's central claim — that the keystroke reaches the monitor while the host is key — is the one thing no headless test can assert.

angusbezzina and others added 3 commits August 9, 2026 21:20
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>
@angusbezzina
angusbezzina merged commit 9fbf860 into main Aug 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant