Skip to content

Commit c16ef8b

Browse files
angusbezzinaclaude
andcommitted
fix(overlay): the toolbar panel is sized to the pill, not to a fixed corner (VRT-jiuo)
The panel carrying the pill was a fixed 240x104 rect pinned to the host's bottom-right, permanently mounted in BOTH modes, and it consumes presses across its whole frame — so that corner of the host app could not be clicked and no frame drag could be started in it. The idle pill used 8% of it. The mechanism was settled by measurement last week and is the reason the only lever is SIZE: macOS does not route mouse events through a window's transparent parts. A panel whose content view is a bare NSView drawing nothing at all, with isOpaque = false and a clear background, swallows the click just the same, and ignoresMouseEvents = true would take the pill's own clicks with it. SIZING AND PLACEMENT ARE SPLIT. SwiftUI knows how wide the pill is; the size is measured from it. What AppKit gets wrong is the ANCHOR — a window resize preserves the TOP-LEFT and this panel is anchored bottom-RIGHT, so letting AppKit apply the size slid the control left by the chrome margin. sizingOptions = [] takes that away and toolbarFrame derives the frame BACKWARDS from where the pill has to land, so the panel grows leftwards and shrinks back with the control fixed at host.maxX - 20 — verified from AppKit's own view frames (buttons at panel-local 20..207), not from the formula under test. Two traps, both hit and both worth the comments they now carry: measuring `fittingSize` on the INSTALLED hosting view returns the frame it was just stretched to, so it never shrinks (a fresh view is measured instead); and the pill animates its width over 0.15s, so the panel grows immediately and shrinks only once that has settled. What remains dead is the chrome band the drop shadow and count badge draw into. Probe 11a now pins all three claims with real clicks: a control click that must land, a click where the old fixed panel used to sit that must now reach the catcher, and a click in the chrome band that must still be swallowed. Phases 3a and 9a assert the PILL's corner rather than the panel frame, since the panel's own edges now move by design; the dead legacy `panelFrame(for:)` shape and its only (unused) caller are gone. The real-input legs also now report SKIPPED rather than FAILED when the app is not frontmost — a posted click that never arrives says nothing about the overlay either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 653b8f6 commit c16ef8b

8 files changed

Lines changed: 428 additions & 158 deletions

File tree

DECISIONS.md

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -346,26 +346,49 @@ When the framed content scrolls fully out of view the frame goes off-surface wit
346346
it, which is the honest outcome; `ComposerPlacement` already clamps the card, so it
347347
stays on screen and sendable.
348348

