diff --git a/.github/workflows/screenshot.yml b/.github/workflows/screenshot.yml new file mode 100644 index 0000000..8286032 --- /dev/null +++ b/.github/workflows/screenshot.yml @@ -0,0 +1,176 @@ +name: Screenshot + +# Build Sumbee and post app screenshots as inline previews on the pull request. +# +# This runs on pull requests targeting `remote-dev` (plus manual dispatch): local development and +# the default branch stay screenshot-free, while opening a PR against `remote-dev` - or pushing any +# new commit to an open one - builds the app, captures screenshots, commits them to the PR branch +# under `screenshots/`, and posts a single sticky PR comment that previews them inline. The PNGs are +# also uploaded as an artifact. +# +# Why commit the PNGs: GitHub only renders images in a comment from a public URL (e.g. +# raw.githubusercontent.com); an uploaded artifact is a zip you must download, so it can't preview +# inline. Committing them to the PR head branch gives each image a stable raw URL to embed. +# +# Sumbee is a macOS/AppKit app, so this MUST run on a macOS runner (a Linux runner cannot build it). +# Screenshots use the app's own headless self-render hooks (SUMBEE_SMOKE / SUMBEE_SHOT / ...), +# which draw the window via cacheDisplay - no Screen Recording permission or live display needed. + +on: + pull_request: + branches: [remote-dev] + # opened: first PR against remote-dev. synchronize: every subsequent push to the PR's head + # branch. reopened: a closed PR brought back. Together this re-runs on any push to an open + # PR targeting remote-dev (these are also the GitHub defaults, listed explicitly for clarity). + types: [opened, synchronize, reopened] + workflow_dispatch: + +# Avoid piling up runs if several pushes land on the same PR in quick succession. +concurrency: + group: screenshot-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: write # commit the captured PNGs back to the PR branch + pull-requests: write # post/update the sticky preview comment + +jobs: + screenshot: + runs-on: macos-15 + timeout-minutes: 20 + + # Forked PRs run with a read-only token and no write access to the head ref, so committing PNGs + # and posting a comment would fail. Only run for same-repo PRs (and manual dispatch). + if: >- + github.event_name == 'workflow_dispatch' || + github.event.pull_request.head.repo.full_name == github.repository + + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + # Check out the PR's head branch (not the merge ref) so we can commit screenshots to it. + ref: ${{ github.head_ref }} + + - name: Show toolchain + run: swift --version + + - name: Build (release) + run: swift build -c release + + - name: Capture screenshots + run: | + set -euo pipefail + BIN="$(swift build -c release --show-bin-path)/Sumbee" + OUT="$PWD/screenshots" + mkdir -p "$OUT" + + # Isolated library so a run never touches a real ~/Sumbee Summaries / ~/Documents. + export SUMBEE_LIBRARY="$(mktemp -d)/Sumbee Summaries" + + # Each invocation boots, draws one window, writes the PNG, and self-terminates (~2s). + shoot() { + local out="$1"; shift + echo ">> $out" + env SUMBEE_SMOKE=1 SUMBEE_SHOT="$out" "$@" \ + "$BIN" || true + } + + # 1) Main panel + shoot "$OUT/screen-01-app.png" + + # 2) Settings - Generation section + shoot "$OUT/screen-02-settings-generation.png" \ + SUMBEE_OPEN_SETTINGS=1 SUMBEE_SETTINGS_SECTION=Generation + + # 3) Styles editor (first style opened) + shoot "$OUT/screen-03-styles.png" \ + SUMBEE_OPEN_SETTINGS=1 SUMBEE_SETTINGS_SECTION=Styles SUMBEE_EDIT_FIRST_STYLE=1 + + # 4) Output section + shoot "$OUT/screen-04-output.png" \ + SUMBEE_OPEN_SETTINGS=1 SUMBEE_SETTINGS_SECTION=Output + + echo ">> Captured:" + ls -la "$OUT" + + - name: Upload screenshots (artifact) + uses: actions/upload-artifact@v4 + with: + name: sumbee-screenshots + path: screenshots/*.png + if-no-files-found: error + + - name: Commit screenshots to PR branch + id: commit + env: + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add screenshots/*.png + + if git diff --cached --quiet; then + echo "No screenshot changes to commit." + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git commit -m "ci: update Sumbee screenshots [skip ci]" + # Push back to the PR's head branch. [skip ci] keeps this from re-triggering the workflow. + git push origin "HEAD:${HEAD_REF}" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Post sticky preview comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const sha = '${{ steps.commit.outputs.sha }}'; + const repo = `${context.repo.owner}/${context.repo.repo}`; + const shots = [ + ['Main panel', 'screenshots/screen-01-app.png'], + ['Settings - Generation', 'screenshots/screen-02-settings-generation.png'], + ['Styles editor', 'screenshots/screen-03-styles.png'], + ['Output', 'screenshots/screen-04-output.png'], + ]; + + // Marker lets us find and update our own comment instead of posting a new one each run. + const marker = ''; + const runUrl = + `${context.serverUrl}/${repo}/actions/runs/${context.runId}`; + + const images = shots.map(([title, path]) => { + const raw = `${context.serverUrl}/${repo}/raw/${sha}/${path}`; + return `### ${title}\n\n![${title}](${raw})`; + }).join('\n\n'); + + const body = [ + marker, + '## Sumbee screenshots', + '', + `Captured from \`${sha.slice(0, 7)}\`. ` + + `Also available as a downloadable [artifact](${runUrl}).`, + '', + images, + ].join('\n'); + + const { owner, repo: name } = context.repo; + const issue_number = context.payload.pull_request.number; + + const { data: comments } = await github.rest.issues.listComments({ + owner, repo: name, issue_number, per_page: 100, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner, repo: name, comment_id: existing.id, body, + }); + } else { + await github.rest.issues.createComment({ + owner, repo: name, issue_number, body, + }); + } diff --git a/Sources/SumbeeKit/App/ContentView.swift b/Sources/SumbeeKit/App/ContentView.swift index 37f01d5..8fcada9 100644 --- a/Sources/SumbeeKit/App/ContentView.swift +++ b/Sources/SumbeeKit/App/ContentView.swift @@ -46,6 +46,10 @@ struct ContentView: View { onCancel: { state.cancelPendingPreview() }) .environmentObject(state) } + .sheet(isPresented: $state.showShare) { + ShareSheet(onClose: { state.showShare = false }) + .environmentObject(state) + } .animation(Theme.spring, value: state.showSettings) .animation(Theme.quick, value: state.toast?.id) .dynamicTypeSize(.xLarge) // bump the whole app's text up a notch (FR-027) diff --git a/Sources/SumbeeKit/Models/ShareContent.swift b/Sources/SumbeeKit/Models/ShareContent.swift new file mode 100644 index 0000000..92aceb0 --- /dev/null +++ b/Sources/SumbeeKit/Models/ShareContent.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Canonical, viral-marketing-tuned copy for sharing Sumbee. Pure value type (no UI/AppKit) so the +/// exact strings + generated links are unit-testable and reused by every share surface. +/// +/// Viral best practices baked in: one clear product (repo) link, a short benefit-led message with a +/// light hook + emoji, a ready-to-post tweet (kept well under platform limits), and platform deep +/// links so a share is one click, not copy-paste-and-figure-it-out. +public enum ShareContent { + /// The single destination every share points at - the public GitHub repo. + public static let repoURLString = "https://github.com/wynnwu/Sumbee" + public static let repoURL = URL(string: repoURLString)! + + /// Short, benefit-led headline used as the share's call to action. + public static let headline = "Enjoying Sumbee?" + + /// One-line value prop reused as the subject / preview line. + public static let tagline = "Turn transcripts & YouTube videos into clean notes - on your Mac, no lock-in." + + /// The default message a sharer posts. Short, specific, ends with the link so it survives + /// truncation on any platform. + public static let message = + "I've been using Sumbee to turn transcripts & YouTube videos into clean Markdown notes on my Mac - local-first, your files, no lock-in. Free & open source: \(repoURLString)" + + /// A tweet-sized variant (kept short so the URL + a quote/RT still fit comfortably). + public static let tweet = + "Sumbee turns transcripts & YouTube videos into clean notes on your Mac - local-first, no lock-in. Free & open source \(repoURLString)" + + /// Email subject line for the Mail share. + public static let emailSubject = "You might like Sumbee" + + /// X / Twitter web intent (works without the native app installed). + public static var twitterShareURL: URL? { + var components = URLComponents(string: "https://twitter.com/intent/tweet") + components?.queryItems = [URLQueryItem(name: "text", value: tweet)] + return components?.url + } + + /// A `mailto:` URL with a prefilled subject and body for the "Email a friend" link. + public static var mailtoURL: URL? { + var components = URLComponents() + components.scheme = "mailto" + components.queryItems = [ + URLQueryItem(name: "subject", value: emailSubject), + URLQueryItem(name: "body", value: message), + ] + return components.url + } +} diff --git a/Sources/SumbeeKit/State/AppState.swift b/Sources/SumbeeKit/State/AppState.swift index e5ff53c..5f9fdb9 100644 --- a/Sources/SumbeeKit/State/AppState.swift +++ b/Sources/SumbeeKit/State/AppState.swift @@ -1,5 +1,6 @@ import SwiftUI import Foundation +import AppKit /// A transient user-facing notice. public struct ToastItem: Identifiable, Equatable { @@ -19,6 +20,7 @@ public final class AppState: ObservableObject { @Published public var settings: AppSettings @Published public private(set) var hasKey: Bool @Published public var showSettings: Bool = false + @Published public var showShare: Bool = false // Library & jobs (populated as services come online) @Published public var library: Library = .empty @@ -322,4 +324,13 @@ public final class AppState: ObservableObject { toast = ToastItem(kind: kind, text: text) } public func dismissToast() { toast = nil } + + // MARK: - Sharing + + /// Copy the Sumbee repo link to the clipboard and confirm with a toast (FR: viral share). + public func copyShareLink() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(ShareContent.repoURLString, forType: .string) + present(.success, "Link copied. Thanks for spreading the word!") + } } diff --git a/Sources/SumbeeKit/Views/Design/Components.swift b/Sources/SumbeeKit/Views/Design/Components.swift index b4f7f44..62cc602 100644 --- a/Sources/SumbeeKit/Views/Design/Components.swift +++ b/Sources/SumbeeKit/Views/Design/Components.swift @@ -1,20 +1,25 @@ import SwiftUI -/// A filled accent (orange) button with the brand gradient and a soft glow. +/// A filled accent (orange) button using the deep brand orange, with a soft glow. Set +/// `prominent: false` for the secondary outline variant, or `compact: true` for a smaller size. public struct AccentButtonStyle: ButtonStyle { public var prominent: Bool - public init(prominent: Bool = true) { self.prominent = prominent } + public var compact: Bool + public init(prominent: Bool = true, compact: Bool = false) { + self.prominent = prominent + self.compact = compact + } public func makeBody(configuration: Configuration) -> some View { configuration.label - .font(.uiBody.weight(.semibold)) - .padding(.horizontal, 18) - .padding(.vertical, 10) + .font((compact ? Font.uiCallout : Font.uiBody).weight(.semibold)) + .padding(.horizontal, compact ? 14 : 18) + .padding(.vertical, compact ? 7 : 10) .foregroundStyle(prominent ? Color.white : Theme.accent) .background { if prominent { RoundedRectangle(cornerRadius: Theme.smallCorner, style: .continuous) - .fill(Theme.accentGradient) + .fill(configuration.isPressed ? Theme.accent : Theme.accentDeep) .shadow(color: Theme.accentGlow(0.5), radius: configuration.isPressed ? 2 : 8, y: 2) } else { RoundedRectangle(cornerRadius: Theme.smallCorner, style: .continuous) diff --git a/Sources/SumbeeKit/Views/MainPanel/MainPanelView.swift b/Sources/SumbeeKit/Views/MainPanel/MainPanelView.swift index 5dd6a78..cc05492 100644 --- a/Sources/SumbeeKit/Views/MainPanel/MainPanelView.swift +++ b/Sources/SumbeeKit/Views/MainPanel/MainPanelView.swift @@ -43,10 +43,27 @@ struct MainPanelView: View { .foregroundStyle(.secondary) } Spacer() + shareCTA } .padding(.top, 6) } + /// Top-right "Enjoying Sumbee?" prompt stacked above a compact Share button (FR: viral share). + private var shareCTA: some View { + VStack(alignment: .trailing, spacing: 4) { + Text("Enjoying Sumbee?") + .font(.uiCaption) + .foregroundStyle(.secondary) + Button { + state.showShare = true + } label: { + Label("Share Now", systemImage: "square.and.arrow.up") + } + .buttonStyle(AccentButtonStyle(compact: true)) + } + .fixedSize() + } + private var keyGateBanner: some View { HStack(spacing: 10) { Image(systemName: "key.fill").font(.uiBody).foregroundStyle(Theme.accent) diff --git a/Sources/SumbeeKit/Views/Share/ShareSheet.swift b/Sources/SumbeeKit/Views/Share/ShareSheet.swift new file mode 100644 index 0000000..83bf295 --- /dev/null +++ b/Sources/SumbeeKit/Views/Share/ShareSheet.swift @@ -0,0 +1,167 @@ +import SwiftUI +import AppKit + +/// "Enjoying Sumbee?" share modal (FR: viral share). Gives one-click ways to spread the word: +/// copy the repo link, post to X, email a friend, or hand off to any macOS share service. +struct ShareSheet: View { + @EnvironmentObject private var state: AppState + let onClose: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + header + messageCard + linkRow + actions + nativeShareRow + } + .padding(24) + .frame(width: 480) + .background(Color(nsColor: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Theme.hairline, lineWidth: 1) + ) + .onExitCommand { onClose() } + } + + // MARK: Header + + private var header: some View { + HStack(spacing: 12) { + ZStack { + Rectangle() + .fill(Theme.accentGradient) + .frame(width: 40, height: 40) + .shadow(color: Theme.accentGlow(0.5), radius: 8, y: 2) + Image(systemName: "heart.fill") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(.white) + } + VStack(alignment: .leading, spacing: 2) { + Text("Love Sumbee? Tell a friend.") + .font(.uiHeadline) + Text(ShareContent.tagline) + .font(.uiCaption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Button { onClose() } label: { + Image(systemName: "xmark").font(.callout.weight(.bold)) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + + // MARK: Prewritten message + + private var messageCard: some View { + VStack(alignment: .leading, spacing: 8) { + Text("A ready-to-share message") + .font(.uiCaption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(ShareContent.message) + .font(.uiBody) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + + // MARK: Copyable link + + private var linkRow: some View { + HStack(spacing: 10) { + Image(systemName: "link").font(.uiBody).foregroundStyle(.secondary) + Text(ShareContent.repoURLString) + .font(.system(size: 14, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + Spacer(minLength: 8) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + // MARK: Primary actions + + private var actions: some View { + HStack(spacing: 10) { + Button { + state.copyShareLink() + } label: { + Label("Copy link", systemImage: "doc.on.doc.fill") + } + .buttonStyle(AccentButtonStyle()) + + Button { + if let url = ShareContent.twitterShareURL { NSWorkspace.shared.open(url) } + } label: { + Label("Post on X", systemImage: "bubble.left.and.text.bubble.right.fill") + } + .buttonStyle(AccentButtonStyle(prominent: false, compact: true)) + + Button { + if let url = ShareContent.mailtoURL { NSWorkspace.shared.open(url) } + } label: { + Label("Email", systemImage: "envelope.fill") + } + .buttonStyle(AccentButtonStyle(prominent: false, compact: true)) + + Spacer() + } + } + + // MARK: Native macOS share services + + private var nativeShareRow: some View { + HStack(spacing: 10) { + Text("Or share via") + .font(.uiCaption) + .foregroundStyle(.secondary) + SharePickerButton(items: [ShareContent.message, ShareContent.repoURL]) + Spacer() + } + } +} + +/// AppKit bridge that pops the system `NSSharingServicePicker` anchored to its own button - the +/// reliable way to surface Messages / Mail / AirDrop / etc. from SwiftUI. Kept as a dedicated +/// control (not on a movable background) per the repo's AppKit-interop learnings. +struct SharePickerButton: NSViewRepresentable { + let items: [Any] + + func makeCoordinator() -> Coordinator { Coordinator(items: items) } + + func makeNSView(context: Context) -> NSButton { + let button = NSButton(title: "More…", target: context.coordinator, action: #selector(Coordinator.present(_:))) + button.bezelStyle = .rounded + button.image = NSImage(systemSymbolName: "square.and.arrow.up", accessibilityDescription: "Share") + button.imagePosition = .imageLeading + context.coordinator.button = button + return button + } + + func updateNSView(_ nsView: NSButton, context: Context) { + context.coordinator.items = items + } + + final class Coordinator: NSObject, NSSharingServicePickerDelegate { + var items: [Any] + weak var button: NSButton? + + init(items: [Any]) { self.items = items } + + @objc func present(_ sender: NSButton) { + let picker = NSSharingServicePicker(items: items) + picker.delegate = self + picker.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) + } + } +} diff --git a/Tests/SumbeeKitTests/ShareContentTests.swift b/Tests/SumbeeKitTests/ShareContentTests.swift new file mode 100644 index 0000000..de35cbb --- /dev/null +++ b/Tests/SumbeeKitTests/ShareContentTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import SumbeeKit + +final class ShareContentTests: XCTestCase { + func testRepoURLIsCanonical() { + XCTAssertEqual(ShareContent.repoURLString, "https://github.com/wynnwu/Sumbee") + XCTAssertEqual(ShareContent.repoURL.absoluteString, ShareContent.repoURLString) + } + + func testShareCopyEndsWithLinkSoItSurvivesTruncation() { + // Viral best practice: the link must be present (and last) so a clipped post still works. + XCTAssertTrue(ShareContent.message.hasSuffix(ShareContent.repoURLString)) + XCTAssertTrue(ShareContent.tweet.contains(ShareContent.repoURLString)) + } + + func testTweetStaysWithinPlatformLimit() { + // Keep the tweet comfortably under 280 chars so a quote/RT still fits. + XCTAssertLessThanOrEqual(ShareContent.tweet.count, 280) + } + + func testTwitterShareURLIsWellFormedAndEncodesText() throws { + let url = try XCTUnwrap(ShareContent.twitterShareURL) + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + XCTAssertEqual(components.host, "twitter.com") + XCTAssertEqual(components.path, "/intent/tweet") + let text = components.queryItems?.first { $0.name == "text" }?.value + XCTAssertEqual(text, ShareContent.tweet) + } + + func testMailtoURLCarriesSubjectAndBody() throws { + let url = try XCTUnwrap(ShareContent.mailtoURL) + XCTAssertEqual(url.scheme, "mailto") + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let subject = components.queryItems?.first { $0.name == "subject" }?.value + let body = components.queryItems?.first { $0.name == "body" }?.value + XCTAssertEqual(subject, ShareContent.emailSubject) + XCTAssertEqual(body, ShareContent.message) + } +} diff --git a/screenshots/screen-01-app.png b/screenshots/screen-01-app.png index f508af3..3f43d90 100644 Binary files a/screenshots/screen-01-app.png and b/screenshots/screen-01-app.png differ diff --git a/screenshots/screen-02-settings-generation.png b/screenshots/screen-02-settings-generation.png new file mode 100644 index 0000000..bc80e60 Binary files /dev/null and b/screenshots/screen-02-settings-generation.png differ diff --git a/screenshots/screen-03-styles.png b/screenshots/screen-03-styles.png index 2f4f017..adedad4 100644 Binary files a/screenshots/screen-03-styles.png and b/screenshots/screen-03-styles.png differ diff --git a/screenshots/screen-04-output.png b/screenshots/screen-04-output.png index 97c4604..a43446d 100644 Binary files a/screenshots/screen-04-output.png and b/screenshots/screen-04-output.png differ diff --git a/specs/001-transcript-summarizer/spec.md b/specs/001-transcript-summarizer/spec.md index 57aeb87..807c5dd 100644 --- a/specs/001-transcript-summarizer/spec.md +++ b/specs/001-transcript-summarizer/spec.md @@ -383,6 +383,13 @@ Several of these (regenerate, streaming) reuse machinery the app already has (th transcript MUST be named after the **original video title** with a `Youtube - YYYY-MM-DD - ` prefix (e.g. `Youtube - 2026-06-22 - How to Build a Mac App.md`), and the summary's library title shows the video title. Non-YouTube assets keep the `YYYY-MM-DD HHmm - ` convention. +- **FR-046 (Share Sumbee)**: The main panel header MUST show a top-right "Enjoying Sumbee?" + prompt stacked above a compact "Share Now" button (across from the brand) that opens a share + modal. In the modal, copying the canonical public repo link to the clipboard MUST be the primary + (solid) action, with "Post on X" and "Email" offered as smaller secondary actions; the modal also + provides a ready-to-post message and hands off to the native macOS share services + (`NSSharingServicePicker`). The shared message MUST end with the repo link so it survives + truncation. No analytics or tracking are added; sharing only opens the user's chosen app. ### Key Entities *(include if feature involves data)*