diff --git a/apps/menubar/Codogotchi.xcodeproj/project.pbxproj b/apps/menubar/Codogotchi.xcodeproj/project.pbxproj index 9bfcb10e..48ff284d 100644 --- a/apps/menubar/Codogotchi.xcodeproj/project.pbxproj +++ b/apps/menubar/Codogotchi.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ 588624B3B7B400E50C961066 /* HookRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93F078A9D14984485EB28AC9 /* HookRowView.swift */; }; 58A715CD06913CC033DA019A /* FloatingPetWindowPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5CF055F2A0C9DD84F4F026A /* FloatingPetWindowPool.swift */; }; 595CC685C452AE0EA1EB9E2F /* PerSessionReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC8A9F0A42DAF1E086D27A /* PerSessionReaderTests.swift */; }; + 59E6748B45FAB395638DCE99 /* FloatingPetPromptDismissal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F7C601B6A2F543E0A4A246 /* FloatingPetPromptDismissal.swift */; }; 5B8D08B5B584C695EB75E553 /* SessionLabelStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970D0D22D956516B6FAF0632 /* SessionLabelStoreTests.swift */; }; 5BE414B5F01BD917B60E917C /* SessionLabelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A99294D27E5D1862559FCA2 /* SessionLabelStore.swift */; }; 5ED2ED3F249FA9CB7F314F94 /* FloatingInteractionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45AA3E424EA09D77F47D6C1F /* FloatingInteractionPolicy.swift */; }; @@ -87,6 +88,7 @@ 686F220636F60BF638F4588B /* RPGHUDViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B45875058BDB67738967BE7A /* RPGHUDViewModel.swift */; }; 68A45AC229C671EE01A9DEAC /* PerPlatformSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41B41CAA48264D60619035FE /* PerPlatformSnapshot.swift */; }; 692DB55B96C40097202445F2 /* PlatformAttribution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44643FE5868DCD56DDF5F1E2 /* PlatformAttribution.swift */; }; + 69FAC7752922A70F4943791A /* FloatingPetPromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C3594285193B88B858A1C8 /* FloatingPetPromptBuilder.swift */; }; 6A6E965676C08DCF5EA44900 /* PromptTimerChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D8D2C351C51A43AE40A1A6 /* PromptTimerChipView.swift */; }; 6A78A39CED185B02C213FE62 /* MenuItemsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06FB02C825E53E28CCC8205 /* MenuItemsTests.swift */; }; 6BE7F6EA53A623C22C0B98E5 /* RPGHUDIconTileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6858ADF75A07FF4C263F283 /* RPGHUDIconTileView.swift */; }; @@ -309,6 +311,7 @@ 8CF4712922D6C0BA86E1D1A4 /* FloatingInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingInteractionTests.swift; sourceTree = ""; }; 8DBC88DE074DC11652453ADE /* PetTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PetTabView.swift; sourceTree = ""; }; 8E2899DD8851B86633D4BB28 /* PetThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PetThumbnail.swift; sourceTree = ""; }; + 92F7C601B6A2F543E0A4A246 /* FloatingPetPromptDismissal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPetPromptDismissal.swift; sourceTree = ""; }; 9310785E0303F2C6627ADAE4 /* ConflictBubbleRateLimiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConflictBubbleRateLimiter.swift; sourceTree = ""; }; 933225DDA49AB5637263654E /* MinimalistBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinimalistBadgeView.swift; sourceTree = ""; }; 93F078A9D14984485EB28AC9 /* HookRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookRowView.swift; sourceTree = ""; }; @@ -334,6 +337,7 @@ A8A8D4BFA46121B6684F74F5 /* AnimationBadgeChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationBadgeChrome.swift; sourceTree = ""; }; A977437677E31BB6CF62E58C /* FloatingPetWindowPoolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPetWindowPoolTests.swift; sourceTree = ""; }; A99F6A33E762533EA412019B /* ConflictBubblePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConflictBubblePayload.swift; sourceTree = ""; }; + A9C3594285193B88B858A1C8 /* FloatingPetPromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPetPromptBuilder.swift; sourceTree = ""; }; AA22D5D2131753BBC81766B8 /* SessionNumberAllocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionNumberAllocator.swift; sourceTree = ""; }; AB2EB6CC13A81EBCFDAF037F /* DemoCycleDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoCycleDriver.swift; sourceTree = ""; }; AC5C744A6A25DDB45C495A36 /* SessionLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLifecycle.swift; sourceTree = ""; }; @@ -547,7 +551,9 @@ AEE399EF550E69D66FBC271D /* FloatingPetInteractionView.swift */, D574C8088D0CC53510729EAA /* FloatingPetOverlayView.swift */, 22CB181AE0D1FF3EE2035186 /* FloatingPetPanelController.swift */, + A9C3594285193B88B858A1C8 /* FloatingPetPromptBuilder.swift */, 8780B208D0E108DA773D3287 /* FloatingPetPromptCoordinator.swift */, + 92F7C601B6A2F543E0A4A246 /* FloatingPetPromptDismissal.swift */, 3351EB8DFFDD6E8E9263B6A8 /* FloatingPetPromptItem.swift */, E2120EF41902AB522F5AC969 /* GateBadgeLayout.swift */, D1203CF2C4FF29C31534A7CF /* GateBadgePanel.swift */, @@ -837,7 +843,9 @@ 2E2D4A6F8E45BCA89918B7F1 /* FloatingPetInteractionView.swift in Sources */, C290EE1287F115D6FCFA05D8 /* FloatingPetOverlayView.swift in Sources */, CA717B4026463DF504612273 /* FloatingPetPanelController.swift in Sources */, + 69FAC7752922A70F4943791A /* FloatingPetPromptBuilder.swift in Sources */, 11206CBC485103EA118341AB /* FloatingPetPromptCoordinator.swift in Sources */, + 59E6748B45FAB395638DCE99 /* FloatingPetPromptDismissal.swift in Sources */, D0829C6BFE047F8829080BE5 /* FloatingPetPromptItem.swift in Sources */, 21B73048740BFD4E98BB56CB /* FloatingPetScene.swift in Sources */, 58A715CD06913CC033DA019A /* FloatingPetWindowPool.swift in Sources */, diff --git a/apps/menubar/Sources/Windows/FloatingPetInteractionView.swift b/apps/menubar/Sources/Windows/FloatingPetInteractionView.swift index 663bf839..7756a712 100644 --- a/apps/menubar/Sources/Windows/FloatingPetInteractionView.swift +++ b/apps/menubar/Sources/Windows/FloatingPetInteractionView.swift @@ -25,10 +25,7 @@ final class FloatingPetInteractionView: NSView { private var isReconfiguringTracking = false private var resizeCursorPushed = false private var localMouseMonitor: Any? - private var globalMouseMonitor: Any? - private var globalKeyboardMonitor: Any? - private var hidePromptLocalMouseMonitor: Any? - private var hidePromptDismissObservers: [NSObjectProtocol] = [] + private let promptDismissal = FloatingPetPromptDismissal() private var pointerInsideFrame = false private var affordanceHoverActive = false private var hidePromptPanel: FloatingPetHidePromptPanel? @@ -153,7 +150,7 @@ final class FloatingPetInteractionView: NSView { deinit { removeLocalMouseMonitor() - removeHidePromptDismissObservers() + promptDismissal.uninstall() } override func updateTrackingAreas() { @@ -470,73 +467,46 @@ final class FloatingPetInteractionView: NSView { func presentHidePrompt(anchorInScreen: CGPoint, localPoint: CGPoint) { dismissHidePrompt() guard let window else { return } - FloatingPetPromptCoordinator.shared.willPresent(owner: self) { [weak self] in - self?.dismissHidePrompt() - } - var items: [FloatingPetPromptItem] = [] - // Escape hatch when the pet is stuck mid-animation (rate-limited or - // manually-stopped prompt): sits above "Hide pet" as the primary action. - if isForceIdleAvailable { - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.forceIdleTitle) { [weak self] in - self?.dismissHidePrompt() - self?.forceIdleHandler?() - }) - } - // Offered whenever the window is showing a session-label badge at all — - // a session-keyed window's own "Session N" default, or a plain-origin/ - // combined window's platform-name default (P?? unification) — not - // just a session-keyed one. Sits above "Hide pet". - if currentSessionLabel != nil { - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.renameTitle) { [weak self] in - self?.dismissHidePrompt() - self?.presentRenameAlert() - }) - } - // Only offered on an actual session-keyed window — a plain-origin/ - // combined window has no session id to resolve a platform title for. - // Sits directly below "Rename…", grouping the two label actions; - // non-destructive, so it fires immediately on click like Rename does - // once its (separate) text-entry alert is confirmed. - if hasActiveSessionBadge { - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.syncLabelTitle) { [weak self] in - self?.dismissHidePrompt() - self?.syncLabelHandler?() - }) - } - // Destroys backing session state (slice, free-list number), so unlike - // Rename this stays restricted to an actual session-keyed window — - // a plain-origin/combined window has no session to prune. Destructive, - // so it sits above "Hide pet" but requires a confirmation alert rather - // than firing immediately on click. - if hasActiveSessionBadge { - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.pruneTitle) { [weak self] in - self?.dismissHidePrompt() - self?.presentPruneConfirmation() - }) - } - // Mode switch to the compact badge strip. Sits directly above "Hide pet" - // — like Hide it acts on the window's chrome, not the session's state. - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.minimalistModeTitle) { [weak self] in + let capabilities = FloatingPetPromptCapabilities( + offersForceIdle: isForceIdleAvailable, + sessionLabel: currentSessionLabel, + hasActiveSession: hasActiveSessionBadge, + modeSwitchTitle: FloatingPetHidePrompt.minimalistModeTitle, + offersPanelSize: false, + hideItemTitle: FloatingPetHidePrompt.title + ) + let handlers = FloatingPetPromptHandlers( + forceIdle: { [weak self] in + self?.dismissHidePrompt() + self?.forceIdleHandler?() + }, + rename: { [weak self] in + self?.dismissHidePrompt() + self?.presentRenameAlert() + }, + syncLabel: { [weak self] in + self?.dismissHidePrompt() + self?.syncLabelHandler?() + }, + prune: { [weak self] in + self?.dismissHidePrompt() + self?.presentPruneConfirmation() + }, + modeSwitch: { [weak self] in self?.dismissHidePrompt() self?.minimalistModeHandler?() - }) - // Offered unconditionally, directly above "Hide pet" — the last - // action before the pet's own hide. - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.hideAllOtherPetsTitle) { [weak self] in + }, + panelSize: {}, + hideAllOtherPets: { [weak self] in self?.dismissHidePrompt() self?.hideAllOtherPetsHandler?() - }) - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.title) { [weak self] in + }, + hideThis: { [weak self] in self?.dismissHidePrompt() self?.hideFloatingPetHandler?() - }) + } + ) + let items = FloatingPetPromptBuilder.items(capabilities: capabilities, handlers: handlers) let promptSize = FloatingPetHidePrompt.stackSize(titles: items.map(\.title)) let visibleFrame = window.screen?.visibleFrame ?? visibleFrameProvider() let screenFrame = FloatingPetHidePrompt.screenFrame( @@ -547,7 +517,9 @@ final class FloatingPetInteractionView: NSView { let panel = FloatingPetHidePromptPanel(frame: screenFrame, items: items) panel.orderFrontRegardless() hidePromptPanel = panel - installHidePromptDismissObservers() + promptDismissal.install(owner: self, panel: panel) { [weak self] in + self?.dismissHidePrompt() + } } /// Presents a modal text-entry alert for renaming this session. Trims and @@ -606,96 +578,9 @@ final class FloatingPetInteractionView: NSView { } private func dismissHidePrompt() { - FloatingPetPromptCoordinator.shared.didDismiss(owner: self) + promptDismissal.uninstall() hidePromptPanel?.orderOut(nil) hidePromptPanel = nil - removeHidePromptDismissObservers() - } - - private func installHidePromptDismissObservers() { - removeHidePromptDismissObservers() - guard let window else { return } - - hidePromptDismissObservers.append( - NotificationCenter.default.addObserver( - forName: NSWindow.didResignKeyNotification, - object: window, - queue: .main - ) { [weak self] _ in - self?.dismissHidePrompt() - } - ) - hidePromptDismissObservers.append( - NotificationCenter.default.addObserver( - forName: NSApplication.didResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.dismissHidePrompt() - } - ) - - globalMouseMonitor = NSEvent.addGlobalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown] - ) { [weak self] event in - Task { @MainActor in - self?.handleGlobalMouseDownWhileHidePromptVisible(event) - } - } - - // In-app half of "any click away dismisses": global monitors only - // report other applications' events, so a click on a sibling pet, a - // Minimalist strip, or the menubar icon would strand this prompt - // without a local monitor. Dismissal is synchronous (not - // Task-deferred) — the monitor fires before the event dispatches, and - // a deferred dismissal would land after a right-click's re-present - // and tear down the new prompt instead of the old one. - hidePromptLocalMouseMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown] - ) { [weak self] event in - if let self, event.window !== self.hidePromptPanel { - self.dismissHidePrompt() - } - return event - } - - // Dismiss on any keyboard input (including Cmd+Tab / Alt+Tab system - // switchers) so the pill never lingers over the UI while the user is - // changing apps/windows. - globalKeyboardMonitor = NSEvent.addGlobalMonitorForEvents( - matching: [.keyDown, .keyUp, .flagsChanged] - ) { [weak self] _ in - Task { @MainActor in - self?.dismissHidePrompt() - } - } - } - - private func removeHidePromptDismissObservers() { - for observer in hidePromptDismissObservers { - NotificationCenter.default.removeObserver(observer) - } - hidePromptDismissObservers.removeAll() - if let globalMouseMonitor { - NSEvent.removeMonitor(globalMouseMonitor) - self.globalMouseMonitor = nil - } - if let hidePromptLocalMouseMonitor { - NSEvent.removeMonitor(hidePromptLocalMouseMonitor) - self.hidePromptLocalMouseMonitor = nil - } - if let globalKeyboardMonitor { - NSEvent.removeMonitor(globalKeyboardMonitor) - self.globalKeyboardMonitor = nil - } - } - - private func handleGlobalMouseDownWhileHidePromptVisible(_ event: NSEvent) { - guard let hidePromptPanel else { return } - if event.window === hidePromptPanel { - return - } - dismissHidePrompt() } private func removeLocalMouseMonitor() { diff --git a/apps/menubar/Sources/Windows/FloatingPetPromptBuilder.swift b/apps/menubar/Sources/Windows/FloatingPetPromptBuilder.swift new file mode 100644 index 00000000..808e4103 --- /dev/null +++ b/apps/menubar/Sources/Windows/FloatingPetPromptBuilder.swift @@ -0,0 +1,82 @@ +import AppKit + +/// Capabilities that vary per window shape (Own vs Minimalist), per +/// `docs/contracts/window-capability-matrix.md` §1. Named per capability, not +/// per shape — Combined is not a distinct capability set here: it inherits +/// whichever shape it is currently routed through and calls the builder with +/// that shape's capabilities. +struct FloatingPetPromptCapabilities { + /// R1.2 — pet/badge is not idle. + var offersForceIdle: Bool + /// R1.3 gate — the window's current session-label badge text, or `nil` + /// when no label is shown at all. Rename… is offered whenever non-nil, + /// independent of `hasActiveSession` (a plain-origin/combined window can + /// carry a label without being session-keyed). + var sessionLabel: String? + /// R1.4/R1.5 gate — an actual session-keyed window (not plain-origin or + /// combined); offers Sync Label and Prune Session. + var hasActiveSession: Bool + /// R1.6 — "Minimalist Mode" (Own) or "Pet Mode" (Minimalist). + var modeSwitchTitle: String + /// R1.7 — Minimalist-only "Panel Size…", unconditional when present; Own + /// has no analogous size concept. + var offersPanelSize: Bool + /// R1.9 — "Hide pet" (Own) or "Hide panel" (Minimalist); same semantics, + /// cosmetic title only. + var hideItemTitle: String +} + +/// Activation closures for every possible prompt item. Each closure is +/// expected to already perform any dismiss-on-activate the caller needs — the +/// builder only decides which items exist and in what order. +struct FloatingPetPromptHandlers { + var forceIdle: () -> Void + var rename: () -> Void + var syncLabel: () -> Void + var prune: () -> Void + var modeSwitch: () -> Void + var panelSize: () -> Void + var hideAllOtherPets: () -> Void + var hideThis: () -> Void +} + +/// The single `[FloatingPetPromptItem]` builder for the right-click hide +/// prompt, shared by Own (`FloatingPetInteractionView`) and Minimalist +/// (`MinimalistBadgeView`). Per-shape differences are expressed only as +/// `FloatingPetPromptCapabilities` fields — never as shape identity. +enum FloatingPetPromptBuilder { + static func items( + capabilities: FloatingPetPromptCapabilities, + handlers: FloatingPetPromptHandlers + ) -> [FloatingPetPromptItem] { + var items: [FloatingPetPromptItem] = [] + + if capabilities.offersForceIdle { + items.append( + FloatingPetPromptItem(title: FloatingPetHidePrompt.forceIdleTitle, onActivate: handlers.forceIdle)) + } + if capabilities.sessionLabel != nil { + items.append( + FloatingPetPromptItem(title: FloatingPetHidePrompt.renameTitle, onActivate: handlers.rename)) + } + if capabilities.hasActiveSession { + items.append( + FloatingPetPromptItem(title: FloatingPetHidePrompt.syncLabelTitle, onActivate: handlers.syncLabel)) + items.append( + FloatingPetPromptItem(title: FloatingPetHidePrompt.pruneTitle, onActivate: handlers.prune)) + } + items.append( + FloatingPetPromptItem(title: capabilities.modeSwitchTitle, onActivate: handlers.modeSwitch)) + if capabilities.offersPanelSize { + items.append( + FloatingPetPromptItem(title: FloatingPetHidePrompt.panelSizeTitle, onActivate: handlers.panelSize)) + } + items.append( + FloatingPetPromptItem( + title: FloatingPetHidePrompt.hideAllOtherPetsTitle, onActivate: handlers.hideAllOtherPets)) + items.append( + FloatingPetPromptItem(title: capabilities.hideItemTitle, onActivate: handlers.hideThis)) + + return items + } +} diff --git a/apps/menubar/Sources/Windows/FloatingPetPromptDismissal.swift b/apps/menubar/Sources/Windows/FloatingPetPromptDismissal.swift new file mode 100644 index 00000000..e1ebd250 --- /dev/null +++ b/apps/menubar/Sources/Windows/FloatingPetPromptDismissal.swift @@ -0,0 +1,131 @@ +import AppKit + +/// Owns the "any click away, keyboard input, or app-resign dismisses this +/// floating popup" observer stack shared by every prompt-like floating panel: +/// the hide-prompt pill stack (Own and Minimalist) and the Minimalist +/// panel-size slider pill. Installed independently on all three surfaces +/// before convergence — "the bug fixed three times" — this is the single +/// implementation; no prompt-presenting surface installs its own monitors. +/// +/// Deliberately does not listen for `NSWindow.didResignKeyNotification`: both +/// prompt/pill panels are `.nonactivatingPanel` borderless panels that can +/// never become key, so that observer never fires (R1.11 in +/// `docs/contracts/window-capability-matrix.md`, dispositioned dead code, not +/// carried into the converged component). +final class FloatingPetPromptDismissal { + private weak var owner: AnyObject? + private weak var panel: NSPanel? + private var dismissAction: (() -> Void)? + + private var didResignActiveObserver: NSObjectProtocol? + private var globalMouseMonitor: Any? + private var localMouseMonitor: Any? + private var globalKeyboardMonitor: Any? + + init() {} + + /// Defensive backstop: `weak var owner` is already `nil` by the time an + /// owning view's own `deinit` body runs (Swift zeroes weak references to + /// `self` before `self`'s `deinit` executes — confirmed empirically, not + /// merely assumed), so a caller that only calls `uninstall()` from its + /// `deinit` would otherwise leak the `NSEvent` monitors and the + /// `NotificationCenter` observer forever: nothing else releases them, and + /// each dead monitor keeps firing (as a harmless `[weak self]` no-op) on + /// every future mouse/keyboard event app-wide. `uninstall()` itself no + /// longer gates monitor teardown on `owner` being non-nil (see below), so + /// this deinit is belt-and-braces for any caller that skips an explicit + /// `uninstall()` call entirely. + deinit { + uninstall() + } + + /// Registers `owner` with `FloatingPetPromptCoordinator.shared` (so a + /// right-click on a different panel dismisses this one) and installs the + /// dismiss-on-click-away/keyboard/resign-active monitors. `panel` is the + /// floating window that a same-app click must NOT dismiss when the click + /// lands on it. + func install(owner: AnyObject, panel: NSPanel, dismiss: @escaping () -> Void) { + self.owner = owner + self.panel = panel + dismissAction = dismiss + + FloatingPetPromptCoordinator.shared.willPresent(owner: owner, dismiss: dismiss) + + didResignActiveObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.dismissAction?() } + } + + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown] + ) { [weak self] _ in + Task { @MainActor in self?.dismissAction?() } + } + + // In-app half of "any click away dismisses": global monitors only + // report other applications' events, so a click on a sibling panel + // would strand this popup without a local monitor. Dismissal is + // synchronous (not Task-deferred) — the monitor fires before the + // event dispatches, and a deferred dismissal would land after a + // re-present and tear down the new popup instead of the old one. + localMouseMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown] + ) { [weak self] event in + if let self, event.window !== self.panel { + self.dismissAction?() + } + return event + } + + // Dismiss on any keyboard input (including Cmd+Tab / Alt+Tab system + // switchers) so the popup never lingers over the UI while the user is + // changing apps/windows. + globalKeyboardMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.keyDown, .keyUp, .flagsChanged] + ) { [weak self] _ in + Task { @MainActor in self?.dismissAction?() } + } + } + + /// Tears down monitors and clears the coordinator's active-owner + /// registration. Safe to call when nothing is installed. + /// + /// Deliberately does NOT gate monitor/observer teardown on `owner` being + /// non-nil: `owner` is a weak reference that is already `nil` by the time + /// a caller's own `deinit` invokes this method (see the `deinit` note + /// above), but the `NSEvent` monitor tokens and `NotificationCenter` + /// observer token are independent of `owner` and must still be released. + /// Only the coordinator hand-off — which requires a live owner identity + /// to compare against — is skipped when `owner` is already gone; the + /// coordinator holds its own `weak var activeOwner`, so that registration + /// self-clears once the same deallocation completes. + func uninstall() { + if let owner { + FloatingPetPromptCoordinator.shared.didDismiss(owner: owner) + } + + if let didResignActiveObserver { + NotificationCenter.default.removeObserver(didResignActiveObserver) + } + if let globalMouseMonitor { + NSEvent.removeMonitor(globalMouseMonitor) + } + if let localMouseMonitor { + NSEvent.removeMonitor(localMouseMonitor) + } + if let globalKeyboardMonitor { + NSEvent.removeMonitor(globalKeyboardMonitor) + } + + didResignActiveObserver = nil + globalMouseMonitor = nil + localMouseMonitor = nil + globalKeyboardMonitor = nil + self.owner = nil + self.panel = nil + dismissAction = nil + } +} diff --git a/apps/menubar/Sources/Windows/MinimalistBadgeView.swift b/apps/menubar/Sources/Windows/MinimalistBadgeView.swift index 8f072a36..2a54f337 100644 --- a/apps/menubar/Sources/Windows/MinimalistBadgeView.swift +++ b/apps/menubar/Sources/Windows/MinimalistBadgeView.swift @@ -71,16 +71,10 @@ final class MinimalistBadgeView: NSView { private var dragOffsetInScreen: CGPoint? private var hidePromptPanel: FloatingPetHidePromptPanel? - private var hidePromptObservers: [NSObjectProtocol] = [] - private var hidePromptGlobalMouseMonitor: Any? - private var hidePromptGlobalKeyboardMonitor: Any? - private var hidePromptLocalMouseMonitor: Any? + private let hidePromptDismissal = FloatingPetPromptDismissal() private var sizePillPanel: MinimalistPanelSizePillPanel? - private var sizePillObservers: [NSObjectProtocol] = [] - private var sizePillGlobalMouseMonitor: Any? - private var sizePillGlobalKeyboardMonitor: Any? - private var sizePillLocalMouseMonitor: Any? + private let sizePillDismissal = FloatingPetPromptDismissal() override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -91,8 +85,8 @@ final class MinimalistBadgeView: NSView { required init?(coder: NSCoder) { nil } deinit { - removeHidePromptDismissObservers() - removeSizePillDismissObservers() + hidePromptDismissal.uninstall() + sizePillDismissal.uninstall() } func configureBadge( @@ -277,80 +271,49 @@ final class MinimalistBadgeView: NSView { guard let window else { return } dismissHidePrompt() dismissSizePill() - let offersForceIdle = FloatingPetHidePrompt.offersForceIdle(for: currentActivity) - FloatingPetPromptCoordinator.shared.willPresent(owner: self) { [weak self] in - self?.dismissHidePrompt() - } - var items: [FloatingPetPromptItem] = [] - // Escape hatch when the pet is stuck mid-animation (rate-limited or - // manually-stopped prompt): sits above "Hide panel" as the primary action. - if offersForceIdle { - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.forceIdleTitle) { [weak self] in - self?.dismissHidePrompt() - self?.onForceIdleRequested?() - }) - } - // Offered whenever the strip is showing a session-label badge at all — - // a session-keyed strip's own "Session N" default, or a plain-origin/ - // combined strip's platform-name default (P?? unification) — not just - // a session-keyed one. Sits above "Hide panel", mirroring Own mode's - // placement. - if currentSessionLabel != nil { - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.renameTitle) { [weak self] in - self?.dismissHidePrompt() - self?.presentRenameAlert() - }) - } - // Only offered on an actual session-keyed strip — a plain-origin/ - // combined strip has no session id to resolve a platform title for. - // Sits directly below "Rename…", grouping the two label actions. - if currentSessionNumber != nil { - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.syncLabelTitle) { [weak self] in - self?.dismissHidePrompt() - self?.syncLabelHandler?() - }) - } - // Destroys backing session state (slice, free-list number), so like - // Sync Label this stays restricted to an actual session-keyed strip. - // Destructive, so it requires a confirmation alert rather than firing - // immediately on click — mirrors Own mode's placement and contract. - if currentSessionNumber != nil { - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.pruneTitle) { [weak self] in - self?.dismissHidePrompt() - self?.presentPruneConfirmation() - }) - } - // Mode switch back to the full pet renderer. Sits directly above - // "Hide panel", mirroring Own mode's "Minimalist Mode" placement. - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.petModeTitle) { [weak self] in + let capabilities = FloatingPetPromptCapabilities( + offersForceIdle: FloatingPetHidePrompt.offersForceIdle(for: currentActivity), + sessionLabel: currentSessionLabel, + hasActiveSession: currentSessionNumber != nil, + modeSwitchTitle: FloatingPetHidePrompt.petModeTitle, + offersPanelSize: true, + hideItemTitle: FloatingPetHidePrompt.panelTitle + ) + let handlers = FloatingPetPromptHandlers( + forceIdle: { [weak self] in + self?.dismissHidePrompt() + self?.onForceIdleRequested?() + }, + rename: { [weak self] in + self?.dismissHidePrompt() + self?.presentRenameAlert() + }, + syncLabel: { [weak self] in + self?.dismissHidePrompt() + self?.syncLabelHandler?() + }, + prune: { [weak self] in + self?.dismissHidePrompt() + self?.presentPruneConfirmation() + }, + modeSwitch: { [weak self] in self?.dismissHidePrompt() self?.onPetModeRequested?() - }) - // Opens the slider pill driving the global minimalist badge - // scale — the same setting as the Customization tab's size slider, so - // it resizes every Minimalist platform's strip, not just this one. - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.panelSizeTitle) { [weak self] in + }, + panelSize: { [weak self] in self?.dismissHidePrompt() self?.presentPanelSizePill() - }) - // Offered unconditionally, directly above "Hide panel" — the last - // action before the strip's own hide, mirroring Own mode's placement. - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.hideAllOtherPetsTitle) { [weak self] in + }, + hideAllOtherPets: { [weak self] in self?.dismissHidePrompt() self?.hideAllOtherPetsHandler?() - }) - items.append( - FloatingPetPromptItem(title: FloatingPetHidePrompt.panelTitle) { [weak self] in + }, + hideThis: { [weak self] in self?.dismissHidePrompt() self?.onHidePanelRequested?() - }) + } + ) + let items = FloatingPetPromptBuilder.items(capabilities: capabilities, handlers: handlers) let promptSize = FloatingPetHidePrompt.stackSize(titles: items.map(\.title)) let visibleFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame @@ -363,7 +326,9 @@ final class MinimalistBadgeView: NSView { let panel = FloatingPetHidePromptPanel(frame: screenFrame, items: items) panel.orderFrontRegardless() hidePromptPanel = panel - installHidePromptDismissObservers() + hidePromptDismissal.install(owner: self, panel: panel) { [weak self] in + self?.dismissHidePrompt() + } } /// Presents a modal text-entry alert for renaming this session. Trims and @@ -432,10 +397,6 @@ final class MinimalistBadgeView: NSView { private func presentPanelSizePill() { guard let window else { return } dismissSizePill() - FloatingPetPromptCoordinator.shared.willPresent(owner: self) { [weak self] in - self?.dismissHidePrompt() - self?.dismissSizePill() - } let visibleFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? CGRect(x: 0, y: 0, width: 800, height: 600) @@ -459,148 +420,23 @@ final class MinimalistBadgeView: NSView { } pill.orderFrontRegardless() sizePillPanel = pill - installSizePillDismissObservers() + sizePillDismissal.install(owner: self, panel: pill) { [weak self] in + self?.dismissSizePill() + } } private func dismissSizePill() { guard sizePillPanel != nil else { return } - FloatingPetPromptCoordinator.shared.didDismiss(owner: self) + sizePillDismissal.uninstall() sizePillPanel?.orderOut(nil) sizePillPanel = nil - removeSizePillDismissObservers() - } - - /// Dismissal triggers: any click in another application (global monitor), - /// any click inside this app landing outside the pill's own window (local - /// monitor — global monitors never see same-app events, so without it a - /// click or drag on a sibling strip or pet panel would strand the pill), - /// any keyboard input, or this app resigning active. - private func installSizePillDismissObservers() { - removeSizePillDismissObservers() - - sizePillObservers.append( - NotificationCenter.default.addObserver( - forName: NSApplication.didResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in self?.dismissSizePill() } - } - ) - sizePillGlobalMouseMonitor = NSEvent.addGlobalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown] - ) { [weak self] _ in - Task { @MainActor in self?.dismissSizePill() } - } - sizePillLocalMouseMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown] - ) { [weak self] event in - // Clicks on the pill itself (the slider) must not dismiss it — - // everything else in-app does, including this strip and siblings. - // Synchronous for the same before-dispatch reason as the hide - // prompt's local monitor above. - if let self, event.window !== self.sizePillPanel { - self.dismissSizePill() - } - return event - } - sizePillGlobalKeyboardMonitor = NSEvent.addGlobalMonitorForEvents( - matching: [.keyDown, .keyUp, .flagsChanged] - ) { [weak self] _ in - Task { @MainActor in self?.dismissSizePill() } - } - } - - private func removeSizePillDismissObservers() { - for observer in sizePillObservers { - NotificationCenter.default.removeObserver(observer) - } - sizePillObservers.removeAll() - if let sizePillGlobalMouseMonitor { - NSEvent.removeMonitor(sizePillGlobalMouseMonitor) - self.sizePillGlobalMouseMonitor = nil - } - if let sizePillLocalMouseMonitor { - NSEvent.removeMonitor(sizePillLocalMouseMonitor) - self.sizePillLocalMouseMonitor = nil - } - if let sizePillGlobalKeyboardMonitor { - NSEvent.removeMonitor(sizePillGlobalKeyboardMonitor) - self.sizePillGlobalKeyboardMonitor = nil - } } private func dismissHidePrompt() { - FloatingPetPromptCoordinator.shared.didDismiss(owner: self) + hidePromptDismissal.uninstall() guard hidePromptPanel != nil else { return } hidePromptPanel?.orderOut(nil) hidePromptPanel = nil - removeHidePromptDismissObservers() - } - - private func installHidePromptDismissObservers() { - removeHidePromptDismissObservers() - - hidePromptObservers.append( - NotificationCenter.default.addObserver( - forName: NSApplication.didResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in self?.dismissHidePrompt() } - } - ) - - // Any click outside the pill dismisses it. Clicks landing on the pill are - // handled by the pill's own view before this monitor sees them. - hidePromptGlobalMouseMonitor = NSEvent.addGlobalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown] - ) { [weak self] _ in - Task { @MainActor in self?.dismissHidePrompt() } - } - - // In-app half of "any click away dismisses": global monitors only - // report other applications' events, so a click on a sibling strip, a - // pet panel, or the menubar icon would strand this prompt without a - // local monitor. Dismissal is synchronous (not Task-deferred) — the - // monitor fires before the event dispatches, and a deferred dismissal - // would land after a right-click's re-present and tear down the new - // prompt instead of the old one. - hidePromptLocalMouseMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown] - ) { [weak self] event in - if let self, event.window !== self.hidePromptPanel { - self.dismissHidePrompt() - } - return event - } - - // Dismiss on any keyboard input (including app switchers) so the pill never - // lingers over the UI while the user changes apps or windows. - hidePromptGlobalKeyboardMonitor = NSEvent.addGlobalMonitorForEvents( - matching: [.keyDown, .keyUp, .flagsChanged] - ) { [weak self] _ in - Task { @MainActor in self?.dismissHidePrompt() } - } - } - - private func removeHidePromptDismissObservers() { - for observer in hidePromptObservers { - NotificationCenter.default.removeObserver(observer) - } - hidePromptObservers.removeAll() - if let hidePromptGlobalMouseMonitor { - NSEvent.removeMonitor(hidePromptGlobalMouseMonitor) - self.hidePromptGlobalMouseMonitor = nil - } - if let hidePromptLocalMouseMonitor { - NSEvent.removeMonitor(hidePromptLocalMouseMonitor) - self.hidePromptLocalMouseMonitor = nil - } - if let hidePromptGlobalKeyboardMonitor { - NSEvent.removeMonitor(hidePromptGlobalKeyboardMonitor) - self.hidePromptGlobalKeyboardMonitor = nil - } } // MARK: - Layout diff --git a/apps/menubar/Tests/MenubarTests/FloatingInteractionTests.swift b/apps/menubar/Tests/MenubarTests/FloatingInteractionTests.swift index 89d4bc13..b2430b94 100644 --- a/apps/menubar/Tests/MenubarTests/FloatingInteractionTests.swift +++ b/apps/menubar/Tests/MenubarTests/FloatingInteractionTests.swift @@ -266,6 +266,188 @@ final class FloatingInteractionTests: XCTestCase { "rows must be separated by exactly rowSpacing with no overlap") } + // MARK: - Shared prompt item builder (P17.02) + // + // Table-driven against `docs/contracts/window-capability-matrix.md` §1: + // per-shape differences are expressed only as `FloatingPetPromptCapabilities` + // fields (never a shape-identity boolean), and Combined is not a distinct + // row here — it inherits whichever shape's capabilities it is routed + // through, so only Own and Minimalist rows are exercised. + + private func noopPromptHandlers() -> FloatingPetPromptHandlers { + FloatingPetPromptHandlers( + forceIdle: {}, rename: {}, syncLabel: {}, prune: {}, + modeSwitch: {}, panelSize: {}, hideAllOtherPets: {}, hideThis: {} + ) + } + + private func ownPromptCapabilities( + offersForceIdle: Bool = false, + sessionLabel: String? = nil, + hasActiveSession: Bool = false + ) -> FloatingPetPromptCapabilities { + FloatingPetPromptCapabilities( + offersForceIdle: offersForceIdle, + sessionLabel: sessionLabel, + hasActiveSession: hasActiveSession, + modeSwitchTitle: FloatingPetHidePrompt.minimalistModeTitle, + offersPanelSize: false, + hideItemTitle: FloatingPetHidePrompt.title + ) + } + + private func minimalistPromptCapabilities( + offersForceIdle: Bool = false, + sessionLabel: String? = nil, + hasActiveSession: Bool = false + ) -> FloatingPetPromptCapabilities { + FloatingPetPromptCapabilities( + offersForceIdle: offersForceIdle, + sessionLabel: sessionLabel, + hasActiveSession: hasActiveSession, + modeSwitchTitle: FloatingPetHidePrompt.petModeTitle, + offersPanelSize: true, + hideItemTitle: FloatingPetHidePrompt.panelTitle + ) + } + + func testOwnBuilderMinimalCapabilitiesProducesUnconditionalItemsOnly() { + let items = FloatingPetPromptBuilder.items( + capabilities: ownPromptCapabilities(), handlers: noopPromptHandlers()) + XCTAssertEqual(items.map(\.title), [ + FloatingPetHidePrompt.minimalistModeTitle, + FloatingPetHidePrompt.hideAllOtherPetsTitle, + FloatingPetHidePrompt.title, + ]) + } + + func testOwnBuilderOffersForceIdleFirst() { + let items = FloatingPetPromptBuilder.items( + capabilities: ownPromptCapabilities(offersForceIdle: true), handlers: noopPromptHandlers()) + XCTAssertEqual(items.map(\.title), [ + FloatingPetHidePrompt.forceIdleTitle, + FloatingPetHidePrompt.minimalistModeTitle, + FloatingPetHidePrompt.hideAllOtherPetsTitle, + FloatingPetHidePrompt.title, + ]) + } + + func testOwnBuilderPlainOriginLabelOffersRenameOnly() { + // R1.4/R1.5: a labeled-but-not-session-keyed window (plain-origin or + // combined) offers Rename but not Sync Label / Prune. + let items = FloatingPetPromptBuilder.items( + capabilities: ownPromptCapabilities(sessionLabel: "codex"), handlers: noopPromptHandlers()) + XCTAssertEqual(items.map(\.title), [ + FloatingPetHidePrompt.renameTitle, + FloatingPetHidePrompt.minimalistModeTitle, + FloatingPetHidePrompt.hideAllOtherPetsTitle, + FloatingPetHidePrompt.title, + ]) + } + + func testOwnBuilderSessionKeyedOffersRenameSyncLabelAndPrune() { + let items = FloatingPetPromptBuilder.items( + capabilities: ownPromptCapabilities(sessionLabel: "Session 3", hasActiveSession: true), + handlers: noopPromptHandlers()) + XCTAssertEqual(items.map(\.title), [ + FloatingPetHidePrompt.renameTitle, + FloatingPetHidePrompt.syncLabelTitle, + FloatingPetHidePrompt.pruneTitle, + FloatingPetHidePrompt.minimalistModeTitle, + FloatingPetHidePrompt.hideAllOtherPetsTitle, + FloatingPetHidePrompt.title, + ]) + } + + func testOwnBuilderNeverOffersPanelSize() { + // R1.7: Own has no analogous size concept. + let items = FloatingPetPromptBuilder.items( + capabilities: ownPromptCapabilities( + offersForceIdle: true, sessionLabel: "x", hasActiveSession: true), + handlers: noopPromptHandlers()) + XCTAssertFalse(items.map(\.title).contains(FloatingPetHidePrompt.panelSizeTitle)) + } + + func testMinimalistBuilderMinimalCapabilitiesIncludesUnconditionalPanelSize() { + // R1.7: Minimalist offers Panel Size… unconditionally. + let items = FloatingPetPromptBuilder.items( + capabilities: minimalistPromptCapabilities(), handlers: noopPromptHandlers()) + XCTAssertEqual(items.map(\.title), [ + FloatingPetHidePrompt.petModeTitle, + FloatingPetHidePrompt.panelSizeTitle, + FloatingPetHidePrompt.hideAllOtherPetsTitle, + FloatingPetHidePrompt.panelTitle, + ]) + } + + func testMinimalistBuilderOffersForceIdleFirst() { + let items = FloatingPetPromptBuilder.items( + capabilities: minimalistPromptCapabilities(offersForceIdle: true), + handlers: noopPromptHandlers()) + XCTAssertEqual(items.first?.title, FloatingPetHidePrompt.forceIdleTitle) + } + + func testMinimalistBuilderPlainOriginLabelOffersRenameOnly() { + // R1.4/R1.5: a labeled-but-not-session-keyed strip (plain-origin or + // combined routed to Minimalist) offers Rename but not Sync Label / + // Prune. Mirrors testOwnBuilderPlainOriginLabelOffersRenameOnly. + let items = FloatingPetPromptBuilder.items( + capabilities: minimalistPromptCapabilities(sessionLabel: "codex"), handlers: noopPromptHandlers()) + XCTAssertEqual(items.map(\.title), [ + FloatingPetHidePrompt.renameTitle, + FloatingPetHidePrompt.petModeTitle, + FloatingPetHidePrompt.panelSizeTitle, + FloatingPetHidePrompt.hideAllOtherPetsTitle, + FloatingPetHidePrompt.panelTitle, + ]) + } + + func testMinimalistBuilderSessionKeyedOffersRenameSyncLabelAndPrune() { + let items = FloatingPetPromptBuilder.items( + capabilities: minimalistPromptCapabilities(sessionLabel: "Session 2", hasActiveSession: true), + handlers: noopPromptHandlers()) + XCTAssertEqual(items.map(\.title), [ + FloatingPetHidePrompt.renameTitle, + FloatingPetHidePrompt.syncLabelTitle, + FloatingPetHidePrompt.pruneTitle, + FloatingPetHidePrompt.petModeTitle, + FloatingPetHidePrompt.panelSizeTitle, + FloatingPetHidePrompt.hideAllOtherPetsTitle, + FloatingPetHidePrompt.panelTitle, + ]) + } + + func testMinimalistBuilderUsesHidePanelTitleNotHidePet() { + // R1.9: cosmetic title-only difference; same last-item semantics. + let items = FloatingPetPromptBuilder.items( + capabilities: minimalistPromptCapabilities(), handlers: noopPromptHandlers()) + XCTAssertEqual(items.last?.title, FloatingPetHidePrompt.panelTitle) + XCTAssertFalse(items.map(\.title).contains(FloatingPetHidePrompt.title)) + } + + func testBuilderActivatingEachItemInvokesOnlyItsOwnHandlerInOrder() { + var fired: [String] = [] + let handlers = FloatingPetPromptHandlers( + forceIdle: { fired.append("forceIdle") }, + rename: { fired.append("rename") }, + syncLabel: { fired.append("syncLabel") }, + prune: { fired.append("prune") }, + modeSwitch: { fired.append("modeSwitch") }, + panelSize: { fired.append("panelSize") }, + hideAllOtherPets: { fired.append("hideAllOtherPets") }, + hideThis: { fired.append("hideThis") } + ) + let items = FloatingPetPromptBuilder.items( + capabilities: minimalistPromptCapabilities( + offersForceIdle: true, sessionLabel: "x", hasActiveSession: true), + handlers: handlers) + for item in items { item.onActivate() } + XCTAssertEqual(fired, [ + "forceIdle", "rename", "syncLabel", "prune", "modeSwitch", "panelSize", "hideAllOtherPets", + "hideThis", + ]) + } + // MARK: - Prompt coordinator (single active prompt across panels) func testCoordinatorDismissesOtherOwnerWhenPresentingElsewhere() { diff --git a/docs/product/delivery/phase-17/reviews/P17.02-pr-review.triage.json b/docs/product/delivery/phase-17/reviews/P17.02-pr-review.triage.json new file mode 100644 index 00000000..46462d45 --- /dev/null +++ b/docs/product/delivery/phase-17/reviews/P17.02-pr-review.triage.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "recordedAt": "2026-07-12T06:29:00.557Z", + "outcome": "skipped", + "note": "PR review disabled by policy", + "prBodyRefresh": { + "attemptedAt": "2026-07-12T06:29:03.584Z", + "status": "updated" + } +} diff --git a/docs/product/delivery/phase-17/reviews/P17.02-subagent-review.ledger.json b/docs/product/delivery/phase-17/reviews/P17.02-subagent-review.ledger.json new file mode 100644 index 00000000..c7d9f659 --- /dev/null +++ b/docs/product/delivery/phase-17/reviews/P17.02-subagent-review.ledger.json @@ -0,0 +1,38 @@ +{ + "ticket": "P17.02", + "invocations": [ + { + "runnerKind": "codex-cli", + "reviewedHeadSha": "03eb2af56ded44987225a010765e69ce7b6915f5", + "outcome": "clean", + "completedAt": "2026-07-12T06:25:05.284Z", + "terminatedReason": "completed", + "rawOutput": "docs/product/delivery/phase-17/reviews/P17.02-subagent-review.report.md", + "filledPrompt": "docs/product/delivery/phase-17/reviews/P17.02-subagent-review.prompt.md", + "fallbackLevel": "preferred", + "schemaVersion": 1, + "primaryAgent": "claude", + "runnerSelfReport": "completed", + "fallbackFrom": null, + "findings": [], + "probedSurfaces": [], + "patches": [] + }, + { + "runnerKind": "operator-recorder", + "reviewedHeadSha": "03eb2af56ded44987225a010765e69ce7b6915f5", + "outcome": "patched", + "completedAt": "2026-07-12T06:28:34.551Z", + "terminatedReason": "completed", + "findings": [], + "probedSurfaces": [], + "patches": [ + "dbc2d17ffab581b6b02ed837b8408963d448b983" + ], + "schemaVersion": 1, + "primaryAgent": "unknown", + "runnerSelfReport": null, + "fallbackFrom": null + } + ] +} diff --git a/docs/product/delivery/phase-17/reviews/P17.02-subagent-review.prompt.md b/docs/product/delivery/phase-17/reviews/P17.02-subagent-review.prompt.md new file mode 100644 index 00000000..b860161d --- /dev/null +++ b/docs/product/delivery/phase-17/reviews/P17.02-subagent-review.prompt.md @@ -0,0 +1,241 @@ +You are conducting an adversarial review of a code change. +You may add extra attack surfaces when your independent repo read finds a plausible +ticket-relevant failure path. +Findings outside the three finding-discipline clauses belong in **Advisory Observations** — +anything off-scope but real is welcome there. +Your job is not a general code review — it is a targeted attack on the behavior this ticket is supposed to +protect. Start from the invariants and attack surfaces below, then independently inspect +the diff and directly related implementation code for missing ticket-relevant risks. You +are looking for paths where the ticket's intended behavior breaks, not for general +improvements. + +### Ticket scope + +Outcome (docs/product/delivery/phase-17/ticket-02-shared-prompt-dismissal.md): +- Exactly one dismissal observer stack (global monitors + local monitors + resign-active) exists in the app target, owned by (or adjacent to) `FloatingPetPromptCoordinator`; no prompt-presenting surface installs its own monitors. +- Exactly one `[FloatingPetPromptItem]` builder exists, parameterized by window-shape capabilities from the approved matrix; the two `presentHidePrompt` implementations (`FloatingPetInteractionView`, `MinimalistBadgeView`) and the mode-switch pill path resolve into it. +- Views reduce to "present at this anchor with these capabilities" — no per-surface item-list construction remains (`grep -n "FloatingPetPromptItem(" apps/menubar/Sources` hits only the shared builder and tests). +- Table-driven unit tests assert the exact prompt-item titles and order the builder produces for each shape × capability row of `docs/contracts/window-capability-matrix.md` (Own with Prune offer, Minimalist with Hide retitle + Panel Size, Combined, session-keyed variants). +- Full existing suite green; behavior identical to the Phase 16 dogfood build. No matrix rows were dispositioned `bug`, so there is no drift-restoration commit in this ticket. + +Rationale (see ticket doc `## Rationale` section): the builder is parameterized by named capability +fields (`offersForceIdle`, `sessionLabel`, `hasActiveSession`, `modeSwitchTitle`, `offersPanelSize`, +`hideItemTitle`) rather than a shape-identity enum/boolean. `FloatingPetPromptDismissal` (adjacent to +`FloatingPetPromptCoordinator`) replaces three independently hand-rolled monitor stacks: Own's hide +prompt, Minimalist's hide prompt, and Minimalist's panel-size pill. `NSWindow.didResignKeyNotification` +was intentionally dropped (matrix row R1.11 — confirmed dead code, both panels are non-activating and +can never become key). + +### Files touched + +Implementation: +apps/menubar/Sources/Windows/FloatingPetPromptBuilder.swift (new) +apps/menubar/Sources/Windows/FloatingPetPromptDismissal.swift (new) +apps/menubar/Sources/Windows/FloatingPetInteractionView.swift +apps/menubar/Sources/Windows/MinimalistBadgeView.swift + +Tests: +apps/menubar/Tests/MenubarTests/FloatingInteractionTests.swift + +### Invariants to hold + +1. `FloatingPetPromptBuilder.items(capabilities:handlers:)` must produce item titles and order + identical to the pre-convergence per-shape lists for every capability combination the two call + sites can actually construct (Own: `offersForceIdle`/`sessionLabel`/`hasActiveSession` from + `isForceIdleAvailable`/`currentSessionLabel`/`hasActiveSessionBadge`; Minimalist: from + `FloatingPetHidePrompt.offersForceIdle(for:)`/`currentSessionLabel`/`currentSessionNumber != nil`). +2. No `FloatingPetPromptItem` may be constructed anywhere in the app target except inside + `FloatingPetPromptBuilder.items` (the ticket's own grep contract). +3. Every prompt-presenting surface (Own hide prompt, Minimalist hide prompt, Minimalist panel-size + pill) must dismiss exclusively via a `FloatingPetPromptDismissal` instance — no surface may install + its own `NSEvent` monitor or `NotificationCenter` observer for click-away/keyboard/resign-active + dismissal. + +### Attack surfaces to probe + +1. `FloatingPetPromptCapabilities.sessionLabel` / `hasActiveSession` independence in + `FloatingPetPromptBuilder.items`: the two gates are structurally independent (a struct can set + `hasActiveSession: true, sessionLabel: nil`, producing Sync Label/Prune without Rename). Do the two + real call sites (`FloatingPetInteractionView.presentHidePrompt`, `MinimalistBadgeView.presentHidePrompt`) + ever construct that combination in practice, and if a future caller did, would the resulting prompt + silently diverge from the matrix's documented pairing (R1.3 vs R1.4/R1.5)? +2. `MinimalistBadgeView` owns two separate `FloatingPetPromptDismissal` instances + (`hidePromptDismissal`, `sizePillDismissal`) that both register `owner: self` with the single + `FloatingPetPromptCoordinator.shared`. Trace `presentHidePrompt` → `presentPanelSizePill` (via the + `panelSize` handler) and the reverse: does calling `install()` on one instance while the other's + monitors are still live ever leave two sets of global/local/keyboard monitors active + simultaneously, or does the explicit `dismissHidePrompt()`/`dismissSizePill()` call pairing at the + top of both `presentHidePrompt` and `presentPanelSizePill` fully guarantee mutual exclusion? +3. `FloatingPetPromptDismissal.panel` is `weak var panel: NSPanel?`. Both `FloatingPetHidePromptPanel` + and `MinimalistPanelSizePillPanel` set `isReleasedWhenClosed = false`, and the owning view holds a + strong reference (`hidePromptPanel`/`sizePillPanel`) released only in `dismissHidePrompt`/ + `dismissSizePill`. Confirm the panel can never be deallocated (and `panel` go `nil`) while + `install()` is still active, and if it somehow could, what the local-mouse-monitor's + `event.window !== self.panel` check does with a `nil` `panel` (does every event start dismissing?). +4. `FloatingPetPromptDismissal.install` calls `FloatingPetPromptCoordinator.shared.willPresent(owner:dismiss:)` + before installing monitors. `willPresent` synchronously invokes the *previous* active owner's + dismiss closure if `activeOwner !== owner`. Given both hide-prompt and panel-size-pill call sites + already call `dismissHidePrompt()`/`dismissSizePill()` immediately before building the new prompt, + verify there is no ordering path where a stale `dismissAction` from a not-yet-uninstalled + `FloatingPetPromptDismissal` instance fires re-entrantly during a fresh `install()` and tears down + the panel that install() just created (race between "old owner's dismiss fires" and "new panel/ + monitors get assigned"). +5. `FloatingPetPromptHandlers` closures captured with `[weak self]` are now built once per + `presentHidePrompt`/`presentPanelSizePill` invocation and stored inside `FloatingPetPromptItem` + values owned by the panel (not the presenting view). Confirm no closure captures `self` strongly + anywhere in the new builder call sites (which would extend the view's lifetime unexpectedly via the + panel), and that dismissing via `dismissHidePrompt()`/`dismissSizePill()` before invoking the real + handler (as every handler closure does) can't leave `hidePromptPanel`/`sizePillPanel` referencing a + panel that was already dismissed by a coordinator hand-off mid-click. +6. `docs/contracts/window-capability-matrix.md` §1 table-driven coverage: do the ten new + `FloatingInteractionTests` cases in `FloatingInteractionTests.swift` actually exercise every + `disposition: intentional` capability combination named in matrix rows R1.1–R1.10 (Own vs + Minimalist, force-idle on/off, plain-origin-labeled vs session-keyed, panel-size present/absent, + mode-switch/hide titles), or is any combination asserted only implicitly / not at all? + +#### Diff-derived attack surfaces + +1. **Output stability across schema-version drift** — N/A, no persisted artifact or on-disk format + changed by this diff; confirm no `FloatingPetPromptItem`/capabilities value crosses a persistence + boundary anywhere in the touched files. +2. **CLI flag/arg symmetry** — N/A, no CLI surface in this diff. +3. **Error-class breadth in `catch` blocks** — probe: does `FloatingPetPromptDismissal` or the two + updated views introduce or remove any `catch`/`try` around the monitor-install paths, and if so, + does it distinguish recoverable failure from a hard error? +4. **Defensive layering at module boundaries** — probe: `FloatingPetPromptBuilder.items` is a new + cross-file boundary (`FloatingPetInteractionView`/`MinimalistBadgeView` → `FloatingPetPromptBuilder`). + Does the builder revalidate/guard its `capabilities`/`handlers` inputs, or does it trust the caller + completely (see attack surface 1 above for the concrete gap)? +5. **Cross-file atomicity windows** — probe: `promptDismissal.install(owner:panel:dismiss:)` performs + coordinator registration, then four separate monitor installs, in sequence with no rollback if a + later step in the sequence somehow throws or is interrupted. Is there an observable + partially-installed window (e.g. coordinator registered but keyboard monitor missing) an event + arriving mid-`install()` could hit? +6. **Test-contract strength** — probe: do the new builder tests assert the exact ordered array + (`items.map(\.title)`) rather than just `contains`/count, and does + `testBuilderActivatingEachItemInvokesOnlyItsOwnHandlerInOrder` sufficiently cover the "no + cross-wiring" contract (activating item N never fires a different handler)? +7. **Doc-vs-code drift in the ticket Rationale** — read `docs/product/delivery/phase-17/ticket-02-shared-prompt-dismissal.md`'s `## Rationale` section against the actual diff: does the Rationale's + description of the two convergence seams, the R1.11 dead-code drop, and "no drift-restoration + commit" match what the diff actually does? + +### Diff context + +Two new files: `FloatingPetPromptBuilder.swift` defines `FloatingPetPromptCapabilities` (named +capability fields), `FloatingPetPromptHandlers` (one closure per possible item), and +`FloatingPetPromptBuilder.items(capabilities:handlers:) -> [FloatingPetPromptItem]`, a pure function +appending items conditionally in a fixed order (force-idle → rename → sync-label+prune → +mode-switch → panel-size → hide-all-others → hide-this). `FloatingPetPromptDismissal.swift` defines a +class with `install(owner:panel:dismiss:)` (registers with `FloatingPetPromptCoordinator.shared`, then +installs one `didResignActiveNotification` observer, one global mouse monitor, one local mouse monitor +gated on `event.window !== panel`, one global keyboard monitor) and `uninstall()` (tears all of that +down and calls `FloatingPetPromptCoordinator.shared.didDismiss(owner:)`). + +`FloatingPetInteractionView.presentHidePrompt` and `MinimalistBadgeView.presentHidePrompt` were rewritten +to build a `FloatingPetPromptCapabilities` value from their existing per-shape state, wrap each real +handler in a `{ self?.dismissHidePrompt(); self?.?() }` closure inside +`FloatingPetPromptHandlers`, call `FloatingPetPromptBuilder.items(...)`, then call +`promptDismissal.install(owner: self, panel: panel) { self?.dismissHidePrompt() }` instead of the old +per-view `installHidePromptDismissObservers()`. `MinimalistBadgeView.presentPanelSizePill` was similarly +rewritten to use a second `FloatingPetPromptDismissal` instance (`sizePillDismissal`). All four old +per-view `install*DismissObservers`/`remove*DismissObservers` methods and their monitor/observer-array +properties were deleted; `dismissHidePrompt`/`dismissSizePill` now just call `.uninstall()`. + +--- + +### Your directives + +**Scope:** You conduct an adversarial review of the implementation diff and directly +related code paths named in the attack surfaces. Do not expand scope beyond what the +ticket outcome describes. + +**Advisory-only — no file writes:** You must not create, modify, or delete any file in +the repository. Your entire deliverable is findings prose in the required output format +below. The primary execution agent owns all patches. + +**Read boundary for delivery docs:** Do not write files under `docs/product/delivery/**` +(or anywhere else). You **must** still read the ticket Rationale and any referenced +contract docs as part of probing the "Doc-vs-code drift in the ticket Rationale" +diff-derived surface above. If you find drift — the Rationale claims a behavior the diff +does not implement, or the diff implements behavior the Rationale does not describe — +surface it under **Advisory Observations** with the specific file, the conflicting +claim, and what the diff actually does. The primary agent decides whether to patch docs +or code. + +**Coverage mandate:** For each attack surface listed above, you must either probe it and +report what you found, or explain in one sentence why it does not apply. "I didn't check" +is not acceptable. A clean result on a surface you probed is a valid and valuable outcome. +Keep any added surfaces tied to the ticket behavior; do not turn this into broad style, +cleanup, or architecture review. + +**Finding discipline:** Report a finding when one of the following holds: + +1. The code breaks a stated invariant. +2. The code introduces a correctness gap you can demonstrate. +3. **Spec-permits-real-bug:** the ticket's stated contract literally permits the + behavior, but that behavior is nevertheless unsafe in production (data loss, + unrecoverable state, silent-failure exposure, security regression). Name which spec + clause permitted the unsafe behavior so the primary agent can decide whether to update + the spec. + +Do not report style, preference, or hypothetical future requirements as blocking findings. +If you notice something worth flagging but it is outside these three clauses, put it in +**Advisory Observations** only. + +**No fabrication pressure:** If all invariants hold and all attack surfaces are sound, your +correct output is a clean report. Do not invent findings to justify the review step. + +--- + +### Required output format + +After completing your review, report in this exact structure (prose only — no file edits). +The structure is canonical and machine-parsed by downstream tooling — see +`docs/template/delivery/subagent-review-report-template.md` for the full +rules. Two rules that catch the most common drift bugs: + +- Use exactly these five top-level section headings, in this order: + `Invariant results`, `Surface results`, `Actionable findings`, + `Advisory Observations`, `Runner termination`. +- **Do not use `---` horizontal rules anywhere in the report.** A `---` + inside the `Advisory Observations` body breaks the all-bullets parser + check, causes fallback to paragraph mode, and preserves `- ` prefixes + on every observation key — creating verbatim-match churn in the + downstream dispositions file and forcing `---` itself to be triaged as + a fake observation. Just omit `---`. +- **`Runner termination` must be the section heading**, not `**runnerStatus:** + \`completed\`` or any other inline key-value variant. Write it as the bold + span `**Runner termination**` on its own line, then `runnerStatus:` and + `terminatedReason:` as plain-text lines below. Any other format leaves the + termination block inside the `Advisory Observations` body. +- Inside `Advisory Observations`, write **one observation per bullet or one + observation per paragraph**. Do NOT use a bold span (`**A1 — Title**`) on a + line by itself before the observation body — that visually mimics a + section heading and splits one labeled observation into two parsed + observations. + +**Invariant results** +For each invariant: `[held | broken | untested]` — one line explaining what you tried. + +**Surface results** +For each attack surface (both ticket-spec-derived and the seven diff-derived classes): +`[probed | N/A — | blocked — missing-input]` +If probed: what you tried and what you found (one to three sentences). + +**Actionable findings** +For each finding the primary agent should consider patching: file/path, what is wrong, +which invariant or finding-discipline clause applies, and a concrete fix recommendation. +If none: "None." + +**Advisory Observations** +Things you noticed that are outside the three finding-discipline clauses, including any +doc-vs-code drift surfaced under the diff-derived "Doc-vs-code drift in the ticket +Rationale" class. One bullet or one paragraph per observation. If none: "None." + +**Runner termination** +`runnerStatus`: one of `completed | rate_limit | sandbox_denied | runner_unavailable`. +`terminatedReason`: one short sentence explaining why this status was reported. + +`completed` means you finished the review per this template. The other three values are +honest failure modes — the CLI refuses to record `outcome: clean` for any non-`completed` +`terminatedReason`, so do not claim `completed` if you stopped early. diff --git a/docs/product/delivery/phase-17/reviews/P17.02-subagent-review.report.md b/docs/product/delivery/phase-17/reviews/P17.02-subagent-review.report.md new file mode 100644 index 00000000..92e668dd --- /dev/null +++ b/docs/product/delivery/phase-17/reviews/P17.02-subagent-review.report.md @@ -0,0 +1,38 @@ +**Invariant results** + +1. [held] Diffed both real call sites against their pre-convergence implementations; capability gates, titles, ordering, and handlers remain equivalent for all constructible Own and Minimalist states. +2. [held] `rg 'FloatingPetPromptItem\(' apps/menubar/Sources` finds constructions only inside `FloatingPetPromptBuilder.items`. +3. [held] All three prompt-presenting surfaces delegate monitor installation and removal to `FloatingPetPromptDismissal`; no per-surface prompt monitor methods remain. + +**Surface results** + +- Capability independence: [probed]. Both production call sites derive `sessionLabel` and `hasActiveSession` independently, but their upstream session-label resolution supplies a label for session-keyed windows. The builder intentionally permits `hasActiveSession: true, sessionLabel: nil`, yielding Sync Label and Prune without Rename; this is not currently constructible through the reviewed production paths, though it remains an unvalidated future-caller state. +- Dual Minimalist dismissal instances: [probed]. Hide-prompt presentation dismisses both existing surfaces; the panel-size handler dismisses the hide prompt before presenting the pill, and `presentPanelSizePill` dismisses an existing pill. No normal transition leaves both monitor stacks installed. +- Weak panel lifetime: [probed]. Each active panel is strongly retained by its presenting view until its matching dismissal method uninstalls monitors and clears the panel. If `panel` unexpectedly became nil while the dismissal object remained alive, the local-monitor identity comparison would treat every mouse event as outside and invoke dismissal. +- Coordinator reentrancy: [probed]. Each presenter stores its new panel before calling `install`, but prior same-surface dismissal is synchronous. Cross-owner `willPresent` invokes only the previous owner’s closure; same-owner Minimalist transitions explicitly uninstall the prior surface first, so no stale closure tears down the newly created panel. +- Handler capture and mid-click dismissal: [probed]. Every builder call-site handler that references a view uses `[weak self]`; the Own-only unused `panelSize` handler is empty. Each activation dismisses and releases its panel before invoking the real action, matching pre-convergence behavior without retaining the view. +- Matrix table coverage: [probed]. The tests cover base, force-idle, plain-labeled Own, session-keyed Own and Minimalist, shape-specific mode/hide titles, panel-size presence, and handler wiring. Coverage is not fully table-driven or exact-array-based for every claimed row; details are in Advisory Observations. +- Output stability across schema-version drift: [N/A — no prompt item or capability value crosses a persistence boundary in the touched code]. +- CLI flag/arg symmetry: [N/A — no CLI surface changed]. +- Error-class breadth in catch blocks: [N/A — the new builder and dismissal paths introduce no `try` or `catch`; existing unrelated `try?` calls in prune-confirmation code are unchanged]. +- Defensive layering at module boundaries: [probed]. The builder trusts named capabilities and does not reject `hasActiveSession: true, sessionLabel: nil`; production callers preserve the documented pairing, so this is not a demonstrated current bug. +- Cross-file atomicity windows: [probed]. Coordinator registration and AppKit monitor installation are sequential, synchronous, nonthrowing main-thread operations with no event-loop yield. There is no observable mid-install event window or throwable partial-install path. +- Test-contract strength: [probed]. Most representative builder tests compare exact ordered title arrays, and the activation test exercises every enabled item and confirms handler order. Two shape checks use only `first`, `contains`, or `last`, weakening the ticket’s exact-array contract. +- Doc-vs-code drift in the ticket Rationale: [probed]. The Rationale accurately describes both convergence seams, the R1.11 dead-observer removal, named capability fields, all three migrated surfaces, and the absence of a drift-restoration commit. +- Dismissal lifecycle during owner deinitialization: [probed]. Swift weak references are already nil inside the owner’s `deinit`; therefore the dismissal object’s owner guard skips teardown when an active presenting view is destroyed. See Actionable findings. +- Full-suite verification: [probed]. `bun run mac:test` passed all 1,059 tests with zero failures. + +**Actionable findings** + +- `apps/menubar/Sources/Windows/FloatingPetPromptDismissal.swift:80` — `uninstall()` begins with `guard let owner else { return }`. Both presenting views call it from their `deinit`, but Swift zeroes weak references before the owner’s deinitializer runs, so `self.owner` is nil and teardown returns without removing the notification observer or three AppKit event monitors. The dismissal object then deallocates, leaving globally registered monitor closures that weakly reference a dead object; repeated view destruction accumulates inert monitor registrations for the app’s lifetime. This is a demonstrated correctness gap under finding-discipline clause 2. Fix by making coordinator cleanup conditional on a live owner while always removing monitor tokens and clearing fields; also add `deinit { uninstall() }` to `FloatingPetPromptDismissal` as defensive ownership-local cleanup. + +**Advisory Observations** + +- The ten new builder tests do not literally satisfy the ticket’s “table-driven exact titles and order for each shape × capability row” claim. `testMinimalistBuilderOffersForceIdleFirst` checks only the first title, `testOwnBuilderNeverOffersPanelSize` checks only absence, and `testMinimalistBuilderUsesHidePanelTitleNotHidePet` checks only the last/absent title. There is also no Minimalist plain-origin/Combined labeled-but-not-session-keyed exact-array case. Consider replacing these with a table of complete capability rows and expected ordered arrays. + +- `FloatingPetPromptBuilder` intentionally allows `hasActiveSession: true` with `sessionLabel: nil`, producing Sync Label and Prune without Rename. Current production state resolution does not appear to construct that combination, so it is not a blocking finding; a precondition, normalized effective label gate, or explicit unit case would make the boundary contract clearer for future callers. + +**Runner termination** + +runnerStatus: completed +terminatedReason: Completed every required surface probe, inspected the directly related implementation and documents, and ran the full test suite. diff --git a/docs/product/delivery/phase-17/ticket-02-shared-prompt-dismissal.md b/docs/product/delivery/phase-17/ticket-02-shared-prompt-dismissal.md index 66ad03fd..50c57bdd 100644 --- a/docs/product/delivery/phase-17/ticket-02-shared-prompt-dismissal.md +++ b/docs/product/delivery/phase-17/ticket-02-shared-prompt-dismissal.md @@ -43,8 +43,37 @@ Red: required > Append here (do not edit above) when behavior or trade-offs change during implementation. -Red first: [what test failed first] -Why this path: [why this implementation was the smallest acceptable] -Alternative considered: [one rejected alternative and why] -Deferred: [what was intentionally left out of this ticket] -Contract note: record any deviation from the ticket metadata contract here, including missing/incorrect `Type:` or non-compliant `Scope:` fields, and why it happened. +Red first: `FloatingInteractionTests` table-driven builder tests referencing +`FloatingPetPromptBuilder`/`FloatingPetPromptCapabilities`/ +`FloatingPetPromptHandlers` — none of those types existed yet, so the suite +failed to compile. + +Why this path: two convergence seams, not one — (1) a pure +`FloatingPetPromptBuilder.items(capabilities:handlers:)` producing +`[FloatingPetPromptItem]`, parameterized by a `FloatingPetPromptCapabilities` +struct whose fields map 1:1 to named matrix rows (`offersForceIdle`, +`sessionLabel`, `hasActiveSession`, `modeSwitchTitle`, `offersPanelSize`, +`hideItemTitle`) rather than a shape-identity boolean; and (2) a +`FloatingPetPromptDismissal` class (adjacent to `FloatingPetPromptCoordinator` +in `Windows/`) owning the global-mouse/local-mouse/global-keyboard/ +resign-active monitor stack, reused by all three surfaces that previously +hand-rolled it: `FloatingPetInteractionView`'s hide prompt, `MinimalistBadgeView`'s +hide prompt, and `MinimalistBadgeView`'s panel-size pill — "the bug fixed +three times" the ticket's Review Focus calls out. `NSWindow.didResignKeyNotification` +(R1.11, confirmed dead code — both panels are non-activating and never become +key) was dropped from the shared stack rather than carried forward. + +Alternative considered: a `shape: .own | .minimalist` enum parameter on the +builder instead of named capability fields. Rejected — it re-encodes shape +identity as a boolean/enum, which the ticket's Review Focus explicitly +prohibits ("no boolean soup that re-encodes shape identity"), and it would +make a future capability that varies independently of shape (none exist +today, but the matrix format anticipates them) awkward to express. + +Deferred: nothing — all 33 matrix rows for §1 (prompt items) were +dispositioned `intentional` with none `bug`, so there is no drift-restoration +commit in this ticket. `MinimalistPanelController`/`FloatingPetPanelController` +chrome-flock convergence stays out of scope per the ticket order (P17.03). + +Contract note: none — `Type: refactor`, `Scope: menubar`, `Red: required` all +matched the actual work.