349-
### The toolbar panel claims its whole frame (measured, VRT-pm3k.7)
350-
351-
The permanently-mounted toolbar panel is 240x104 with the pill in its bottom-right
352-
corner, and it consumes presses across the **whole** rect — so the host's
353-
bottom-right 240x104 is inert to clicks and to the start of a frame drag, in both
354-
modes.
355-
356-
This was listed as candidate (d) on the assumption that per-pixel alpha
357-
pass-through would save it. It does not: measured against a panel whose content
358-
view was a bare `NSView` drawing nothing at all, with `isOpaque = false` and a
359-
clear background, the click was still swallowed. macOS does not route mouse events
360-
through the transparent parts of a window; `ignoresMouseEvents = true` is the only
361-
configuration that lets them through, and it would take the pill's own clicks with
362-
it. `AnnotKitOverlayProbe` phase 11a pins the behaviour with a real posted click,
363-
against a control click on the catcher so a null result means something.
364-
365-
Not fixed here, deliberately: the remedy is to size the panel to the pill, which
366-
changes the design `be624b4` landed (a fixed-size panel whose frame moves for
367-
exactly one reason), and this epic's job was to settle the question. Tracked
368-
separately.
349+
### The toolbar panel is sized to the pill (VRT-jiuo)
350+
351+
The panel carrying the pill used to be a fixed 240x104 rect pinned to the host's
352+
bottom-right, permanently mounted in BOTH modes. It consumes presses across its
353+
whole frame, so that corner of the host app could not be clicked, and no frame drag
354+
could be started in it. The idle pill used 8% of it; the other 92% was dead space no
355+
user could see and every user could hit.
356+
357+
**The mechanism, measured rather than assumed.** macOS does not route mouse events
358+
through a window's transparent parts. This was listed as a candidate on the
359+
assumption that per-pixel alpha pass-through would save it; it does not. A panel
360+
whose content view is a bare `NSView` drawing NOTHING at all, with `isOpaque =
361+
false` and a clear background, swallows the click just the same.
362+
`ignoresMouseEvents = true` is the only setting that lets events through, and it
363+
would take the pill's own clicks with it. So the only lever is the panel's SIZE.
364+
365+
**Sizing and placement are split, and the split is the design.** SwiftUI knows
366+
exactly how wide the pill is — including while it animates between the lone pencil
367+
and the six-control row — so the size is measured from it. What AppKit gets wrong is
368+
the ANCHOR: a window resize preserves the TOP-LEFT, and this panel is anchored
369+
bottom-RIGHT, so letting AppKit apply the size slid the control left by the chrome
370+
margin. `sizingOptions = []` takes that away, and
371+
``OverlayPlacement/toolbarFrame(hostFrame:visibleFrame:panelSize:)`` derives the
372+
frame BACKWARDS from where the pill has to land. The panel grows leftwards and
373+
shrinks back with the control fixed at `host.maxX - 20`, exactly where it always
374+
was — verified from AppKit's own view frames, not from the formula under test.
375+
376+
**Measured on a FRESH hosting view, not the installed one.** The installed view has
377+
already been stretched to the panel's current frame, so asking it for a fitting size
378+
returns the answer being replaced — a measurement of the status quo, which never
379+
shrinks. That circularity is why the first attempt silently did nothing.
380+
381+
**Grow now, shrink later.** A panel that grows LATE clips the control it carries; a
382+
panel that shrinks EARLY clips it just the same, because the pill animates its width
383+
over 0.15s on a mode change. So growth is immediate and speculative and shrinking
384+
waits for the animation. In between the panel is briefly larger than it needs to be.
385+
386+
What remains dead is ``PillStyle/panelChrome`` — the band the drop shadow (radius
387+
12, offset 8 down) and the count badge (hung 5pt past the pill's top-left corner)
388+
draw into. Clipping either is visible, so this is the irreducible cost, and it hugs
389+
the control and reads as part of it. Pinned by probe 11a with three real clicks: a
390+
control click that must land, a click where the old fixed panel used to sit that must
391+
now reach the catcher, and a click in the chrome band that must still be swallowed.
369392

370393
### Recall survives a scroll, best-effort and no further (macOS)
371394

PARITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ row; each asymmetry is closed by code or has a tracked mitigation.
1818
| 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 |
1919
| 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:)` |
2020
| 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 |
21+
| Toolbar panel footprint | sized to the pill (`OverlayPlacement.toolbarFrame(hostFrame:visibleFrame:panelSize:)`, measured from a fresh hosting view) | n/a — the iOS overlay is a single pass-through `UIWindow`, and the pill is not carried in a window of its own | ASYMMETRIC BY THE HOSTING MODEL. On macOS the pill lives in its own `NSPanel`, and a panel covers what it covers: macOS does not route mouse events through a window's transparent parts, so the panel's SIZE is the host app's clickable area. iOS has no equivalent problem — `PassThroughWindow` forwards touches outside the overlay's interactive subviews, which is the pass-through macOS does not offer |
2122
| The first click on the overlay acts | `FirstMouseHostingView` (`acceptsFirstMouse -> true`) on both panels' content views | n/a | ASYMMETRIC BY THE PLATFORM, not by the code: "a mouse-down in a non-key window is discarded unless the view accepts first mouse" is an AppKit window-activation rule with no UIKit equivalent — iOS has no key window a press must first buy, and a touch on the pass-through `UIWindow` is delivered on the first tap. Nothing to mirror; the fix lives entirely in the macOS host |
2223
| Live selection follows the content on scroll | `translateSelection(by:within:)`, driven by the same measurement `KeyablePanel.scrollWheel` already makes | none | SAME asymmetry and the same reason as the row for captured notes below: the correction is affordable on macOS only because the overlay panel intercepts every wheel event and drives the clip itself. The receiving side is shared and unit-tested (`translateSelection` is pure geometry over session state), so an iOS host that observes its own scrollers can adopt it unchanged |
2324
| Recallable selection marks (a captured note's geometry comes back on hover) | shared: `AnnotationNote.anchorRect`/`drawnRect` snapshotted in `AnnotationSession.addNote`, `PinAttentionRule` + `RecalledMark` as pure rules, rendered by the shared `AnnotationMarks` | same | none — every part of it is platform-free. The two rects are WINDOW-LOCAL, which is the one place a platform could have crept in: macOS subtracts the overlay panel's `axOrigin`, iOS passes `.zero` because its view-tree frames are already view-local, and `addNote` takes that origin as a parameter rather than knowing which platform it is on. Both fields are UI-only and out of `CodingKeys`, so the exported record is identical on both |

Sources/AnnotKit/Overlay/OverlayView.swift

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -635,19 +635,21 @@ struct ToolbarOverlayView: View {
635635
let onExport: () -> Void
636636

637637
var body: some View {
638-
VStack {
639-
Spacer()
640-
HStack {
641-
Spacer()
642-
ToolbarView(
643-
session: session,
644-
onToggle: onToggle,
645-
onCopy: onCopy,
646-
onExport: onExport
647-
)
648-
.padding(20)
649-
}
650-
}
638+
// JUST the pill, inset by the space its shadow and count badge need. There
639+
// are no Spacers pushing it into a corner any more, because the panel is no
640+
// longer a fixed 240x104 rect the pill sits in the corner of — it is sized
641+
// to THIS view (`fittingSize`) and placed so the pill lands exactly where it
642+
// always has. The panel covering only the control is the point: every pixel
643+
// it covers is a pixel the host app cannot be clicked through, in both
644+
// modes, permanently.
645+
ToolbarView(
646+
session: session,
647+
onToggle: onToggle,
648+
onCopy: onCopy,
649+
onExport: onExport
650+
)
651+
.padding(PillStyle.panelChrome)
652+
.fixedSize()
651653
.ignoresSafeArea()
652654
.accessibilityHidden(true)
653655
}

Sources/AnnotKit/Overlay/PillStyle.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,30 @@ enum PillStyle {
4848
/// white-on-dark weight as ``border`` but a touch stronger, so it reads as a
4949
/// deliberate group boundary at 1pt instead of disappearing into the capsule.
5050
static let divider = Color.white.opacity(0.1)
51+
52+
/// Blank space the toolbar panel keeps AROUND the pill, for the chrome that
53+
/// draws OUTSIDE the control's own layout bounds and would otherwise be clipped
54+
/// by a snug window: the drop shadow (radius 12, offset 8 DOWN, hence the taller
55+
/// bottom) and the count badge, which is hung 5pt past the pill's top-left
56+
/// corner.
57+
///
58+
/// This is the whole remaining cost of the panel being hit-testable. macOS does
59+
/// not route mouse events through a window's transparent parts — measured, and
60+
/// true even of a panel drawing nothing at all — so every pixel the panel covers
61+
/// is a pixel the host app cannot be clicked through. Sizing the panel to the
62+
/// pill reduces that to this band, which hugs the control and reads as part of
63+
/// it. It was a permanently-mounted 240x104 rect over the host's bottom-right
64+
/// corner, dead in BOTH modes, of which the pill used 8% when idle.
65+
///
66+
/// Shared with ``OverlayPlacement/toolbarFrame(hostFrame:visibleFrame:panelSize:)``,
67+
/// which subtracts it to keep the pill at the same inset from the host's corner
68+
/// it has always had: the panel changing size must not move the control.
69+
static let panelChrome = EdgeInsets(top: 14, leading: 14, bottom: 20, trailing: 14)
70+
71+
/// The pill's inset from the visible region's bottom-right corner. Unchanged
72+
/// from when it was padding INSIDE a fixed 240x104 panel, so the control sits
73+
/// exactly where it always has.
74+
static let cornerInset: CGFloat = 20
5175
}
5276

5377
// MARK: - Icon-button palette

0 commit comments

Comments
 (0)