Skip to content

Commit 3834a2d

Browse files
Add marketing screenshot pipeline and URL scheme window triggers
- Add pika://window/splash, about, help, preferences URL triggers - Add pika://window/resize URL trigger for main window sizing - Set window.title in createSecondaryWindow so kCGWindowName is populated - Fix AboutView text colour in light mode (displayOnTransparent: true) - Fix SplashWindow titlebar transparency (remove override of titleVisibility) - Add build/marketing/ pipeline: capture.sh captures all window types, generate.ts builds @2x/@1x web composites and shadow-trimmed figma PNGs, manifest.json defines 16 shots (dark + light for main, history, splash, about, help, preferences windows) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 069d0a7 commit 3834a2d

8 files changed

Lines changed: 1250 additions & 72 deletions

File tree

Pika/Services/PikaWindow.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class PikaWindow {
5858
window.titlebarAppearsTransparent = true
5959
}
6060

61+
window.title = title
6162
window.level = (Defaults[.appFloating] ? .floating : .normal) + 1
6263
window.isMovableByWindowBackground = true
6364
window.center()

Pika/Services/URLSchemeHandler.swift

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,17 @@ final class URLSchemeHandler: NSObject {
2424
}
2525

2626
switch action {
27-
case "format": handleFormat(task: task)
28-
case "pick": handlePick(task: task)
29-
case "system": handleSystem(task: task)
30-
case "copy": handleCopy(task: task)
31-
case "set": handleSet(task: task, hex: arg1)
32-
case "history": handleHistory(task: task)
33-
case "window": handleWindow(task: task, arg1: arg1, arg2: arg2)
27+
case "format": handleFormat(task: task)
28+
case "pick": handlePick(task: task)
29+
case "system": handleSystem(task: task)
30+
case "copy": handleCopy(task: task)
31+
case "set": handleSet(task: task, hex: arg1)
32+
case "history": handleHistory(task: task)
33+
case "window": handleWindow(task: task, arg1: arg1, arg2: arg2)
3434
case "appearance": handleAppearance(task: task)
35-
case "swap": NSApp.sendAction(#selector(AppDelegate.triggerSwap), to: nil, from: nil)
36-
case "undo": NSApp.sendAction(#selector(AppDelegate.triggerUndo), to: nil, from: nil)
37-
case "redo": NSApp.sendAction(#selector(AppDelegate.triggerRedo), to: nil, from: nil)
35+
case "swap": NSApp.sendAction(#selector(AppDelegate.triggerSwap), to: nil, from: nil)
36+
case "undo": NSApp.sendAction(#selector(AppDelegate.triggerUndo), to: nil, from: nil)
37+
case "redo": NSApp.sendAction(#selector(AppDelegate.triggerRedo), to: nil, from: nil)
3838
default: break
3939
}
4040
}
@@ -88,19 +88,20 @@ final class URLSchemeHandler: NSObject {
8888
private func handleHistory(task: String?) {
8989
let visible = Defaults[.historyDrawerVisible]
9090
switch task {
91-
case "show" where !visible: Defaults[.historyDrawerVisible] = true
92-
case "hide" where visible: Defaults[.historyDrawerVisible] = false
91+
case "show" where !visible: Defaults[.historyDrawerVisible] = true
92+
case "hide" where visible: Defaults[.historyDrawerVisible] = false
9393
case "toggle": Defaults[.historyDrawerVisible].toggle()
9494
default: break
9595
}
9696
}
9797

9898
private func handleWindow(task: String?, arg1: String?, arg2: String?) {
9999
switch task {
100-
case "about": NSApp.sendAction(#selector(AppDelegate.openAboutWindow), to: nil, from: nil)
101-
case "help": NSApp.sendAction(#selector(AppDelegate.openHelpWindow), to: nil, from: nil)
100+
case "about": NSApp.sendAction(#selector(AppDelegate.openAboutWindow), to: nil, from: nil)
101+
case "help": NSApp.sendAction(#selector(AppDelegate.openHelpWindow), to: nil, from: nil)
102102
case "preferences": NSApp.sendAction(#selector(AppDelegate.openPreferencesWindow), to: nil, from: nil)
103-
case "resize": handleWindowResize(w: arg1, h: arg2)
103+
case "splash": NSApp.sendAction(#selector(AppDelegate.openSplashWindow), to: nil, from: nil)
104+
case "resize": handleWindowResize(w: arg1, h: arg2)
104105
default: break
105106
}
106107
}
@@ -116,8 +117,8 @@ final class URLSchemeHandler: NSObject {
116117

117118
private func handleAppearance(task: String?) {
118119
switch task {
119-
case "light": NSApp.appearance = NSAppearance(named: .aqua)
120-
case "dark": NSApp.appearance = NSAppearance(named: .darkAqua)
120+
case "light": NSApp.appearance = NSAppearance(named: .aqua)
121+
case "dark": NSApp.appearance = NSAppearance(named: .darkAqua)
121122
case "system": NSApp.appearance = nil
122123
default: break
123124
}

Pika/Services/WindowCoordinator.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,10 @@ class WindowCoordinator: NSObject {
139139

140140
func openSplashWindow() {
141141
splashWindow = PikaWindow.createSecondaryWindow(
142-
title: "Splash",
142+
title: PikaText.textAppName,
143143
size: NSRect(x: 0, y: 0, width: 650, height: 380),
144144
styleMask: [.titled, .fullSizeContentView]
145145
)
146-
splashWindow.title = PikaText.textAppName
147-
splashWindow.titleVisibility = .visible
148146
splashTouchBarController = SplashTouchBarController(window: splashWindow)
149147
splashWindow.contentView = NSHostingView(rootView: SplashView().edgesIgnoringSafeArea(.all))
150148
splashWindow.fadeIn(nil)

Pika/Views/AboutView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ struct AboutView: View {
1616
))
1717
.frame(maxWidth: .infinity)
1818
.frame(height: 200)
19-
AppVersion()
19+
AppVersion(displayOnTransparent: true)
2020
.padding(.bottom, 32)
2121
}
2222
VStack(spacing: 0) {

build/marketing/capture.sh

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
#!/bin/sh
2-
# Usage: ./capture.sh <output.png> <fg-hex> <bg-hex> <appearance> [history]
3-
# Example: ./capture.sh window-dark-1.png fbbf24 e74661 dark hide
2+
# Usage: ./capture.sh <output.png> <fg-hex> <bg-hex> <appearance> [history] [window]
3+
# Example: ./capture.sh window-dark-1.png fbbf24 e74661 dark hide main
4+
# ./capture.sh window-about.png 0a31bc 432f9b light hide about
45
#
56
# Set PIKA_APP to target a specific Pika.app rather than the system default:
67
# PIKA_APP="/path/to/Pika.app" ./capture.sh ...
78
#
89
# Pika must be running (generate.ts launches it automatically when PIKA_APP is set).
10+
#
11+
# Windows:
12+
# main — the primary Pika colour picker window (default)
13+
# about — the About window (pika://window/about)
14+
# help — the Help window (pika://window/help)
15+
# preferences — the Preferences window (pika://window/preferences)
16+
# splash — the first-run/splash window (pika://window/splash)
17+
#
18+
# Secondary windows are detected by z-order: the URL trigger calls makeKeyAndOrderFront,
19+
# so the target window is always the frontmost Pika window above the main window level.
920

1021
OUTPUT="$1"
1122
FG="$2"
1223
BG="$3"
1324
APPEARANCE="$4"
1425
HISTORY="${5:-hide}"
26+
WINDOW="${6:-main}"
1527

1628
# Send a pika:// URL — via a specific app if PIKA_APP is set, otherwise system default
1729
pika_open() {
@@ -31,11 +43,23 @@ pika_open "pika://set/background/$BG"
3143
sleep 0.3
3244
pika_open "pika://history/$HISTORY"
3345
sleep 0.3
34-
pika_open "pika://window/resize/480/300"
35-
sleep 0.5
3646

37-
# Look up the Pika window ID
38-
WINDOW_ID=$(swift - <<'EOF'
47+
# Only resize the main window — secondary windows have fixed sizes
48+
if [ "$WINDOW" = "main" ]; then
49+
pika_open "pika://window/resize/480/300"
50+
sleep 0.5
51+
fi
52+
53+
# Open the requested secondary window
54+
if [ "$WINDOW" != "main" ]; then
55+
pika_open "pika://window/$WINDOW"
56+
sleep 0.8
57+
fi
58+
59+
# Look up the window ID
60+
if [ "$WINDOW" = "main" ]; then
61+
# Main window: floating level (3)
62+
WINDOW_ID=$(swift - <<'EOF'
3963
import Quartz
4064
let list = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] ?? []
4165
for w in list {
@@ -47,9 +71,25 @@ for w in list {
4771
}
4872
EOF
4973
)
74+
else
75+
# Secondary windows: the URL trigger calls makeKeyAndOrderFront, so the target window
76+
# is the frontmost Pika window above the main window level (z-order, first in list).
77+
WINDOW_ID=$(swift - <<'EOF'
78+
import Quartz
79+
let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]] ?? []
80+
for w in list {
81+
if (w["kCGWindowOwnerName"] as? String) == "Pika",
82+
let layer = w["kCGWindowLayer"] as? Int, layer > 3 {
83+
print(w["kCGWindowNumber"] as? Int ?? 0)
84+
break
85+
}
86+
}
87+
EOF
88+
)
89+
fi
5090

5191
if [ -z "$WINDOW_ID" ]; then
52-
echo "Error: Pika window not found. Is Pika running?" >&2
92+
echo "Error: Pika window '$WINDOW' not found. Is Pika running?" >&2
5393
exit 1
5494
fi
5595

build/marketing/generate.ts

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
33
* Pika marketing asset generator.
44
*
55
* Usage:
6-
* npx tsx build/marketing/generate.ts --version <version> [--capture] [--web] [--figma] [--all]
6+
* npx tsx build/marketing/generate.ts --version <version> [--app <path>] [--capture] [--web] [--figma] [--all]
77
*
88
* Flags:
99
* --version Required. Version string, e.g. 1.3.0 or 1.2.0-beta1.
10-
* --capture Launch the exported Pika.app, capture all shots, quit.
10+
* --app Optional. Path to Pika.app. Overrides auto-detection.
11+
* --capture Launch Pika.app, capture all shots, quit.
1112
* --web Composite source PNGs into website JPGs (@2x + @1x).
1213
* --figma Copy shadow-trimmed PNGs to figma/ output folder.
1314
* --all Run capture, web, and figma in sequence.
1415
*
15-
* Pika.app is auto-detected from:
16-
* pika-releases/Production Exports/Pika * (v<version>)/Pika.app
17-
*
18-
* If no export is found, falls back to whatever Pika is registered system-wide.
16+
* Pika.app resolution order:
17+
* 1. --app <path> if provided
18+
* 2. pika-releases/Production Exports/Pika * (v<version>)/Pika.app
19+
* 3. Whatever Pika is registered system-wide (fallback)
1920
*
2021
* Output: pika-releases/Marketing/v<version>/
2122
*/
@@ -41,6 +42,7 @@ const RELEASES_DIR = join(WORKSPACE_ROOT, "pika-releases", "Marketing");
4142
// ---------------------------------------------------------------------------
4243

4344
interface Shot {
45+
window?: "about" | "help" | "preferences" | "splash";
4446
file: string;
4547
fg: string;
4648
bg: string;
@@ -105,7 +107,7 @@ function findExportedApp(ver: string): string | undefined {
105107
return existsSync(appPath) ? appPath : undefined;
106108
}
107109

108-
const pikaApp = findExportedApp(version);
110+
const pikaApp = arg("--app") ?? findExportedApp(version);
109111

110112
// ---------------------------------------------------------------------------
111113
// Capture
@@ -117,6 +119,9 @@ async function runCaptureStep(): Promise<void> {
117119

118120
if (pikaApp) {
119121
console.log(` App: ${pikaApp}`);
122+
console.log(" Quitting any existing Pika…");
123+
spawnSync("pkill", ["-x", "Pika"], { stdio: "pipe" });
124+
await sleep(500);
120125
console.log(" Launching Pika…");
121126
spawnSync("open", ["-a", pikaApp], { stdio: "inherit" });
122127
// Allow Pika to fully initialise before sending URL triggers
@@ -130,7 +135,7 @@ async function runCaptureStep(): Promise<void> {
130135

131136
for (const shot of manifest.shots) {
132137
const outputPath = join(SOURCE_DIR, shot.file);
133-
const cmd = `"${captureScript}" "${outputPath}" "${shot.fg}" "${shot.bg}" "${shot.appearance}" "${shot.history}"`;
138+
const cmd = `"${captureScript}" "${outputPath}" "${shot.fg}" "${shot.bg}" "${shot.appearance}" "${shot.history}" "${shot.window ?? "main"}"`;
134139
console.log(` ${shot.file}`);
135140
execSync(cmd, { stdio: "inherit", env });
136141
}
@@ -149,15 +154,22 @@ function sleep(ms: number): Promise<void> {
149154
// Web composite
150155
// ---------------------------------------------------------------------------
151156

157+
// Canvas dimensions match the website @2x asset size.
158+
// screencapture gives 960x600px windows (480x300pt at @2x Retina).
159+
// Windows are placed at full captured size on the @2x canvas.
160+
// @1x output is a 50% resize of the @2x canvas.
152161
const CANVAS_WIDTH = 2380;
153162
const CANVAS_HEIGHT = 838;
154163

155-
// Layout: left (x=-30, y=54), center (x=893, y=0), right (x=1820, y=54)
164+
// Three-window fan layout on the @2x canvas (2380x838).
165+
// Center window is in front (composited last), side windows partially off-screen.
166+
// Draw order: left → right → center (so center appears on top).
156167
const WINDOW_POSITIONS = [
157-
{ left: -30, top: 54 },
158-
{ left: 893, top: 0 },
159-
{ left: 1820, top: 54 },
168+
{ left: -50, top: 160 }, // left (index 0 in manifest.web)
169+
{ left: 710, top: 80 }, // center (index 1)
170+
{ left: 1470, top: 160 }, // right (index 2)
160171
];
172+
const COMPOSITE_ORDER = [0, 2, 1]; // draw left and right first, center last
161173

162174
const BACKGROUNDS: Record<"dark" | "light", { r: number; g: number; b: number }> = {
163175
dark: { r: 26, g: 26, b: 26 },
@@ -168,29 +180,32 @@ async function compositeWeb(mode: "dark" | "light"): Promise<void> {
168180
const files = manifest.web[mode];
169181
const bg = BACKGROUNDS[mode];
170182

171-
const trimmed = await Promise.all(
172-
files.map((f) =>
173-
sharp(join(SOURCE_DIR, f))
174-
.trim()
175-
.toBuffer({ resolveWithObject: true })
176-
)
177-
);
178-
179-
const composites = trimmed.map(({ data }, i) => ({
180-
input: data,
181-
left: Math.max(0, WINDOW_POSITIONS[i].left),
182-
top: WINDOW_POSITIONS[i].top,
183-
}));
183+
// Build composites in COMPOSITE_ORDER so center window renders on top.
184+
const composites: Parameters<ReturnType<typeof sharp>["composite"]>[0] = [];
185+
for (const idx of COMPOSITE_ORDER) {
186+
const file = join(SOURCE_DIR, files[idx]);
187+
let left = WINDOW_POSITIONS[idx].left;
188+
const top = WINDOW_POSITIONS[idx].top;
189+
190+
let input: Buffer;
191+
if (left < 0) {
192+
// Crop off the portion that would be off-screen to the left.
193+
const meta = await sharp(file).metadata();
194+
input = await sharp(file)
195+
.extract({ left: -left, top: 0, width: (meta.width ?? 960) + left, height: meta.height ?? 600 })
196+
.toBuffer();
197+
left = 0;
198+
} else {
199+
input = await sharp(file).toBuffer();
200+
}
201+
202+
composites.push({ input, left, top });
203+
}
184204

185205
mkdirSync(OUTPUT_DIR, { recursive: true });
186206

187207
const composited = await sharp({
188-
create: {
189-
width: CANVAS_WIDTH,
190-
height: CANVAS_HEIGHT,
191-
channels: 3,
192-
background: bg,
193-
},
208+
create: { width: CANVAS_WIDTH, height: CANVAS_HEIGHT, channels: 3, background: bg },
194209
})
195210
.composite(composites)
196211
.jpeg({ quality: 95 })
@@ -219,14 +234,30 @@ async function runWebStep(): Promise<void> {
219234
// Figma export
220235
// ---------------------------------------------------------------------------
221236

237+
// Remove macOS window shadow from a PNG.
238+
// screencapture includes the shadow as semi-transparent pixels outside the window frame.
239+
// sharp's default .trim() only removes fully-transparent pixels (alpha === 0), leaving
240+
// residual shadow. This function first zeros out any pixel with alpha < 200, then trims.
241+
async function removeShadow(src: string): Promise<Buffer> {
242+
const { data, info } = await sharp(src).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
243+
const { width, height, channels } = info;
244+
for (let i = 0; i < data.length; i += channels) {
245+
if (data[i + 3] < 200) {
246+
data[i] = 0; data[i + 1] = 0; data[i + 2] = 0; data[i + 3] = 0;
247+
}
248+
}
249+
return sharp(Buffer.from(data), { raw: { width, height, channels } }).trim().png().toBuffer();
250+
}
251+
222252
async function runFigmaStep(): Promise<void> {
223253
console.log("\n── Figma exports ────────────────────────────────");
224254
mkdirSync(FIGMA_DIR, { recursive: true });
225255

226256
for (const [key, file] of Object.entries(manifest.figma)) {
227257
const src = join(SOURCE_DIR, file);
228258
const dest = join(FIGMA_DIR, `${key}.png`);
229-
await sharp(src).trim().toFile(dest);
259+
const buf = await removeShadow(src);
260+
await sharp(buf).toFile(dest);
230261
console.log(` ${key}.png ← ${file}`);
231262
}
232263
}

0 commit comments

Comments
 (0)