From 98586938f3b158c8019c63cebfba89ad11d46b00 Mon Sep 17 00:00:00 2001 From: Hasaam Date: Mon, 28 Sep 2026 21:51:49 -0400 Subject: [PATCH] chore(debug): add --portfolio panel renderer, ignore its output Render the menu bar panel for portfolio use: transparent PNGs at exactly 2 px per point, cropped to the panel edge, identical in light and dark, plus a fully expanded variant, from a fictional fixture. The images are regenerated on demand, so docs/screenshots/portfolio/ is gitignored; CONTRIBUTING documents the render and icon export commands. --- .gitignore | 3 + CONTRIBUTING.md | 5 + Sources/PRMonitor/App/PRMonitorApp.swift | 1 + Sources/PRMonitor/App/Showcase.swift | 140 +++++++++++++++++++++++ Sources/PRMonitor/Services/Monitor.swift | 4 +- 5 files changed, 151 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 2fb4027..9b2573a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ PRMonitor.xcodeproj/project.xcworkspace/ # Release output from scripts/release.sh dist/ + +# Portfolio images: regenerate with `PRMonitor --portfolio` (see CONTRIBUTING.md) +docs/screenshots/portfolio/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a90d12..991f775 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,11 @@ Read [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) before larger changes. - `PRMonitor --showcase hero-light` (also `hero-dark`, `settings-light`, `settings-dark`) puts the real UI, on real Liquid Glass, over a designed backdrop and prints the rectangle to capture with `screencapture -x -R out.png`. These are the README images. - `PRMonitor --snapshot ` renders the panel, Settings panes and every menu bar icon state offscreen, for quick layout checks. - `PRMonitor --diagnose owner/repo` checks status derivation against live repositories, using your `gh` token. + - `PRMonitor --portfolio docs/screenshots/portfolio` renders portfolio images of the panel: transparent, exactly 2 px per point, cropped to the panel edge, in light and dark, plus a fully expanded variant. The folder is gitignored. For the matching 1024 px icon in its default style (regardless of the Mac's icon style setting), use Icon Composer's exporter: + + ```bash + "/Applications/Xcode.app/Contents/Applications/Icon Composer.app/Contents/Executables/ictool" PRMonitorApp/AppIcon.icon --export-image --output-file docs/screenshots/portfolio/app-icon-1024.png --platform macOS --rendition Default --width 1024 --height 1024 --scale 1 + ``` ## Submitting changes diff --git a/Sources/PRMonitor/App/PRMonitorApp.swift b/Sources/PRMonitor/App/PRMonitorApp.swift index b28c505..b9e3f93 100644 --- a/Sources/PRMonitor/App/PRMonitorApp.swift +++ b/Sources/PRMonitor/App/PRMonitorApp.swift @@ -11,6 +11,7 @@ struct PRMonitorApp: App { #if DEBUG Diagnostics.runIfRequested() Snapshots.runIfRequested() + PortfolioShots.runIfRequested() #endif let settings = AppSettings() let account = Account() diff --git a/Sources/PRMonitor/App/Showcase.swift b/Sources/PRMonitor/App/Showcase.swift index 9850872..d39366e 100644 --- a/Sources/PRMonitor/App/Showcase.swift +++ b/Sources/PRMonitor/App/Showcase.swift @@ -197,3 +197,143 @@ private struct MockMenuBar: View { } } #endif + +#if DEBUG +/// `PRMonitor --portfolio ` renders the menu bar panel for a portfolio site: transparent +/// PNGs at exactly 2 px per point, cropped to the panel edge, with no shadow, in light and dark, +/// with the first two pull requests expanded, plus a fully expanded variant. Uses fictional +/// repositories. Debug builds only. +enum PortfolioShots { + @MainActor + static func runIfRequested() { + let arguments = CommandLine.arguments + guard let index = arguments.firstIndex(of: "--portfolio"), arguments.indices.contains(index + 1) else { return } + let directory = URL(filePath: arguments[index + 1], directoryHint: .isDirectory) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let (settings, account, monitor, router) = PreviewData.environment() + // Minute-granular "Updated 2 minutes ago" can't tick between the light and dark renders. + monitor.loadPreview(PortfolioData.pullRequests, updatedSecondsAgo: 150) + let primary = Set(PortfolioData.pullRequests.prefix(2).map(\.id)) + let everything = Set(PortfolioData.pullRequests.map(\.id)) + + func panel(_ expanded: Set) -> some View { + MenuBarPanel(initiallyExpanded: expanded) + .environment(monitor).environment(settings).environment(account).environment(router) + .background(PanelSurface()) + .clipShape(.rect(cornerRadius: Metrics.panelCornerRadius, style: .continuous)) + } + + render(panel(primary), dark: false, to: directory.appending(path: "panel-light@2x.png")) + render(panel(primary), dark: true, to: directory.appending(path: "panel-dark@2x.png")) + render(panel(everything), dark: false, to: directory.appending(path: "panel-light-full@2x.png")) + // The icon comes from Icon Composer's exporter, which ignores the Mac's icon style setting: + // ictool PRMonitorApp/AppIcon.icon --export-image --output-file app-icon-1024.png \ + // --platform macOS --rendition Default --width 1024 --height 1024 --scale 1 + exit(0) + } + + /// Renders at exactly 2 px per point into a transparent bitmap, independent of the screen. + @MainActor + private static func render(_ view: some View, dark: Bool, to url: URL) { + let appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + let host = NSHostingView(rootView: view.environment(\.colorScheme, dark ? .dark : .light)) + host.appearance = appearance + host.frame = NSRect(origin: .zero, size: host.fittingSize) + let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.isOpaque = false + window.backgroundColor = .clear + window.appearance = appearance + window.contentView = host + // The list measures its own height after the first layout pass, so settle, re-measure and + // resize until the size stops changing before capturing. + var size = host.frame.size + for _ in 0..<5 { + host.layoutSubtreeIfNeeded() + RunLoop.main.run(until: .now.addingTimeInterval(0.25)) + let fitted = host.fittingSize + if fitted == size { break } + size = fitted + window.setContentSize(size) + host.frame = NSRect(origin: .zero, size: size) + } + + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: Int(size.width * 2), pixelsHigh: Int(size.height * 2), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { return } + rep.size = size + host.cacheDisplay(in: host.bounds, to: rep) + try? rep.representation(using: .png, properties: [:])?.write(to: url) + } +} + +/// The panel's own surface, standing in for the system's glass (which can't render offscreen): +/// an opaque menu-like fill and the hairline rim macOS draws around menu bar panels. No shadow. +private struct PanelSurface: View { + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let shape = RoundedRectangle(cornerRadius: Metrics.panelCornerRadius, style: .continuous) + shape + .fill(colorScheme == .dark ? Color(white: 0.16) : Color(white: 0.965)) + .overlay(shape.strokeBorder(.primary.opacity(colorScheme == .dark ? 0.16 : 0.1), lineWidth: 0.5)) + } +} + +/// Portfolio fixture: two fictional repositories, three pull requests in three states. +private enum PortfolioData { + static let now = Date.now + + static let pullRequests: [PullRequest] = [ + // Checkout: Vercel and Cursor Bugbot passed, Devin left review threads. + pr("PF_1", "lumen-labs/checkout-web", 214, "Add Apple Pay to the checkout flow", author: "maya", minutesAgo: 4, + checks: [ + check("Vercel", source: .commitStatus(creator: "vercel")), + check("Cursor Bugbot", source: .checkRun(appName: "Cursor", appSlug: "cursor")), + check("Devin Review", source: .checkRun(appName: "Devin", appSlug: "devin-ai-integration")), + ], + threads: [thread("devin-ai-integration"), thread("devin-ai-integration")]), + // Checkout: still running. + pr("PF_2", "lumen-labs/checkout-web", 209, "Retry failed payment webhooks with backoff", author: "sam", minutesAgo: 11, + checks: [ + check("Vercel", source: .commitStatus(creator: "vercel")), + check("build", source: .checkRun(appName: "GitHub Actions", appSlug: "github-actions")), + check("test", source: .checkRun(appName: "GitHub Actions", appSlug: "github-actions"), outcome: .pending), + check("Cursor Bugbot", source: .checkRun(appName: "Cursor", appSlug: "cursor"), outcome: .pending), + ]), + // Design system: everything green and approved. + pr("PF_3", "lumen-labs/design-system", 88, "Adopt Liquid Glass tokens for buttons", author: "jordan", minutesAgo: 38, + checks: [ + check("CI", source: .checkRun(appName: "GitHub Actions", appSlug: "github-actions")), + check("Vercel", source: .commitStatus(creator: "vercel")), + check("Cursor Bugbot", source: .checkRun(appName: "Cursor", appSlug: "cursor")), + ], + decision: .approved), + ] + + private static func pr( + _ id: String, _ repo: String, _ number: Int, _ title: String, author: String, minutesAgo: Double, + checks: [CheckSignal], threads: [ThreadSignal] = [], decision: ReviewDecision? = nil + ) -> PullRequest { + PullRequest( + id: id, repository: RepositoryID(parsing: repo)!, number: number, title: title, + url: URL(string: "https://github.com/\(repo)/pull/\(number)")!, author: author, isDraft: false, + createdAt: now.addingTimeInterval(-86_400), updatedAt: now.addingTimeInterval(-minutesAgo * 60), + headSHA: "head-\(id)", headCommittedAt: now.addingTimeInterval(-(minutesAgo + 20) * 60), rollupState: nil, + reviewDecision: decision, mergeable: .mergeable, checks: checks, requestedReviewers: [], + reviews: [], threads: threads, comments: [] + ) + } + + private static func check(_ name: String, source: CheckSignal.Source, outcome: CheckSignal.Outcome = .succeeded) -> CheckSignal { + CheckSignal(name: name, source: source, outcome: outcome, rawState: outcome == .pending ? "IN_PROGRESS" : "SUCCESS", + startedAt: now.addingTimeInterval(-600), completedAt: outcome == .pending ? nil : now.addingTimeInterval(-180), url: nil) + } + + private static func thread(_ login: String) -> ThreadSignal { + ThreadSignal(author: login, isBot: true, isResolved: false, isOutdated: false, createdAt: now.addingTimeInterval(-120)) + } +} +#endif diff --git a/Sources/PRMonitor/Services/Monitor.swift b/Sources/PRMonitor/Services/Monitor.swift index 0af0aea..feb7a20 100644 --- a/Sources/PRMonitor/Services/Monitor.swift +++ b/Sources/PRMonitor/Services/Monitor.swift @@ -331,10 +331,10 @@ final class Monitor { #if DEBUG extension Monitor { /// Shows fixed data without polling, for previews and snapshots. - func loadPreview(_ items: [PullRequest]) { + func loadPreview(_ items: [PullRequest], updatedSecondsAgo: TimeInterval = 45) { pullRequests = Dictionary(grouping: items, by: \.repository).mapValues { ($0, $0.count) } rebuild() - lastUpdated = .now.addingTimeInterval(-45) + lastUpdated = .now.addingTimeInterval(-updatedSecondsAgo) } } #endif