Skip to content

Commit ed59186

Browse files
angusbezzinaclaude
andcommitted
Overlay UX round 4 (cli-4gbzc): toggle glyphs, copy feedback, tooltips, X-to-exit, keyboard, colors
1. Annotate toggle uses the real Lucide pencil (idle) / pencil-off (annotating). Adds elliptical-arc (A/a) handling to the LucideShape parser (line-to-endpoint approximation) so the real Lucide d-strings render. 2. Copy shows a green check for ~1s as success feedback, then reverts. 3. Every pill icon gets a reliable hover tooltip (SwiftUI .help plus an NSView.toolTip backing for the non-activating overlay panel). 4. The X no longer unmounts the pill (which made it vanish permanently); it now exits annotate mode, and the pill is always visible in dev. 5. In the note field, Enter submits and Shift+Enter inserts a newline (.onKeyPress(keys: [.return]) checking the shift modifier), replacing the Cmd-Return decision. Applies to the composer and the pin editor. 6. Composer: 'Add note' (and the pin 'Save') are blue prominent buttons; the caret triangle now uses the card's material so it matches the box gray. 7. The pin-editor Delete button is red. Build clean (Swift 6); 44 tests; AnnotKitProbe + AnnotKitOverlayProbe pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 688cfdc commit ed59186

5 files changed

Lines changed: 136 additions & 42 deletions

File tree

Sources/AnnotKit/Overlay/AnnotationPins.swift

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,21 @@ private struct PinPopover: View {
9191
self._draft = State(initialValue: note.comment)
9292
}
9393

94+
/// Save the edited comment (Enter or the Save button) and dismiss.
95+
private func save() {
96+
guard !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
97+
session.updateNote(id: note.id, comment: draft)
98+
isPresented = false
99+
}
100+
94101
var body: some View {
95102
VStack(alignment: .leading, spacing: 8) {
96103
HStack(spacing: 8) {
97104
Text(note.selector)
98105
.font(.caption.monospaced())
99106
.lineLimit(1)
100107
Spacer(minLength: 8)
101-
Text("⏎ save · esc cancel")
108+
Text("⏎ save · ⇧⏎ newline")
102109
.font(.caption2)
103110
.foregroundStyle(.secondary)
104111
.fixedSize()
@@ -109,20 +116,25 @@ private struct PinPopover: View {
109116
.lineLimit(2 ... 5)
110117
.frame(width: 240)
111118
.focused($focused)
119+
// Enter saves the edit; Shift+Enter inserts a newline.
120+
.onKeyPress(keys: [.return]) { key in
121+
if key.modifiers.contains(.shift) { return .ignored }
122+
save()
123+
return .handled
124+
}
112125
HStack {
113126
Button(role: .destructive) {
114127
session.deleteNote(id: note.id)
115128
isPresented = false
116129
} label: {
117130
Label("Delete", systemImage: "trash")
131+
.foregroundStyle(.red)
118132
}
133+
.tint(.red)
119134
Spacer()
120-
Button("Save") {
121-
session.updateNote(id: note.id, comment: draft)
122-
isPresented = false
123-
}
124-
.keyboardShortcut(.return, modifiers: .command)
125-
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
135+
Button("Save") { save() }
136+
.buttonStyle(.borderedProminent)
137+
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
126138
}
127139
.frame(width: 240)
128140
}

Sources/AnnotKit/Overlay/OverlayView.swift

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,8 @@ struct OverlayView: View {
107107
.font(.headline)
108108
.lineLimit(1)
109109
Spacer(minLength: 8)
110-
// The field is multiline (Return inserts a newline), so surface
111-
// the submit/cancel keys instead of relying on a bare Return.
112-
Text("⌘⏎ save · esc cancel")
110+
// Enter submits; Shift+Enter inserts a newline; Esc cancels.
111+
Text("⏎ save · ⇧⏎ newline")
113112
.font(.caption2)
114113
.foregroundStyle(.secondary)
115114
.fixedSize()
@@ -120,6 +119,13 @@ struct OverlayView: View {
120119
.lineLimit(2 ... 5)
121120
.frame(width: 260)
122121
.focused($composerFocused)
122+
// Enter submits the note; Shift+Enter falls through to insert a
123+
// newline in the multiline field.
124+
.onKeyPress(keys: [.return]) { key in
125+
if key.modifiers.contains(.shift) { return .ignored }
126+
addNote()
127+
return .handled
128+
}
123129
HStack {
124130
Button("Cancel") {
125131
comment = ""
@@ -129,33 +135,23 @@ struct OverlayView: View {
129135
// panel is key while typing, so the shortcut reaches this button.
130136
.keyboardShortcut(.cancelAction)
131137
Spacer()
132-
Button("Add note") {
133-
// Snapshot the pin anchor BEFORE addNote clears the selection:
134-
// element AX top-left minus axOrigin, the same window-local
135-
// transform the highlight uses, so the pin lands on the
136-
// element's top-left corner.
137-
let anchor = session.selected.map {
138-
CGPoint(x: $0.frame.minX - axOrigin.x, y: $0.frame.minY - axOrigin.y)
139-
}
140-
session.addNote(comment: comment, anchor: anchor)
141-
comment = ""
142-
}
143-
// Multiline field: Return is a newline, ⌘Return submits.
144-
.keyboardShortcut(.return, modifiers: .command)
145-
.disabled(comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
138+
Button("Add note") { addNote() }
139+
.buttonStyle(.borderedProminent)
140+
.disabled(comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
146141
}
147142
.frame(width: 260)
148143
}
149144
.padding(12)
150145
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
151-
// Accent caret in the gap, tying the card back to the highlighted
152-
// element: it uses the same accent as the highlight stroke, points up
153-
// when the card is below (down when flipped above), and slides
154-
// horizontally (`caretDX`) to line up with the element's center. Added
155-
// before the shadow so the card and caret cast one unified shadow.
146+
// Caret in the gap, tying the card back to the highlighted element. It
147+
// uses the SAME material as the card so it reads as the card's pointer
148+
// (not an accent), points up when the card is below (down when flipped
149+
// above), and slides horizontally (`caretDX`) to line up with the
150+
// element's center. Added before the shadow so card and caret cast one
151+
// unified shadow.
156152
.overlay(alignment: placement.caretPointsUp ? .top : .bottom) {
157153
ComposerCaret(pointsUp: placement.caretPointsUp)
158-
.fill(Color.accentColor)
154+
.fill(.regularMaterial)
159155
.frame(width: 16, height: 8)
160156
.offset(x: placement.caretDX, y: placement.caretPointsUp ? -7 : 7)
161157
}
@@ -187,6 +183,20 @@ struct OverlayView: View {
187183
Task { @MainActor in composerFocused = true }
188184
}
189185

186+
/// Capture the pending note. Snapshots the pin anchor BEFORE `addNote` clears
187+
/// the selection (element AX top-left minus axOrigin — the same window-local
188+
/// transform the highlight uses — so the pin lands on the element's top-left
189+
/// corner), then resets the field. Enter submits; Shift+Enter inserts a
190+
/// newline (handled in the field's `onKeyPress`).
191+
private func addNote() {
192+
guard !comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
193+
let anchor = session.selected.map {
194+
CGPoint(x: $0.frame.minX - axOrigin.x, y: $0.frame.minY - axOrigin.y)
195+
}
196+
session.addNote(comment: comment, anchor: anchor)
197+
comment = ""
198+
}
199+
190200
/// Estimated card size (260 field + 12 padding each side ≈ 284 wide; a
191201
/// representative height). Only used to clamp the card on-screen and to pick
192202
/// below vs above — the caret attaches to the card's real edge via layout, so
@@ -253,29 +263,35 @@ private struct ToolbarView: View {
253263
let onClose: () -> Void
254264

255265
@Environment(\.accessibilityReduceMotion) private var reduceMotion
266+
@State private var justCopied = false
256267

257268
private var annotating: Bool { session.mode == .annotating }
258269
private var hasNotes: Bool { !session.pending.isEmpty }
259270

260271
var body: some View {
261272
HStack(spacing: 2) {
262273
PillButton(
263-
icon: .pencil,
274+
icon: annotating ? .pencilOff : .pencil,
264275
isActive: annotating,
265276
tooltip: annotating ? "Stop annotating" : "Annotate",
266277
action: onToggle
267278
)
268279

269280
if hasNotes {
270-
PillButton(icon: .copy, tooltip: "Copy notes (Markdown)", action: onCopy)
281+
PillButton(
282+
icon: justCopied ? .check : .copy,
283+
glyphTint: justCopied ? PillStyle.success : nil,
284+
tooltip: justCopied ? "Copied" : "Copy notes (Markdown)",
285+
action: { onCopy(); flashCopied() }
286+
)
271287
PillButton(icon: .download, tooltip: "Export to AGENTATION_NOTES.md", action: onExport)
272288
PillButton(icon: .trash, isDestructive: true, tooltip: "Clear notes") {
273289
session.clear()
274290
}
275291
}
276292

277293
divider
278-
PillButton(icon: .close, tooltip: "Close", action: onClose)
294+
PillButton(icon: .close, tooltip: "Exit annotate mode", action: onClose)
279295
}
280296
.padding(.horizontal, 6)
281297
.padding(.vertical, 8) // 28pt buttons + 8*2 -> 44pt pill height
@@ -299,6 +315,16 @@ private struct ToolbarView: View {
299315
.animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: hasNotes)
300316
}
301317

318+
/// Show a green check on the Copy button for a beat as success feedback, then
319+
/// revert to the copy glyph.
320+
private func flashCopied() {
321+
justCopied = true
322+
Task { @MainActor in
323+
try? await Task.sleep(for: .seconds(1))
324+
justCopied = false
325+
}
326+
}
327+
302328
/// Compact count bubble that overlaps the pill's top-left corner. A ~18pt
303329
/// accent capsule (grows for multi-digit counts) with white monospaced digits
304330
/// and a thin dark ring for contrast; reuses the accent so it reads as one
@@ -330,13 +356,15 @@ private struct PillButton: View {
330356
let icon: LucideIcon
331357
var isActive: Bool = false
332358
var isDestructive: Bool = false
359+
var glyphTint: Color? = nil
333360
let tooltip: String
334361
let action: () -> Void
335362

336363
@Environment(\.accessibilityReduceMotion) private var reduceMotion
337364
@State private var hovering = false
338365

339366
private var glyphColor: Color {
367+
if let glyphTint { return glyphTint }
340368
if isDestructive && hovering { return .white }
341369
if isActive { return .white }
342370
return hovering ? PillStyle.iconHover : PillStyle.iconIdle
@@ -360,7 +388,7 @@ private struct PillButton: View {
360388
.contentShape(Circle())
361389
}
362390
.buttonStyle(PressablePillButtonStyle(reduceMotion: reduceMotion))
363-
.help(tooltip)
391+
.pillToolTip(tooltip)
364392
.accessibilityLabel(tooltip)
365393
.onHover { value in
366394
guard !reduceMotion else { hovering = value; return }

Sources/AnnotKit/Overlay/PillStyle.swift

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ enum PillStyle {
3737
static let iconHover = Color.white.opacity(0.8)
3838
static let hoverBackground = Color.white.opacity(0.1)
3939
static let destructive = Color(hex: "EF4444")
40+
static let success = Color(hex: "22C55E")
4041
static let divider = Color.white.opacity(0.08)
4142
}
4243

@@ -62,14 +63,21 @@ enum IconPart {
6263
struct LucideIcon {
6364
let parts: [IconPart]
6465

65-
/// The annotate-toggle glyph (an annotate-indicative `pencil`). Authored from
66-
/// straight strokes only — Lucide's real `pencil` and `square-dashed-mouse-
67-
/// pointer` are arc-heavy, and the primitive `d`-parser implements no
68-
/// elliptical arc (`A`) command, so this follows the existing `download`
69-
/// precedent: a diagonal shaft with a triangular tip, plus a collar band.
66+
/// Lucide `pencil` — the annotate toggle's IDLE glyph. Uses the real Lucide
67+
/// `d` strings; the parser approximates the small corner arcs (`a`) as a line
68+
/// to the arc endpoint, which reads identically at 16pt.
7069
static let pencil = LucideIcon(parts: [
71-
.path("M4 20 L4 16 L14 6 L18 10 L8 20 Z"),
72-
.line(CGPoint(x: 13, y: 7), CGPoint(x: 17, y: 11)),
70+
.path("M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"),
71+
.path("m15 5 4 4"),
72+
])
73+
74+
/// Lucide `pencil-off` — the annotate toggle's ACTIVE (annotating) glyph: the
75+
/// pencil with a diagonal slash through it.
76+
static let pencilOff = LucideIcon(parts: [
77+
.path("m10 10-6.157 6.162a2 2 0 0 0-.5.833l-1.322 4.36a.5.5 0 0 0 .622.624l4.358-1.323a2 2 0 0 0 .83-.5L14 13.982"),
78+
.path("m12.829 7.172 4.359-4.346a1 1 0 1 1 3.986 3.986l-4.353 4.353"),
79+
.path("m15 5 4 4"),
80+
.path("m2 2 20 20"),
7381
])
7482

7583
static let check = LucideIcon(parts: [.path("M20 6 9 17l-5-5")])
@@ -201,6 +209,17 @@ struct LucideShape: Shape {
201209
let end = rel ? CGPoint(x: current.x + c, y: current.y + d2) : CGPoint(x: c, y: d2)
202210
current = end
203211
path.addQuadCurve(to: scaled(end), control: scaled(ctrl))
212+
case "A", "a":
213+
// Elliptical arc. The parser has no arc-to-bezier, so it draws a
214+
// straight segment to the arc ENDPOINT — Lucide's pencil/pencil-off
215+
// arcs are small corner rounds and the flat eraser diagonal, which
216+
// read the same at 16pt. Consume all 7 params (rx ry rot large
217+
// sweep x y).
218+
guard nextNumber() != nil, nextNumber() != nil, nextNumber() != nil,
219+
nextNumber() != nil, nextNumber() != nil,
220+
let ax = nextNumber(), let ay = nextNumber() else { return }
221+
current = command == "a" ? CGPoint(x: current.x + ax, y: current.y + ay) : CGPoint(x: ax, y: ay)
222+
path.addLine(to: scaled(current))
204223
case "Z", "z":
205224
path.closeSubpath()
206225
current = subStart
@@ -254,3 +273,35 @@ struct LucideShape: Shape {
254273
return tokens
255274
}
256275
}
276+
277+
// MARK: - Tooltip
278+
279+
#if os(macOS)
280+
/// A zero-content `NSView` carrying a `toolTip`, layered behind a control so the
281+
/// hover tooltip shows reliably inside the borderless, non-activating overlay
282+
/// panel (where SwiftUI's `.help` alone can fail to render).
283+
private struct ToolTipBacking: NSViewRepresentable {
284+
let text: String
285+
func makeNSView(context: Context) -> NSView {
286+
let view = NSView()
287+
view.toolTip = text
288+
return view
289+
}
290+
func updateNSView(_ view: NSView, context: Context) {
291+
view.toolTip = text
292+
}
293+
}
294+
#endif
295+
296+
extension View {
297+
/// Hover tooltip for a pill control: SwiftUI `.help` plus (on macOS) an
298+
/// NSView-backed `toolTip` for reliability inside the overlay panel.
299+
@ViewBuilder
300+
func pillToolTip(_ text: String) -> some View {
301+
#if os(macOS)
302+
help(text).background(ToolTipBacking(text: text))
303+
#else
304+
help(text)
305+
#endif
306+
}
307+
}

Sources/AnnotKit/macOS/OverlayController.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,9 @@ public final class OverlayController: NSObject {
152152
onToggle: { [weak self] in self?.toggle() },
153153
onCopy: { [weak self] in self?.copy() },
154154
onExport: { [weak self] in self?.export() },
155-
onClose: { [weak self] in self?.unmount() },
155+
// The pill's X exits annotate mode; it does NOT unmount the overlay.
156+
// Dev overlays stay visible so the pill is always reachable.
157+
onClose: { [weak self] in self?.stop() },
156158
// Make the non-activating child panel key so the composer/pin-editor
157159
// text fields accept keystrokes (a plain borderless panel that is not
158160
// key silently drops typing).

Tests/AnnotKitTests/LucideIconTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ final class LucideIconTests: XCTestCase {
1212

1313
private let icons: [(name: String, icon: LucideIcon)] = [
1414
("pencil", .pencil),
15+
("pencilOff", .pencilOff),
1516
("check", .check),
1617
("copy", .copy),
1718
("download", .download),

0 commit comments

Comments
 (0)