From 264d79ac08e75a3b52fab07c75bc6192a17e03d2 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 2 Jun 2026 09:21:16 +0100 Subject: [PATCH 1/4] fix(compact): blur corner radius, draggable full panel, expand on post-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Match the NSVisualEffectView corner radius to the SwiftUI Capsule in pill mode (size.height / 2) so the rect corners don't poke past the capsule curve and show as dark squares. - Allow dragging the expanded panel by its window background — the snap monitor stays gated on compact-not-expanded, so the full panel free-drags without corner snapping. - When a post-update changelog fires while in the pill, auto-expand to the full panel so the release notes render in the proper frame instead of getting clipped into the 320x56 widget. Co-Authored-By: Claude Opus 4.7 --- panel/Panel.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/panel/Panel.swift b/panel/Panel.swift index efeca8f..4ee2259 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -489,6 +489,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, UNUserNotificationCenterDelegate { private var panel: FloatingPanel! + // Held so applyCompactLayout can swap its corner radius between the + // full-panel value and the pill's capsule radius — otherwise the + // smaller-radius rect corners poke past the SwiftUI capsule curve. + private weak var contentBlurView: NSVisualEffectView? private var hotkey: Hotkey? private let store = EventStore() private let sessions = SessionStore() @@ -563,6 +567,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, blur.wantsLayer = true blur.layer?.cornerRadius = 12 blur.layer?.masksToBounds = true + contentBlurView = blur let host = NSHostingView(rootView: PanelContentView( store: store, sessions: sessions, nav: nav, phrases: phrases, @@ -782,6 +787,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, nav.postUpdateVersion = result.version.isEmpty ? "?" : result.version nav.postUpdateNotes = nil nav.mode = .postUpdate + // Expand out of the pill so the changelog renders in the full + // panel rather than getting clipped into the widget frame. + if nav.compactMode, !nav.compactExpanded { + nav.compactExpanded = true + } // Auto-open the panel so the user immediately sees the // "what shipped" view rather than discovering it via hotkey. DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in @@ -843,6 +853,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, ignoringProgrammaticMove = true panel.setFrame(frame, display: true, animate: false) ignoringProgrammaticMove = false + // Match the SwiftUI Capsule's corner radius (half the pill + // height) so the blur backing doesn't poke out beyond the + // capsule curve and show as dark squares in the corners. + contentBlurView?.layer?.cornerRadius = size.height / 2 panel.level = .statusBar panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle] @@ -862,10 +876,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // Restore the original layout-protecting minimum so SwiftUI's // full panel content (Settings, Sessions, etc.) has room. panel.contentMinSize = NSSize(width: 560, height: 260) + contentBlurView?.layer?.cornerRadius = 12 panel.level = .floating panel.collectionBehavior = [] panel.hasShadow = true - panel.isMovableByWindowBackground = false + panel.isMovableByWindowBackground = true positionPanel() if nav.compactExpanded { NSApp.activate(ignoringOtherApps: true) From fb1bad641c4ff6053b5a9b1c823ede0d71f08331 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 2 Jun 2026 11:13:29 +0100 Subject: [PATCH 2/4] fix(compact): banner-window veto, quota toggle, mascot hover, menu hotkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reopen + button veto: pill click/tap expansions now bail if a banner posted within the last 2s. Banners share the pill's screen corner, so a click intended for the banner could leak through to the SwiftUI expand button or the double-tap gesture and pop the panel open. applicationShouldHandleReopen also short-circuits in pill mode so unsolicited Dock/Spotlight/AppleEvent activations don't expand it. - Per-mascot hover reactions: robot antenna flicks up, cat ears + head lift with a left-eye wink, eye pupil dilates and darts faster, ghost pops up + scales + opens its mouth. Earlier hover-opacity dim was removed in favor of these. - Quota count-down toggle (Settings → Usage → "Show remaining"). Pill center readout and Usage tier rows flip "30" → "70% left"; ring fill + color stay urgency-based so visual semantics are preserved. - Menu-bar hotkey label reads nav.hotkeyDisplay so newly recorded combos appear immediately. Used to read raw config with a different default than Settings, which never reflected user changes. Co-Authored-By: Claude Opus 4.7 --- panel/CompactView.swift | 110 +++++++++++++++++++++++++++++++-------- panel/MenuBar.swift | 7 ++- panel/Panel.swift | 31 ++++++++++- panel/PanelNav.swift | 35 ++++++++----- panel/SessionUsage.swift | 10 +++- panel/Settings.swift | 15 +++--- 6 files changed, 160 insertions(+), 48 deletions(-) diff --git a/panel/CompactView.swift b/panel/CompactView.swift index 5e39c2f..499a12f 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -19,6 +19,7 @@ struct CompactView: View { @State private var rippleScale: CGFloat = 0.3 @State private var rippleOpacity: Double = 0 + @State private var isHovering: Bool = false private static let glowColor = Color(red: 0.4, green: 0.85, blue: 1.0) private static let recentEventWindow: TimeInterval = 5 * 60 @@ -39,6 +40,14 @@ struct CompactView: View { .contentShape(Capsule()) .onTapGesture(count: 2) { onExitCompact() } .onChange(of: store.events.first?.id) { _ in triggerRipple() } + // Hover state drives per-mascot reactions (robot antenna flick, cat + // wink + ear twitch, eye pupil dilate-and-dart, ghost pop-and-yawn). + // Gated on pill mode — the expanded panel doesn't need playfulness. + .onHover { isHovering = $0 } + } + + private var mascotHovered: Bool { + isHovering && !nav.compactExpanded } // Soft outward wave on event arrival. Capsule scales from 0.3 → 1.15 @@ -79,7 +88,8 @@ struct CompactView: View { hasSeven: nav.quota?.sevenDay != nil, polling: nav.quotaSyncing, anyBusy: anyBusy, - paused: nav.compactDragging + paused: nav.compactDragging, + showRemaining: nav.quotaShowRemaining ) .frame(width: 42, height: 42) } @@ -97,7 +107,7 @@ struct CompactView: View { @ViewBuilder private var headline: some View { HStack(spacing: 6) { - BotMascot(state: botState, kind: nav.mascot, paused: nav.compactDragging) + BotMascot(state: botState, kind: nav.mascot, paused: nav.compactDragging, hovered: mascotHovered) .frame(width: 26, height: 24) headlineText } @@ -351,6 +361,7 @@ private struct QuotaGauge: View { let polling: Bool let anyBusy: Bool let paused: Bool + let showRemaining: Bool private static let cyan = Color(red: 0.30, green: 0.92, blue: 1.0) private static let outerLineWidth: CGFloat = 4.0 @@ -443,7 +454,11 @@ private struct QuotaGauge: View { } private var centerReadout: some View { - Text(hasFive ? "\(Int(fivePct.rounded()))" : "—") + // Ring still fills with "used" so the urgency-colored gradient + // (green→red as it grows) keeps its meaning. Only the number in + // the middle flips: 30% used ↔ 70% (remaining). + let pct = showRemaining ? max(0, 100 - fivePct) : fivePct + return Text(hasFive ? "\(Int(pct.rounded()))" : "—") .font(.system(size: 14, weight: .semibold).monospacedDigit()) .foregroundStyle(.primary) } @@ -474,13 +489,14 @@ private struct BotMascot: View { let state: BotState let kind: MascotKind let paused: Bool + let hovered: Bool var body: some View { switch kind { - case .robot: RobotMascot(state: state, paused: paused) - case .cat: CatMascot(state: state, paused: paused) - case .eye: EyeMascot(state: state, paused: paused) - case .ghost: GhostMascot(state: state, paused: paused) + case .robot: RobotMascot(state: state, paused: paused, hovered: hovered) + case .cat: CatMascot(state: state, paused: paused, hovered: hovered) + case .eye: EyeMascot(state: state, paused: paused, hovered: hovered) + case .ghost: GhostMascot(state: state, paused: paused, hovered: hovered) } } } @@ -489,6 +505,7 @@ private struct RobotMascot: View { let state: BotState let paused: Bool + let hovered: Bool private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) private static let outline = Color.secondary.opacity(0.7) @@ -521,9 +538,11 @@ private struct RobotMascot: View { VStack(spacing: 0) { Circle().fill(antennaColor.opacity(0.9)) .frame(width: 3.5, height: 3.5) + .scaleEffect(hovered ? 1.5 : 1.0) Rectangle().fill(Self.outline).frame(width: 1, height: 3) } - .offset(y: -8.5) + .offset(y: hovered ? -10.5 : -8.5) + .animation(.spring(response: 0.25, dampingFraction: 0.5), value: hovered) } private var staticEyes: some View { @@ -560,11 +579,13 @@ private struct RobotMascot: View { Circle() .fill(antennaColor.opacity(0.6 + 0.4 * blink)) .frame(width: 3.5, height: 3.5) + .scaleEffect(hovered ? 1.5 : 1.0) Rectangle() .fill(Self.outline) .frame(width: 1, height: 3) } - .offset(y: -8.5) + .offset(y: hovered ? -10.5 : -8.5) + .animation(.spring(response: 0.25, dampingFraction: 0.5), value: hovered) } private var antennaColor: Color { @@ -658,6 +679,7 @@ private struct CatMascot: View { let state: BotState let paused: Bool + let hovered: Bool private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) private static let outline = Color.secondary.opacity(0.7) @@ -675,6 +697,9 @@ private struct CatMascot: View { } } + // Ears perked = head + ear-hints lift slightly while hovered. + private var earLift: CGFloat { hovered ? -1.5 : 0 } + private var head: some View { ZStack { // Face + ears combined as a single path so the ears feel @@ -683,16 +708,19 @@ private struct CatMascot: View { .fill(headFill) .overlay(CatHeadShape().stroke(Self.outline, lineWidth: 1.2)) .frame(width: 20, height: 18) - .offset(y: 1) + .offset(y: 1 + earLift) + .animation(.spring(response: 0.28, dampingFraction: 0.6), value: hovered) // Inner ear hints (small triangles inside each ear) Triangle() .fill(Self.outline.opacity(0.4)) .frame(width: 2, height: 2.2) - .offset(x: -5.5, y: -5.5) + .offset(x: -5.5, y: -5.5 + earLift) + .animation(.spring(response: 0.28, dampingFraction: 0.6), value: hovered) Triangle() .fill(Self.outline.opacity(0.4)) .frame(width: 2, height: 2.2) - .offset(x: 5.5, y: -5.5) + .offset(x: 5.5, y: -5.5 + earLift) + .animation(.spring(response: 0.28, dampingFraction: 0.6), value: hovered) // Nose (tiny triangle) Triangle() .rotation(.degrees(180)) @@ -715,14 +743,24 @@ private struct CatMascot: View { let cycle = t.truncatingRemainder(dividingBy: 3.5) let blinking = state != .alert && cycle < 0.15 let scaleY: CGFloat = blinking ? 0.15 : 1.0 - return HStack(spacing: 5) { eyeShape; eyeShape } - .scaleEffect(x: 1, y: scaleY) - .animation(.easeInOut(duration: 0.08), value: blinking) - .offset(y: -0.5) + // While hovered, scale the left eye flat → looks like a wink. + let leftScale: CGFloat = hovered ? 0.15 : scaleY + return HStack(spacing: 5) { + eyeShape.scaleEffect(x: 1, y: leftScale) + eyeShape.scaleEffect(x: 1, y: scaleY) + } + .animation(.easeInOut(duration: 0.18), value: hovered) + .animation(.easeInOut(duration: 0.08), value: blinking) + .offset(y: -0.5) } private var staticEyes: some View { - HStack(spacing: 5) { eyeShape; eyeShape }.offset(y: -0.5) + HStack(spacing: 5) { + eyeShape.scaleEffect(x: 1, y: hovered ? 0.15 : 1) + eyeShape + } + .animation(.easeInOut(duration: 0.18), value: hovered) + .offset(y: -0.5) } @ViewBuilder @@ -843,6 +881,7 @@ private struct EyeMascot: View { let state: BotState let paused: Bool + let hovered: Bool private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) private static let outline = Color.secondary.opacity(0.7) @@ -884,15 +923,27 @@ private struct EyeMascot: View { } private func pupil(at t: TimeInterval) -> some View { - let scan = state == .watching ? sin(t * 1.5) * 3 : 0 + // Hovered: faster, wider dart — the eye is "tracking" the cursor. + // Otherwise: the slow watching scan, or static. + let scan: Double + if hovered { scan = sin(t * 6.0) * 4.5 } + else if state == .watching { scan = sin(t * 1.5) * 3 } + else { scan = 0 } return Circle() .fill(pupilColor) - .frame(width: pupilSize, height: pupilSize) + .frame(width: pupilSize + (hovered ? 2.0 : 0), + height: pupilSize + (hovered ? 2.0 : 0)) .offset(x: scan, y: 1) + .animation(.easeInOut(duration: 0.18), value: hovered) } private var staticPupil: some View { - Circle().fill(pupilColor).frame(width: pupilSize, height: pupilSize).offset(y: 1) + Circle() + .fill(pupilColor) + .frame(width: pupilSize + (hovered ? 2.0 : 0), + height: pupilSize + (hovered ? 2.0 : 0)) + .offset(y: 1) + .animation(.easeInOut(duration: 0.18), value: hovered) } private var pupilSize: CGFloat { @@ -919,6 +970,7 @@ private struct GhostMascot: View { let state: BotState let paused: Bool + let hovered: Bool private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) private static let outline = Color.secondary.opacity(0.7) @@ -927,13 +979,18 @@ private struct GhostMascot: View { if paused { ZStack { body_; staticEyes; mouth } .frame(maxWidth: .infinity, maxHeight: .infinity) + .offset(y: hovered ? -4 : 0) + .scaleEffect(hovered ? 1.08 : 1.0) + .animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered) } else { TimelineView(.animation(minimumInterval: 0.05)) { tl in let t = tl.date.timeIntervalSinceReferenceDate let bob = sin(t * 1.3) * 1.0 ZStack { body_; eyes(at: t); mouth } .frame(maxWidth: .infinity, maxHeight: .infinity) - .offset(y: bob) + .offset(y: bob + (hovered ? -4 : 0)) + .scaleEffect(hovered ? 1.08 : 1.0) + .animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered) } } } @@ -1006,7 +1063,16 @@ private struct GhostMascot: View { .frame(width: 2.4, height: 3.5) .offset(y: 4) default: - EmptyView() + if hovered { + // Yawning "boo" while hovered — open oval mouth. + Capsule() + .fill(Self.outline.opacity(0.85)) + .frame(width: 3, height: 4) + .offset(y: 4) + .transition(.scale.combined(with: .opacity)) + } else { + EmptyView() + } } } } diff --git a/panel/MenuBar.swift b/panel/MenuBar.swift index fca5ba9..98fda3f 100644 --- a/panel/MenuBar.swift +++ b/panel/MenuBar.swift @@ -113,7 +113,12 @@ final class MenuBarController: NSObject, NSMenuDelegate { let banner = ConfigFile.bool(config, "STACKNUDGE_BANNER", default: true) let voice = ConfigFile.bool(config, "STACKNUDGE_VOICE", default: false) let mute = ConfigFile.bool(config, "STACKNUDGE_MUTE_WHEN_FOCUSED", default: true) - let hotkey = config["STACKNUDGE_PANEL_HOTKEY"] ?? "cmd+shift+n" + // Source of truth is nav.hotkeyDisplay so Settings recordings and + // unset-default fallback agree. Reading raw config here used to + // show a different default ("cmd+shift+n") than what Settings + // displayed ("cmd+opt+n") and never reflected new combos until + // the user manually edited the config file. + let hotkey = panelController?.nav.hotkeyDisplay ?? "cmd+opt+n" let status = NSMenuItem(title: "Hotkey · \(hotkey)", action: nil, keyEquivalent: "") status.isEnabled = false diff --git a/panel/Panel.swift b/panel/Panel.swift index 4ee2259..b905cb5 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -496,7 +496,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private var hotkey: Hotkey? private let store = EventStore() private let sessions = SessionStore() - private let nav = PanelNav() + let nav = PanelNav() private let phrases = PhrasesViewModel() private var listener: EventListener? private var menuBar: MenuBarController? @@ -898,12 +898,27 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, applyCompactLayout() } + // Wraps expandFromCompact for entry points that come from mouse/tap + // events on the pill itself — the SwiftUI expand button and the + // double-tap gesture. Notification banners sit at the same screen + // corner as the pill, so a click meant for the banner can leak + // through and activate these. Hotkey + keyboard paths (M, global + // toggle) bypass this veto because keyboard input is unambiguous. + private func expandFromCompactUserGesture() { + if Date().timeIntervalSince(lastEventArrivalAt) < 2 { return } + expandFromCompact() + } + // Compact mode is always on, so what used to be "exit compact" // (double-click, expand button) now means "expand to full panel // temporarily." Calling expandFromCompact lets the existing wiring // and Settings actions keep working without renaming. private func exitCompactMode() { - expandFromCompact() + // exitCompactMode is wired into the SwiftUI expand button and the + // pill's double-tap gesture — both ambiguous in the banner window. + // The hotkey/M paths call expandFromCompact directly and skip this + // veto, so a deliberate keystroke still expands instantly. + expandFromCompactUserGesture() } // Called from the "M" keystroke in Events/Sessions/Usage tabs to @@ -1731,6 +1746,18 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in guard let self else { return } + // Compact mode: the pill is the resting state and is always + // visible. Unsolicited reopen events (Dock click, Spotlight, + // AppleEvent activations from other apps, banner side-effects + // not caught by the bannerActivationUntil veto) used to expand + // the pill into the full panel — surfacing as "panel randomly + // appears for a few seconds before collapsing." Keep the pill + // at rest; just raise it in case another full-screen app + // covered it. + if self.nav.compactMode, !self.nav.compactExpanded { + self.panel.orderFront(nil) + return + } if Date() < self.bannerActivationUntil { return } // Suppress if a banner just posted — macOS sometimes routes a // reopen through us as a side effect of the notification arriving, diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 2a4ca1d..a9d8db2 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -164,6 +164,7 @@ final class PanelNav: ObservableObject { // all tiers — banner fires once per period when any tier reaches it. @Published var quotaTrackingEnabled: Bool = true @Published var quotaAlertsEnabled: Bool = true + @Published var quotaShowRemaining: Bool = false @Published var quotaAlertThreshold: Int = 80 // Background poll interval in minutes when the panel is hidden. // Visible-panel polling is fixed at 60s (see Panel.swift). Cycle @@ -291,13 +292,14 @@ final class PanelNav: ObservableObject { // 15 Alert threshold cycle // 16 Poll frequency cycle // 17 Context alert at cycle (per-session token thresholds) - // 18 Edit phrases… action - // 19 Check permissions… action - // 20 Open config file… action - // 21 View release notes… action - // 22 Check for updates… action - // 23 Uninstall stack-nudge action - // 24 Quit panel action + // 18 Show remaining toggle (invert gauge readout: 70% left vs 30% used) + // 19 Edit phrases… action + // 20 Check permissions… action + // 21 Open config file… action + // 22 View release notes… action + // 23 Check for updates… action + // 24 Uninstall stack-nudge action + // 25 Quit panel action // MARK: - Disk I/O @@ -321,6 +323,7 @@ final class PanelNav: ObservableObject { voiceSpeed = Double(config["STACKNUDGE_VOICE_SPEED"] ?? "") ?? 1.1 quotaTrackingEnabled = ConfigFile.bool(config, "STACKNUDGE_QUOTA_TRACKING", default: true) quotaAlertsEnabled = ConfigFile.bool(config, "STACKNUDGE_QUOTA_ALERTS", default: true) + quotaShowRemaining = ConfigFile.bool(config, "STACKNUDGE_QUOTA_SHOW_REMAINING", default: false) // Coerce out-of-list values to the nearest valid threshold so a // hand-edited config can't desync the cycle row's selection. let rawThreshold = Int(config["STACKNUDGE_QUOTA_THRESHOLD"] ?? "") ?? 80 @@ -526,13 +529,13 @@ final class PanelNav: ObservableObject { } else { startVoiceModelDownload() } - case 18: actions?.editPhrases() - case 19: actions?.checkPermissions() - case 20: actions?.openConfig() - case 21: actions?.openReleaseNotes() - case 22: actions?.checkForUpdates() - case 23: actions?.beginUninstall() - case 24: actions?.quit() + case 19: actions?.editPhrases() + case 20: actions?.checkPermissions() + case 21: actions?.openConfig() + case 22: actions?.openReleaseNotes() + case 23: actions?.checkForUpdates() + case 24: actions?.beginUninstall() + case 25: actions?.quit() default: applyCycle(forward: true) } } @@ -655,6 +658,10 @@ final class PanelNav: ObservableObject { contextAlertThresholdK = list[next] ConfigFile.write(key: "STACKNUDGE_CONTEXT_ALERT_THRESHOLD", value: String(contextAlertThresholdK)) + case 18: + quotaShowRemaining.toggle() + ConfigFile.write(key: "STACKNUDGE_QUOTA_SHOW_REMAINING", + value: quotaShowRemaining ? "true" : "false") default: break } diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index 1ae4f0b..27a21a7 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -269,10 +269,16 @@ struct UsageView: View { } private func tierRow(_ tier: QuotaTier) -> some View { - VStack(alignment: .leading, spacing: 4) { + // Show "30% used" or "70% remaining" depending on the toggle. Bar + // still represents utilization so the color ramp keeps its meaning. + let display = nav.quotaShowRemaining + ? max(0, 100 - tier.utilization) + : tier.utilization + let suffix = nav.quotaShowRemaining ? "% left" : "%" + return VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 8) { Spacer() - Text("\(Int(tier.utilization.rounded()))%") + Text("\(Int(display.rounded()))\(suffix)") .font(.caption.monospacedDigit().weight(.semibold)) .foregroundStyle(barColor(tier.utilization)) } diff --git a/panel/Settings.swift b/panel/Settings.swift index 21e7380..9336afe 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -79,16 +79,17 @@ struct SettingsView: View { row(15 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%", enabled: nav.quotaTrackingEnabled && nav.quotaAlertsEnabled) row(16 + off, label: "Poll frequency", kind: .cycle, value: "\(nav.quotaPollMinutes) min", enabled: nav.quotaTrackingEnabled) row(17 + off, label: "Context alert at", kind: .cycle, value: contextAlertLabel) + row(18 + off, label: "Show remaining", kind: .toggle, value: nav.quotaShowRemaining ? "On" : "Off", enabled: nav.quotaTrackingEnabled) } section("Actions") { - row(18 + off, label: "Edit phrases…", kind: .action, value: "") - row(19 + off, label: "Check permissions…", kind: .action, value: "") - row(20 + off, label: "Open config file…", kind: .action, value: "") - row(21 + off, label: "View release notes…", kind: .action, value: "") - row(22 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) - row(23 + off, label: "Uninstall StackNudge…", kind: .action, value: "") - row(24 + off, label: "Quit panel", kind: .action, value: "") + row(19 + off, label: "Edit phrases…", kind: .action, value: "") + row(20 + off, label: "Check permissions…", kind: .action, value: "") + row(21 + off, label: "Open config file…", kind: .action, value: "") + row(22 + off, label: "View release notes…", kind: .action, value: "") + row(23 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) + row(24 + off, label: "Uninstall StackNudge…", kind: .action, value: "") + row(25 + off, label: "Quit panel", kind: .action, value: "") } aboutFooter From 4cdf47bb3f0e8b1554d825f148c864381a890f75 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 2 Jun 2026 11:49:11 +0100 Subject: [PATCH 3/4] =?UTF-8?q?feat(compact):=20Settings=20=E2=86=92=20Wid?= =?UTF-8?q?get=20=E2=86=92=20Pill=20opacity=20(40/60/80/100%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-configurable window alpha applied only in pill mode; expanded panel + full-mode stay fully opaque so content remains readable. Persists as STACKNUDGE_COMPACT_ALPHA. Default 100% (no-op until opted in). Earlier hover-driven alpha experiment was reverted — the perception of "more transparent at rest, snap to solid on hover" didn't map cleanly to what users were seeing visually, and the dual-blur stack (NSVisualEffect + SwiftUI .regularMaterial) muted the difference even at extreme values. A plain user-set value is the cleaner contract. Co-Authored-By: Claude Opus 4.7 --- panel/CompactView.swift | 6 +-- panel/Panel.swift | 16 ++++++++ panel/PanelNav.swift | 90 ++++++++++++++++++++++++----------------- panel/Settings.swift | 41 ++++++++++--------- 4 files changed, 93 insertions(+), 60 deletions(-) diff --git a/panel/CompactView.swift b/panel/CompactView.swift index 499a12f..6ebb07d 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -40,9 +40,9 @@ struct CompactView: View { .contentShape(Capsule()) .onTapGesture(count: 2) { onExitCompact() } .onChange(of: store.events.first?.id) { _ in triggerRipple() } - // Hover state drives per-mascot reactions (robot antenna flick, cat - // wink + ear twitch, eye pupil dilate-and-dart, ghost pop-and-yawn). - // Gated on pill mode — the expanded panel doesn't need playfulness. + // Hover state drives per-mascot reactions (robot antenna flick, + // cat wink + ear twitch, eye pupil dilate-and-dart, ghost + // pop-and-yawn). Gated on pill mode inside each mascot. .onHover { isHovering = $0 } } diff --git a/panel/Panel.swift b/panel/Panel.swift index b905cb5..cfd3415 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -684,6 +684,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, .dropFirst() .sink { [weak self] _ in self?.applyCompactLayout() } .store(in: &cancellables) + nav.$compactAlpha + .removeDuplicates() + .sink { [weak self] _ in self?.applyCompactAlpha() } + .store(in: &cancellables) applyCompactLayout() // If a previous panel instance was pkilled mid-update by install.sh, @@ -887,6 +891,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, panel.makeKeyAndOrderFront(nil) } } + applyCompactAlpha() } // Called from the widget's expand button. Sets the expanded flag, @@ -898,6 +903,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, applyCompactLayout() } + // Applies the user-configured pill opacity to the window. Only takes + // effect in pill mode; expanded panel + full-mode are always fully + // opaque so the user can actually read content. + private func applyCompactAlpha() { + if nav.compactMode, !nav.compactExpanded { + panel.alphaValue = CGFloat(nav.compactAlpha) + } else { + panel.alphaValue = 1.0 + } + } + // Wraps expandFromCompact for entry points that come from mouse/tap // events on the pill itself — the SwiftUI expand button and the // double-tap gesture. Notification banners sit at the same screen diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index a9d8db2..b132cf1 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -187,6 +187,12 @@ final class PanelNav: ObservableObject { @Published var compactMode: Bool = true @Published var compactCorner: CompactCorner = .topRight @Published var mascot: MascotKind = .robot + // Pill window alpha when at rest. 1.0 = fully opaque; lower values + // let the desktop show through so the widget recedes. Applied + // window-level so the NSVisualEffectView blur fades with it. Only + // takes effect while in pill mode (compact + !expanded). + @Published var compactAlpha: Double = 1.0 + static let compactAlphaOptions: [Double] = [0.4, 0.6, 0.8, 1.0] // Transient: true when the widget was clicked → render full panel // at full size; resignKey resets this. Not persisted — purely a // session-local "the user wants details right now" flag. @@ -281,25 +287,26 @@ final class PanelNav: ObservableObject { // 4 Launch at login toggle // 5 Widget corner cycle // 6 Mascot cycle - // 7 Sound enabled toggle (gates rows 8 + 9) - // 8 Agent done sound cycle - // 9 Permission sound cycle - // 10 Voice notifications toggle (gates rows 11 + 12) - // 11 Voice cycle (or "Download model" action) - // 12 Speed cycle - // 13 Quota tracking toggle (master; gates rows 14-16) - // 14 Quota alerts toggle - // 15 Alert threshold cycle - // 16 Poll frequency cycle - // 17 Context alert at cycle (per-session token thresholds) - // 18 Show remaining toggle (invert gauge readout: 70% left vs 30% used) - // 19 Edit phrases… action - // 20 Check permissions… action - // 21 Open config file… action - // 22 View release notes… action - // 23 Check for updates… action - // 24 Uninstall stack-nudge action - // 25 Quit panel action + // 7 Pill opacity cycle (40/60/80/100%; applied window-level) + // 8 Sound enabled toggle (gates rows 9 + 10) + // 9 Agent done sound cycle + // 10 Permission sound cycle + // 11 Voice notifications toggle (gates rows 12 + 13) + // 12 Voice cycle (or "Download model" action) + // 13 Speed cycle + // 14 Quota tracking toggle (master; gates rows 15-17) + // 15 Quota alerts toggle + // 16 Alert threshold cycle + // 17 Poll frequency cycle + // 18 Context alert at cycle (per-session token thresholds) + // 19 Show remaining toggle (invert gauge readout: 70% left vs 30% used) + // 20 Edit phrases… action + // 21 Check permissions… action + // 22 Open config file… action + // 23 View release notes… action + // 24 Check for updates… action + // 25 Uninstall stack-nudge action + // 26 Quit panel action // MARK: - Disk I/O @@ -337,6 +344,8 @@ final class PanelNav: ObservableObject { compactCorner = CompactCorner(rawValue: config["STACKNUDGE_COMPACT_CORNER"] ?? "") ?? .topRight mascot = MascotKind(rawValue: config["STACKNUDGE_MASCOT"] ?? "") ?? .robot + let rawAlpha = Double(config["STACKNUDGE_COMPACT_ALPHA"] ?? "") ?? 1.0 + compactAlpha = Self.compactAlphaOptions.min(by: { abs($0 - rawAlpha) < abs($1 - rawAlpha) }) ?? 1.0 } // MARK: - Agent reconciliation @@ -529,13 +538,13 @@ final class PanelNav: ObservableObject { } else { startVoiceModelDownload() } - case 19: actions?.editPhrases() - case 20: actions?.checkPermissions() - case 21: actions?.openConfig() - case 22: actions?.openReleaseNotes() - case 23: actions?.checkForUpdates() - case 24: actions?.beginUninstall() - case 25: actions?.quit() + case 20: actions?.editPhrases() + case 21: actions?.checkPermissions() + case 22: actions?.openConfig() + case 23: actions?.openReleaseNotes() + case 24: actions?.checkForUpdates() + case 25: actions?.beginUninstall() + case 26: actions?.quit() default: applyCycle(forward: true) } } @@ -604,16 +613,23 @@ final class PanelNav: ObservableObject { mascot = list[next] ConfigFile.write(key: "STACKNUDGE_MASCOT", value: mascot.rawValue) case 7: + let list = Self.compactAlphaOptions + let idx = list.firstIndex(of: compactAlpha) ?? (list.count - 1) + let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count + compactAlpha = list[next] + ConfigFile.write(key: "STACKNUDGE_COMPACT_ALPHA", + value: String(format: "%.2f", compactAlpha)) + case 8: soundEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_SOUND", value: soundEnabled ? "true" : "false") - case 8: - soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) case 9: - soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) case 10: + soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + case 11: voiceEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_VOICE", value: voiceEnabled ? "true" : "false") - case 11: + case 12: // Pre-download: the row is an action, not a cycle. Treat // left/right arrow as a trigger so a user discovering the // row keyboard-only can still start the download. @@ -625,17 +641,17 @@ final class PanelNav: ObservableObject { voice = step(voice, in: voicesAvailable, forward: forward, key: "STACKNUDGE_VOICE_NAME", preview: false) let phrase = Self.voicePreviewPhrases.randomElement() ?? "Hello." Speaker.speak(phrase, voice: voice, speed: String(format: "%.2f", voiceSpeed)) - case 12: + case 13: let next = forward ? voiceSpeed + Self.speedStep : voiceSpeed - Self.speedStep voiceSpeed = max(Self.speedMin, min(Self.speedMax, (next * 100).rounded() / 100)) ConfigFile.write(key: "STACKNUDGE_VOICE_SPEED", value: String(format: "%.2f", voiceSpeed)) - case 13: - toggleQuotaTracking() case 14: + toggleQuotaTracking() + case 15: quotaAlertsEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_QUOTA_ALERTS", value: quotaAlertsEnabled ? "true" : "false") - case 15: + case 16: // Cycle through the static thresholds list. Index wraps in both // directions so the user can dial in either way. let list = Self.quotaThresholds @@ -644,21 +660,21 @@ final class PanelNav: ObservableObject { quotaAlertThreshold = list[next] ConfigFile.write(key: "STACKNUDGE_QUOTA_THRESHOLD", value: String(quotaAlertThreshold)) - case 16: + case 17: let list = Self.quotaPollMinuteOptions let idx = list.firstIndex(of: quotaPollMinutes) ?? 2 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count quotaPollMinutes = list[next] ConfigFile.write(key: "STACKNUDGE_USAGE_POLL_MIN", value: String(quotaPollMinutes)) - case 17: + case 18: let list = Self.contextAlertThresholdOptions let idx = list.firstIndex(of: contextAlertThresholdK) ?? 0 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count contextAlertThresholdK = list[next] ConfigFile.write(key: "STACKNUDGE_CONTEXT_ALERT_THRESHOLD", value: String(contextAlertThresholdK)) - case 18: + case 19: quotaShowRemaining.toggle() ConfigFile.write(key: "STACKNUDGE_QUOTA_SHOW_REMAINING", value: quotaShowRemaining ? "true" : "false") diff --git a/panel/Settings.swift b/panel/Settings.swift index 9336afe..c53ec7a 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -55,41 +55,42 @@ struct SettingsView: View { section("Widget") { row(5 + off, label: "Widget corner", kind: .cycle, value: nav.compactCorner.label) row(6 + off, label: "Mascot", kind: .cycle, value: nav.mascot.label) + row(7 + off, label: "Pill opacity", kind: .cycle, value: "\(Int(nav.compactAlpha * 100))%") } section("Sounds") { - row(7 + off, label: "Sound enabled", kind: .toggle, value: nav.soundEnabled ? "On" : "Off") - row(8 + off, label: "Agent done", kind: .cycle, value: nav.soundStop, enabled: nav.soundEnabled) - row(9 + off, label: "Permission", kind: .cycle, value: nav.soundPermission, enabled: nav.soundEnabled) + row(8 + off, label: "Sound enabled", kind: .toggle, value: nav.soundEnabled ? "On" : "Off") + row(9 + off, label: "Agent done", kind: .cycle, value: nav.soundStop, enabled: nav.soundEnabled) + row(10 + off, label: "Permission", kind: .cycle, value: nav.soundPermission, enabled: nav.soundEnabled) } section("Voice") { - row(10 + off, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") + row(11 + off, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") if nav.voiceModelCached { - row(11 + off, label: "Voice", kind: .cycle, value: voiceLabel, enabled: nav.voiceEnabled) - row(12 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed), enabled: nav.voiceEnabled) + row(12 + off, label: "Voice", kind: .cycle, value: voiceLabel, enabled: nav.voiceEnabled) + row(13 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed), enabled: nav.voiceEnabled) } else { - voiceModelDownloadRow(index: 11 + off) + voiceModelDownloadRow(index: 12 + off) } } section("Usage") { - row(13 + off, label: "Quota tracking", kind: .toggle, value: nav.quotaTrackingEnabled ? "On" : "Off") - row(14 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off", enabled: nav.quotaTrackingEnabled) - row(15 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%", enabled: nav.quotaTrackingEnabled && nav.quotaAlertsEnabled) - row(16 + off, label: "Poll frequency", kind: .cycle, value: "\(nav.quotaPollMinutes) min", enabled: nav.quotaTrackingEnabled) - row(17 + off, label: "Context alert at", kind: .cycle, value: contextAlertLabel) - row(18 + off, label: "Show remaining", kind: .toggle, value: nav.quotaShowRemaining ? "On" : "Off", enabled: nav.quotaTrackingEnabled) + row(14 + off, label: "Quota tracking", kind: .toggle, value: nav.quotaTrackingEnabled ? "On" : "Off") + row(15 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off", enabled: nav.quotaTrackingEnabled) + row(16 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%", enabled: nav.quotaTrackingEnabled && nav.quotaAlertsEnabled) + row(17 + off, label: "Poll frequency", kind: .cycle, value: "\(nav.quotaPollMinutes) min", enabled: nav.quotaTrackingEnabled) + row(18 + off, label: "Context alert at", kind: .cycle, value: contextAlertLabel) + row(19 + off, label: "Show remaining", kind: .toggle, value: nav.quotaShowRemaining ? "On" : "Off", enabled: nav.quotaTrackingEnabled) } section("Actions") { - row(19 + off, label: "Edit phrases…", kind: .action, value: "") - row(20 + off, label: "Check permissions…", kind: .action, value: "") - row(21 + off, label: "Open config file…", kind: .action, value: "") - row(22 + off, label: "View release notes…", kind: .action, value: "") - row(23 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) - row(24 + off, label: "Uninstall StackNudge…", kind: .action, value: "") - row(25 + off, label: "Quit panel", kind: .action, value: "") + row(20 + off, label: "Edit phrases…", kind: .action, value: "") + row(21 + off, label: "Check permissions…", kind: .action, value: "") + row(22 + off, label: "Open config file…", kind: .action, value: "") + row(23 + off, label: "View release notes…", kind: .action, value: "") + row(24 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) + row(25 + off, label: "Uninstall StackNudge…", kind: .action, value: "") + row(26 + off, label: "Quit panel", kind: .action, value: "") } aboutFooter From f4a871208face993c770e1f04aa9c0b3993bb8b6 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 2 Jun 2026 11:56:39 +0100 Subject: [PATCH 4/4] feat(compact): more mascot hover reactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Robot: cyan smile arc appears below the eyes when hovered - Cat: pink tongue blep peeks out from below the mouth - Eye: questioning eyebrow arc draws above the lens - Ghost: three cyan sparkles fade in around the body Each reaction is hover-only and additive — composes with the existing reactions (antenna flick, ear lift + wink, pupil dilate-and-dart, pop-and-yawn) without conflicting with state-driven visuals. Co-Authored-By: Claude Opus 4.7 --- panel/CompactView.swift | 106 ++++++++++++++++++++++++++++++++++------ 1 file changed, 91 insertions(+), 15 deletions(-) diff --git a/panel/CompactView.swift b/panel/CompactView.swift index 6ebb07d..1e0f7c6 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -668,7 +668,21 @@ private struct RobotMascot: View { .frame(width: 4.5, height: 1.2) .offset(y: 6) default: - EmptyView() + // Hover-only smile arc — the robot grins back when you reach + // for it, regardless of its current state. + if hovered { + Path { p in + p.move(to: CGPoint(x: 0, y: 0)) + p.addQuadCurve(to: CGPoint(x: 6, y: 0), + control: CGPoint(x: 3, y: 2.2)) + } + .stroke(Self.cyan, style: StrokeStyle(lineWidth: 1.2, lineCap: .round)) + .frame(width: 6, height: 2.2) + .offset(y: 6) + .transition(.scale.combined(with: .opacity)) + } else { + EmptyView() + } } } } @@ -686,12 +700,12 @@ private struct CatMascot: View { var body: some View { if paused { - ZStack { head; staticEyes; mouth; whiskers } + ZStack { head; staticEyes; mouth; whiskers; blep } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { TimelineView(.animation(minimumInterval: 0.05)) { tl in let t = tl.date.timeIntervalSinceReferenceDate - ZStack { head; eyes(at: t); mouth; whiskers } + ZStack { head; eyes(at: t); mouth; whiskers; blep } .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -700,6 +714,20 @@ private struct CatMascot: View { // Ears perked = head + ear-hints lift slightly while hovered. private var earLift: CGFloat { hovered ? -1.5 : 0 } + // Tiny tongue blep — a pink rounded triangle peeking out below the + // mouth on hover. Pure decoration; appears only while hovered. + @ViewBuilder + private var blep: some View { + if hovered { + Capsule() + .fill(Color(red: 1.0, green: 0.55, blue: 0.7)) + .frame(width: 2.2, height: 2.8) + .offset(y: 7.5) + .transition(.scale.combined(with: .opacity)) + .animation(.spring(response: 0.25, dampingFraction: 0.55), value: hovered) + } + } + private var head: some View { ZStack { // Face + ears combined as a single path so the ears feel @@ -888,17 +916,35 @@ private struct EyeMascot: View { var body: some View { if paused { - ZStack { lens; staticPupil } + ZStack { lens; staticPupil; eyebrow } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { TimelineView(.animation(minimumInterval: 0.05)) { tl in let t = tl.date.timeIntervalSinceReferenceDate - ZStack { lens; pupil(at: t) } + ZStack { lens; pupil(at: t); eyebrow } .frame(maxWidth: .infinity, maxHeight: .infinity) } } } + // Hover-only eyebrow arc above the lens — gives the sentinel a + // questioning, "oh hey you" expression when the cursor approaches. + @ViewBuilder + private var eyebrow: some View { + if hovered { + Path { p in + p.move(to: CGPoint(x: 0, y: 2)) + p.addQuadCurve(to: CGPoint(x: 9, y: 2), + control: CGPoint(x: 4.5, y: -1.5)) + } + .stroke(Self.outline, style: StrokeStyle(lineWidth: 1.4, lineCap: .round)) + .frame(width: 9, height: 3) + .offset(y: -10) + .transition(.scale.combined(with: .opacity)) + .animation(.spring(response: 0.25, dampingFraction: 0.6), value: hovered) + } + } + private var lens: some View { ZStack { Circle() @@ -977,24 +1023,54 @@ private struct GhostMascot: View { var body: some View { if paused { - ZStack { body_; staticEyes; mouth } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .offset(y: hovered ? -4 : 0) - .scaleEffect(hovered ? 1.08 : 1.0) - .animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered) + ZStack { + sparkles + ZStack { body_; staticEyes; mouth } + .offset(y: hovered ? -4 : 0) + .scaleEffect(hovered ? 1.08 : 1.0) + .animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { TimelineView(.animation(minimumInterval: 0.05)) { tl in let t = tl.date.timeIntervalSinceReferenceDate let bob = sin(t * 1.3) * 1.0 - ZStack { body_; eyes(at: t); mouth } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .offset(y: bob + (hovered ? -4 : 0)) - .scaleEffect(hovered ? 1.08 : 1.0) - .animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered) + ZStack { + sparkles + ZStack { body_; eyes(at: t); mouth } + .offset(y: bob + (hovered ? -4 : 0)) + .scaleEffect(hovered ? 1.08 : 1.0) + .animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } } + // Three little sparkles around the ghost on hover. They fade in via + // transition, then a subtle pulse via TimelineView would be overkill + // — keeping them static for clarity and frame budget. + @ViewBuilder + private var sparkles: some View { + if hovered { + ZStack { + sparkle.offset(x: -10, y: -8) + sparkle.offset(x: 10, y: -3).scaleEffect(0.7) + sparkle.offset(x: 8, y: 9).scaleEffect(0.85) + } + .transition(.scale.combined(with: .opacity)) + .animation(.spring(response: 0.3, dampingFraction: 0.55), value: hovered) + } + } + + private var sparkle: some View { + // Four-point star drawn as two crossed thin capsules. + ZStack { + Capsule().fill(Self.cyan).frame(width: 1.2, height: 4) + Capsule().fill(Self.cyan).frame(width: 4, height: 1.2) + } + } + private var body_: some View { GhostShape() .stroke(Self.outline, lineWidth: 1.2)