Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions .github/workflows/screenshot.yml
Original file line number Diff line number Diff line change
@@ -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 = '<!-- sumbee-screenshots -->';
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,
});
}
4 changes: 4 additions & 0 deletions Sources/SumbeeKit/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions Sources/SumbeeKit/Models/ShareContent.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
11 changes: 11 additions & 0 deletions Sources/SumbeeKit/State/AppState.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import Foundation
import AppKit

/// A transient user-facing notice.
public struct ToastItem: Identifiable, Equatable {
Expand All @@ -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
Expand Down Expand Up @@ -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!")
}
}
17 changes: 11 additions & 6 deletions Sources/SumbeeKit/Views/Design/Components.swift
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
17 changes: 17 additions & 0 deletions Sources/SumbeeKit/Views/MainPanel/MainPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading