← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


commit 577bbfaf924dc233025c203a3c106a9cf36dac07
parent cc62a31d019dc3a62ffcb4a215cdc562a68ee8ee
Author: Michael Camilleri <[email protected]>
Date:   Fri,  3 Apr 2026 00:45:31 +0900

Add automated screenshot mechanism for other platforms

This commit adds scripts for creating screenshots for other Apple
platforms.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat

MListless.xcodeproj/project.pbxproj | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListless.xcodeproj/xcshareddata/xcschemes/Listless watchOS.xcscheme | 10++++++++++
MListlessMac/ListlessMacApp.swift | 9++++++++-
MListlessWatch/ListlessWatchApp.swift | 14+++++++++++++-
RScripts/screenshots-iphone-compose.swift -> Scripts/screenshots-ios-compose.swift | 24+++++++++++++++---------
AScripts/screenshots-ipad.sh | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MScripts/screenshots-iphone.sh | 2+-
AScripts/screenshots-mac-desktop.swift | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AScripts/screenshots-mac.sh | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AScripts/screenshots-watch.sh | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/UI/ListlessMacScreenshots.swift | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/UI/ListlessWatchScreenshots.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 20++++++++++++++++++++

13 files changed, 968 insertions(+), 12 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj

@@ -41,6 +41,7 @@
 		42CB50C94EDF48562F680B56 /* ItemStoreDeleteAndNormalizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5EFAB5629C31FC2C6FFE0A1 /* ItemStoreDeleteAndNormalizeTests.swift */; };
 		47D9272442A5F15B324D3DAC /* ItemStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8EC97702E7B218A03B7898 /* ItemStore.swift */; };
 		4A0682E85B50DF42ECF83B48 /* Listless watchOS.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = C6812E535A24C599C28F9278 /* Listless watchOS.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+		4CA383C2B90DA7EF080BDABB /* ListlessWatchScreenshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76A89CFEB95B9BB9A0540CD9 /* ListlessWatchScreenshots.swift */; };
 		4DD2030E321567BD25661760 /* SyncDiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */; };
 		4F150E893D1F78BC23A49659 /* AccentColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C96B8A403274C4CC7F57460 /* AccentColorTests.swift */; };
 		53700EA974FE4AD771FE89EC /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; };
@@ -102,6 +103,7 @@
 		DBFE5DADCAAF26CF77245410 /* ItemListTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848DBC251E2D2EB7BD089768 /* ItemListTypes.swift */; };
 		DC103B1BFDC5940F63DD48ED /* ItemRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05AB46824DCDD04903EA4C82 /* ItemRowDragGesture.swift */; };
 		DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
+		DD2F098B472D048B66CEC8B1 /* ListlessMacScreenshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3578459F9888CA59AACBEC /* ListlessMacScreenshots.swift */; };
 		DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */; };
 		E0BDC0FCAB43CEE0C9AC5279 /* ItemListView+Undo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4B98BD127A56CE4669DCD5 /* ItemListView+Undo.swift */; };
 		E12C1304464FC7799856B2BA /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B048B19C5219862BBED2E7 /* TestHelpers.swift */; };
@@ -128,6 +130,13 @@
 			remoteGlobalIDString = 0FB4F07A37999BBC6DFE4DBB;
 			remoteInfo = "Listless macOS";
 		};
+		47C07B5C46AB5A2CD8741632 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 3256C2BF8F1DAF371DA32120 /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 9BDC1B2175AB9CE26790448D;
+			remoteInfo = "Listless watchOS";
+		};
 		642EE6A5908DB4216F049E9C /* PBXContainerItemProxy */ = {
 			isa = PBXContainerItemProxy;
 			containerPortal = 3256C2BF8F1DAF371DA32120 /* Project object */;
@@ -186,6 +195,7 @@
 		1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacApp.swift; sourceTree = "<group>"; };
 		20A09A6C1C2251E96E1B5D96 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
 		23060125DF03EE84F3AED8CB /* ItemListView+PullToClear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+PullToClear.swift"; sourceTree = "<group>"; };
+		2523E01C8F130C2799123479 /* Listless watchOS UI Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Listless watchOS UI Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
 		2B8EC97702E7B218A03B7898 /* ItemStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemStore.swift; sourceTree = "<group>"; };
 		3048ACA1CAF1284F99E1400E /* ItemRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowView.swift; sourceTree = "<group>"; };
 		3116A37F1353BF6E18308DD2 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
@@ -216,6 +226,7 @@
 		72BAC76C5B5ED291048705CF /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
 		74255E6B6C40899E9B17D927 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		75B048B19C5219862BBED2E7 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
+		76A89CFEB95B9BB9A0540CD9 /* ListlessWatchScreenshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessWatchScreenshots.swift; sourceTree = "<group>"; };
 		78232E9D9F77FA3630F9D089 /* ItemListView+PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+PullToCreate.swift"; sourceTree = "<group>"; };
 		7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
 		82F2C024B6C1F2F1FD7A25B0 /* ItemListView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+Logic.swift"; sourceTree = "<group>"; };
@@ -262,6 +273,7 @@
 		E8F7B7D46010E217FC0A5BFF /* TutorialSeeder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialSeeder.swift; sourceTree = "<group>"; };
 		E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
 		EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacUITests.swift; sourceTree = "<group>"; };
+		EE3578459F9888CA59AACBEC /* ListlessMacScreenshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacScreenshots.swift; sourceTree = "<group>"; };
 		F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundClickMonitor.swift; sourceTree = "<group>"; };
 		F1E998119283F784B9ADEE28 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
 		F31EDB2C34C8B255000A2525 /* ItemListViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListViewProtocol.swift; sourceTree = "<group>"; };
@@ -294,7 +306,9 @@
 			children = (
 				16D0F534D5B4D5ECF74CF6E6 /* ListlessiOSScreenshots.swift */,
 				88C0D6F2667BD14F29CB84E5 /* ListlessiOSUITests.swift */,
+				EE3578459F9888CA59AACBEC /* ListlessMacScreenshots.swift */,
 				EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */,
+				76A89CFEB95B9BB9A0540CD9 /* ListlessWatchScreenshots.swift */,
 			);
 			name = UI;
 			path = Tests/UI;
@@ -320,6 +334,7 @@
 				CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */,
 				B88DC6E36FA41DCB6CEB9647 /* Listless macOS Unit Tests.xctest */,
 				5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */,
+				2523E01C8F130C2799123479 /* Listless watchOS UI Tests.xctest */,
 				C6812E535A24C599C28F9278 /* Listless watchOS.app */,
 			);
 			name = Products;
@@ -662,6 +677,24 @@
 			productReference = CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */;
 			productType = "com.apple.product-type.bundle.ui-testing";
 		};
+		FAC694EFB69F0D9F12557137 /* Listless watchOS UI Tests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = C82D32230C96FF767EC26C77 /* Build configuration list for PBXNativeTarget "Listless watchOS UI Tests" */;
+			buildPhases = (
+				6B19BAB29A5C66A5E7516EB8 /* Sources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				05AD385080A44E4C08F53091 /* PBXTargetDependency */,
+			);
+			name = "Listless watchOS UI Tests";
+			packageProductDependencies = (
+			);
+			productName = "Listless watchOS UI Tests";
+			productReference = 2523E01C8F130C2799123479 /* Listless watchOS UI Tests.xctest */;
+			productType = "com.apple.product-type.bundle.ui-testing";
+		};
 /* End PBXNativeTarget section */
 
 /* Begin PBXProject section */
@@ -701,6 +734,11 @@
 						ProvisioningStyle = Automatic;
 						TestTargetID = 0FB4F07A37999BBC6DFE4DBB;
 					};
+					FAC694EFB69F0D9F12557137 = {
+						DevelopmentTeam = 7TD7PZBNXP;
+						ProvisioningStyle = Automatic;
+						TestTargetID = 9BDC1B2175AB9CE26790448D;
+					};
 				};
 			};
 			buildConfigurationList = CAACA40A09D5F78ECE7A0EDF /* Build configuration list for PBXProject "Listless" */;
@@ -724,6 +762,7 @@
 				ECF4D3D0597D0648A1FBC4A4 /* Listless macOS UI Tests */,
 				7F0B17D1EC9FD4A80BC99002 /* Listless macOS Unit Tests */,
 				9BDC1B2175AB9CE26790448D /* Listless watchOS */,
+				FAC694EFB69F0D9F12557137 /* Listless watchOS UI Tests */,
 			);
 		};
 /* End PBXProject section */
@@ -778,6 +817,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				DD2F098B472D048B66CEC8B1 /* ListlessMacScreenshots.swift in Sources */,
 				5E6BE0BA881F6CAEF455D9ED /* ListlessMacUITests.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
@@ -886,6 +926,14 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		6B19BAB29A5C66A5E7516EB8 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				4CA383C2B90DA7EF080BDABB /* ListlessWatchScreenshots.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		8A2F25417981C1E116479B25 /* Sources */ = {
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
@@ -925,6 +973,11 @@
 			target = 34A03D42B91730DEAC2EBD8E /* Listless iOS */;
 			targetProxy = 6F7E207AE10E1516EF8A683C /* PBXContainerItemProxy */;
 		};
+		05AD385080A44E4C08F53091 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 9BDC1B2175AB9CE26790448D /* Listless watchOS */;
+			targetProxy = 47C07B5C46AB5A2CD8741632 /* PBXContainerItemProxy */;
+		};
 		56F7D0BF0FAA18615E95FB41 /* PBXTargetDependency */ = {
 			isa = PBXTargetDependency;
 			target = 0FB4F07A37999BBC6DFE4DBB /* Listless macOS */;
@@ -979,6 +1032,25 @@
 			};
 			name = Release;
 		};
+		10A7B7F1EF302597A276E649 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				GENERATE_INFOPLIST_FILE = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@loader_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = net.inqk.listless.watchos.uitests;
+				SDKROOT = watchos;
+				SKIP_INSTALL = YES;
+				TARGETED_DEVICE_FAMILY = 4;
+				TEST_TARGET_NAME = "Listless watchOS";
+			};
+			name = Debug;
+		};
 		2D4E3CC5FF8E6299F754CCFC /* Debug */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
@@ -1012,6 +1084,25 @@
 			};
 			name = Release;
 		};
+		8AEF59A64EEB64E23C516357 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				GENERATE_INFOPLIST_FILE = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@loader_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = net.inqk.listless.watchos.uitests;
+				SDKROOT = watchos;
+				SKIP_INSTALL = YES;
+				TARGETED_DEVICE_FAMILY = 4;
+				TEST_TARGET_NAME = "Listless watchOS";
+			};
+			name = Release;
+		};
 		A59B52CB6CD91C01F164C0F6 /* Debug */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
@@ -1374,6 +1465,15 @@
 			defaultConfigurationIsVisible = 0;
 			defaultConfigurationName = Debug;
 		};
+		C82D32230C96FF767EC26C77 /* Build configuration list for PBXNativeTarget "Listless watchOS UI Tests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				10A7B7F1EF302597A276E649 /* Debug */,
+				8AEF59A64EEB64E23C516357 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
 		CAACA40A09D5F78ECE7A0EDF /* Build configuration list for PBXProject "Listless" */ = {
 			isa = XCConfigurationList;
 			buildConfigurations = (

diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless watchOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless watchOS.xcscheme

@@ -55,6 +55,16 @@
          </BuildableReference>
       </MacroExpansion>
       <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "FAC694EFB69F0D9F12557137"
+               BuildableName = "Listless watchOS UI Tests.xctest"
+               BlueprintName = "Listless watchOS UI Tests"
+               ReferencedContainer = "container:Listless.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
       </Testables>
    </TestAction>
    <LaunchAction

diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift

@@ -28,9 +28,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
     }
 
     override init() {
-        let isUITesting = ProcessInfo.processInfo.arguments.contains("UI_TESTING")
+        let args = ProcessInfo.processInfo.arguments
+        let isUITesting = args.contains("UI_TESTING")
         persistenceController = isUITesting ? PersistenceController(inMemory: true) : .shared
         super.init()
+
+        if isUITesting, args.contains("SCREENSHOT_LIGHT") {
+            UserDefaults.standard.set(1, forKey: Self.appearanceModeKey)
+        } else if isUITesting, args.contains("SCREENSHOT_DARK") {
+            UserDefaults.standard.set(2, forKey: Self.appearanceModeKey)
+        }
     }
 
     func applicationDidFinishLaunching(_ notification: Notification) {

diff --git a/ListlessWatch/ListlessWatchApp.swift b/ListlessWatch/ListlessWatchApp.swift

@@ -2,11 +2,23 @@ import SwiftUI
 
 @main
 struct ListlessWatchApp: App {
-    private let persistenceController = PersistenceController.shared
+    private let persistenceController: PersistenceController
     private let keyValueSyncBridge = KeyValueSyncBridge(keys: ["listName", "colorTheme"])
 
     init() {
+        let args = ProcessInfo.processInfo.arguments
+        let isUITesting = args.contains("UI_TESTING")
+        persistenceController = isUITesting ? PersistenceController(inMemory: true) : .shared
         keyValueSyncBridge.start()
+
+        if isUITesting, args.contains("SCREENSHOT_SEED") {
+            let store = ItemStore(persistenceController: persistenceController)
+            try? store.createItem(title: "Make smartband", sortOrder: 1000)
+            try? store.createItem(title: "Add custom faces", sortOrder: 2000)
+            let item = try? store.createItem(title: "Focus on fitness", sortOrder: 3000)
+            if let item { try? store.complete(itemID: item.id) }
+            try? store.save()
+        }
     }
 
     var body: some Scene {

diff --git a/Scripts/screenshots-iphone-compose.swift b/Scripts/screenshots-ios-compose.swift

@@ -7,7 +7,9 @@ import Foundation
 import ImageIO
 
 guard CommandLine.arguments.count >= 4 else {
-    fputs("Usage: compose-screenshot.swift <input.png> <output.png> <text>\n", stderr)
+    fputs(
+        "Usage: screenshots-ios-compose.swift <input.png> <output.png> <text> [width height]\n",
+        stderr)
     exit(1)
 }
 
@@ -15,9 +17,11 @@ let inputPath = CommandLine.arguments[1]
 let outputPath = CommandLine.arguments[2]
 let text = CommandLine.arguments[3]
 
-// Output dimensions (6.9" App Store)
-let canvasWidth: CGFloat = 1320
-let canvasHeight: CGFloat = 2868
+// Output dimensions (default: 6.9" iPhone for App Store)
+let canvasWidth: CGFloat =
+    CommandLine.arguments.count >= 5 ? CGFloat(Int(CommandLine.arguments[4]) ?? 1320) : 1320
+let canvasHeight: CGFloat =
+    CommandLine.arguments.count >= 6 ? CGFloat(Int(CommandLine.arguments[5]) ?? 2868) : 2868
 
 // Background: rgb(30, 16, 40)
 let bgColor = CGColor(red: 30.0 / 255.0, green: 16.0 / 255.0, blue: 40.0 / 255.0, alpha: 1.0)
@@ -56,9 +60,11 @@ context.setFillColor(bgColor)
 context.fill(CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight))
 
 // Scale device image to fit width with padding, leaving space for text
-let bottomPadding: CGFloat = 60
-let sidePadding: CGFloat = 60
-let topReserved: CGFloat = 300
+let isCompact = canvasWidth < 600
+let isLandscape = canvasWidth > canvasHeight
+let bottomPadding: CGFloat = isCompact ? -60 : isLandscape ? -350 : 60
+let sidePadding: CGFloat = isCompact ? 20 : isLandscape ? 20 : 60
+let topReserved: CGFloat = isCompact ? 90 : isLandscape ? 200 : 300
 let maxDeviceWidth = canvasWidth - sidePadding * 2
 let maxDeviceHeight = canvasHeight - bottomPadding - topReserved
 let scale = min(maxDeviceWidth / deviceWidth, maxDeviceHeight / deviceHeight, 1.0)
@@ -71,7 +77,7 @@ context.draw(
     deviceImage, in: CGRect(x: deviceX, y: deviceY, width: scaledWidth, height: scaledHeight))
 
 // Draw text centered above device
-let fontSize: CGFloat = 72
+let fontSize: CGFloat = isCompact ? canvasWidth * 0.08 : isLandscape ? canvasHeight * 0.06 : canvasWidth * 0.0545
 let font = NSFont.systemFont(ofSize: fontSize, weight: .bold)
 let attributes: [NSAttributedString.Key: Any] = [
     .font: font,
@@ -81,7 +87,7 @@ let attrString = NSAttributedString(string: text, attributes: attributes)
 let ctLine = CTLineCreateWithAttributedString(attrString)
 let textBounds = CTLineGetBoundsWithOptions(ctLine, .useOpticalBounds)
 
-let textAreaTop = canvasHeight
+let textAreaTop = isLandscape ? canvasHeight - 40 : canvasHeight
 let textAreaBottom = deviceY + scaledHeight
 let textX = (canvasWidth - textBounds.width) / 2 - textBounds.origin.x
 let textY = (textAreaTop + textAreaBottom) / 2 - textBounds.height / 2 - textBounds.origin.y

diff --git a/Scripts/screenshots-ipad.sh b/Scripts/screenshots-ipad.sh

@@ -0,0 +1,139 @@
+#!/bin/bash
+set -euo pipefail
+
+DO_COMPOSE=false
+while getopts "c" opt; do
+  case $opt in
+    c) DO_COMPOSE=true ;;
+    *) ;;
+  esac
+done
+shift $((OPTIND - 1))
+
+MAJOR="${1:-26}"
+RUNTIME=$(xcrun simctl list runtimes available \
+  | grep "iOS ${MAJOR}\." \
+  | sed 's/.*iOS \([0-9.]*\).*/\1/' \
+  | sort -t. -k1,1n -k2,2n \
+  | tail -1)
+
+if [ -z "$RUNTIME" ]; then
+  echo "No available iOS ${MAJOR}.x simulator runtime found." >&2
+  exit 1
+fi
+
+# Prefer iPad Pro 13" for ASC screenshots (2064x2752)
+DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+  | grep "iPad Pro 13" \
+  | head -1 \
+  | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/')
+if [ -z "$DEVICE" ]; then
+  DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+    | grep "iPad" \
+    | head -1 \
+    | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/')
+fi
+
+if [ -z "$DEVICE" ]; then
+  echo "No available iPad simulator found for iOS ${RUNTIME}." >&2
+  exit 1
+fi
+
+echo "Using ${DEVICE}, iOS ${RUNTIME}"
+
+# Boot simulator if needed
+UDID=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+  | grep "$DEVICE" \
+  | head -1 \
+  | sed 's/.*(\([A-F0-9-]*\)).*/\1/')
+
+xcrun simctl boot "$UDID" 2>/dev/null || true
+
+# Override status bar: 9:41, full signal, full battery
+xcrun simctl status_bar "$UDID" override \
+  --time "9:41" \
+  --batteryState discharging \
+  --batteryLevel 100 \
+  --wifiBars 3 \
+  --cellularBars 4 \
+  --cellularMode active \
+  --dataNetwork wifi
+
+echo "Status bar overridden"
+
+SCREENSHOT_TMP="/tmp/listless-screenshots"
+FRAMED_TMP="/tmp/listless-framed"
+MARKETING_DIR="$(pwd)/Marketing"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# iPad Pro 13" ASC canvas dimensions
+CANVAS_WIDTH=2064
+CANVAS_HEIGHT=2752
+
+CAPTIONS=(
+  "Syncs via iCloud"
+  "Minimalist"
+  "Gesture Based"
+  "(Slightly) Customisable"
+)
+
+# Run screenshot tests (same iOS UI test target, different simulator)
+xcodebuild test \
+  -scheme "Listless iOS" \
+  -destination "platform=iOS Simulator,name=${DEVICE},OS=${RUNTIME}" \
+  -only-testing:"Listless iOS UI Tests/ListlessiOSScreenshots" \
+  2>&1
+
+# Clear status bar override (ignore errors if simulator already shut down)
+xcrun simctl status_bar "$UDID" clear 2>/dev/null || true
+
+if ! ls "${SCREENSHOT_TMP}"/*.png 1>/dev/null 2>&1; then
+  echo "No screenshots found in ${SCREENSHOT_TMP}."
+  exit 1
+fi
+
+mkdir -p "${MARKETING_DIR}"
+
+if [ "$DO_COMPOSE" = true ]; then
+  mkdir -p "${FRAMED_TMP}"
+
+  echo ""
+  echo "Framing screenshots..."
+  n=0
+  for file in "${SCREENSHOT_TMP}"/0*.png; do
+    shortcuts run "Frame Screenshots" -i "$file" -o "${FRAMED_TMP}/framed_${n}.png"
+    echo "  Framed screenshot ${n}"
+    n=$((n + 1))
+  done
+
+  echo "Composing final images..."
+  n=0
+  for file in "${FRAMED_TMP}"/framed_*.png; do
+    caption="${CAPTIONS[$n]:-}"
+    swift "${SCRIPT_DIR}/screenshots-ios-compose.swift" "$file" "${MARKETING_DIR}/ipad_${n}.png" "$caption" "$CANVAS_WIDTH" "$CANVAS_HEIGHT"
+    echo "  ipad_${n}.png — ${caption}"
+    n=$((n + 1))
+  done
+
+  rm -rf "${FRAMED_TMP}"
+else
+  OUTPUT_DIR="$(pwd)"
+  echo ""
+  n=0
+  for file in "${SCREENSHOT_TMP}"/0*.png; do
+    cp "$file" "${OUTPUT_DIR}/ipad_${n}.png"
+    n=$((n + 1))
+  done
+fi
+
+rm -rf "${SCREENSHOT_TMP}"
+
+if [ "$DO_COMPOSE" = true ]; then
+  echo ""
+  echo "Screenshots saved to ${MARKETING_DIR}/"
+  ls -la "${MARKETING_DIR}"/ipad_*.png
+else
+  echo ""
+  echo "Screenshots saved to ${OUTPUT_DIR}/"
+  ls -la "${OUTPUT_DIR}"/ipad_*.png
+fi

diff --git a/Scripts/screenshots-iphone.sh b/Scripts/screenshots-iphone.sh

@@ -101,7 +101,7 @@ if [ "$DO_COMPOSE" = true ]; then
   n=0
   for file in "${FRAMED_TMP}"/framed_*.png; do
     caption="${CAPTIONS[$n]:-}"
-    swift "${SCRIPT_DIR}/screenshots-iphone-compose.swift" "$file" "${MARKETING_DIR}/iphone_${n}.png" "$caption"
+    swift "${SCRIPT_DIR}/screenshots-ios-compose.swift" "$file" "${MARKETING_DIR}/iphone_${n}.png" "$caption"
     echo "  iphone_${n}.png — ${caption}"
     n=$((n + 1))
   done

diff --git a/Scripts/screenshots-mac-desktop.swift b/Scripts/screenshots-mac-desktop.swift

@@ -0,0 +1,178 @@
+#!/usr/bin/env swift
+
+import AppKit
+import CoreGraphics
+import CoreText
+import Foundation
+import ImageIO
+
+guard CommandLine.arguments.count >= 3 else {
+    fputs("Usage: screenshots-mac-desktop.swift <window.png> <output.png> [wallpaper]\n", stderr)
+    exit(1)
+}
+
+let windowPath = CommandLine.arguments[1]
+let outputPath = CommandLine.arguments[2]
+let wallpaperPath =
+    CommandLine.arguments.count >= 4
+    ? CommandLine.arguments[3]
+    : "sequoia-light.jpg"
+
+// MacBook Air 13" 4th Gen native resolution
+let canvasWidth: CGFloat = 2560
+let canvasHeight: CGFloat = 1664
+
+// Load wallpaper (supports HEIC, JPEG, PNG via ImageIO)
+guard let wpData = try? Data(contentsOf: URL(fileURLWithPath: wallpaperPath)),
+    let wpSource = CGImageSourceCreateWithData(wpData as CFData, nil),
+    let wallpaper = CGImageSourceCreateImageAtIndex(wpSource, 0, nil)
+else {
+    fputs("Failed to load wallpaper: \(wallpaperPath)\n", stderr)
+    exit(1)
+}
+
+// Load window screenshot
+guard let winProvider = CGDataProvider(filename: windowPath),
+    let windowImage = CGImage(
+        pngDataProviderSource: winProvider, decode: nil, shouldInterpolate: true,
+        intent: .defaultIntent)
+else {
+    fputs("Failed to load window image: \(windowPath)\n", stderr)
+    exit(1)
+}
+
+// Create canvas
+guard
+    let context = CGContext(
+        data: nil,
+        width: Int(canvasWidth),
+        height: Int(canvasHeight),
+        bitsPerComponent: 8,
+        bytesPerRow: 0,
+        space: CGColorSpaceCreateDeviceRGB(),
+        bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+    )
+else {
+    fputs("Failed to create graphics context\n", stderr)
+    exit(1)
+}
+
+// Draw wallpaper: scale to fit width, align to top, crop from bottom
+let wpWidth = CGFloat(wallpaper.width)
+let wpHeight = CGFloat(wallpaper.height)
+let wpScale = canvasWidth / wpWidth
+let scaledWpHeight = wpHeight * wpScale
+// CG origin is bottom-left; align top edge of wallpaper to top of canvas
+let wpY = canvasHeight - scaledWpHeight
+context.draw(
+    wallpaper, in: CGRect(x: 0, y: wpY, width: canvasWidth, height: scaledWpHeight))
+
+// Draw menu bar: translucent strip at the top (@2x: 60px ≈ 30pt)
+let menuBarHeight: CGFloat = 60
+let menuBarY = canvasHeight - menuBarHeight
+context.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 0.25))
+context.fill(CGRect(x: 0, y: menuBarY, width: canvasWidth, height: menuBarHeight))
+
+// Helper to draw a menu bar SF Symbol via CTFont
+func drawMenuSymbol(_ name: String, at x: CGFloat, size: CGFloat) -> CGFloat {
+    guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) else {
+        return x
+    }
+    let config = NSImage.SymbolConfiguration(pointSize: size, weight: .regular)
+    let configured = image.withSymbolConfiguration(config) ?? image
+    // Tint the symbol
+    let tinted = NSImage(size: configured.size, flipped: false) { rect in
+        menuTextColor.set()
+        rect.fill(using: .sourceOver)
+        configured.draw(in: rect, from: .zero, operation: .destinationIn, fraction: 1.0)
+        return true
+    }
+    let imgWidth = tinted.size.width
+    let imgHeight = tinted.size.height
+    let imgY = menuBarY + (menuBarHeight - imgHeight) / 2
+    guard let cgImage = tinted.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+        return x
+    }
+    context.draw(cgImage, in: CGRect(x: x, y: imgY, width: imgWidth, height: imgHeight))
+    return x + imgWidth
+}
+
+// Helper to draw menu bar text
+let menuTextColor = NSColor(white: 0.0, alpha: 0.85)
+func drawMenuText(_ text: String, at x: CGFloat, size: CGFloat, weight: NSFont.Weight = .regular)
+    -> CGFloat
+{
+    let font = NSFont.systemFont(ofSize: size, weight: weight)
+    let attrs: [NSAttributedString.Key: Any] = [
+        .font: font,
+        .foregroundColor: menuTextColor,
+    ]
+    let attrStr = NSAttributedString(string: text, attributes: attrs)
+    let line = CTLineCreateWithAttributedString(attrStr)
+    let bounds = CTLineGetBoundsWithOptions(line, .useOpticalBounds)
+    let y = menuBarY + (menuBarHeight - bounds.height) / 2 - bounds.origin.y
+    context.textPosition = CGPoint(x: x, y: y)
+    CTLineDraw(line, context)
+    return x + bounds.width
+}
+
+// Left side: Apple logo, app name, menus
+let appleFont = NSFont(name: "SF Pro Display", size: 42) ?? NSFont.systemFont(ofSize: 42)
+let appleAttrs: [NSAttributedString.Key: Any] = [
+    .font: appleFont,
+    .foregroundColor: menuTextColor,
+]
+let appleString = NSAttributedString(string: "\u{F8FF}", attributes: appleAttrs)
+let appleLine = CTLineCreateWithAttributedString(appleString)
+let appleBounds = CTLineGetBoundsWithOptions(appleLine, .useOpticalBounds)
+let appleX: CGFloat = 32
+let appleTextY = menuBarY + (menuBarHeight - appleBounds.height) / 2 - appleBounds.origin.y
+context.textPosition = CGPoint(x: appleX, y: appleTextY)
+CTLineDraw(appleLine, context)
+
+var curX = appleX + appleBounds.width + 40
+curX = drawMenuText("Listless", at: curX, size: 28, weight: .bold) + 36
+curX = drawMenuText("File", at: curX, size: 28) + 36
+curX = drawMenuText("Edit", at: curX, size: 28) + 36
+curX = drawMenuText("View", at: curX, size: 28) + 36
+curX = drawMenuText("Window", at: curX, size: 28) + 36
+_ = drawMenuText("Help", at: curX, size: 28)
+
+// Right side: icons, date and time
+var rightX = canvasWidth - 260
+_ = drawMenuText("Wed 9 Apr  9:41 AM", at: rightX, size: 28)
+rightX -= 20
+_ = drawMenuSymbol("switch.2", at: rightX - 44, size: 32)
+_ = drawMenuSymbol("magnifyingglass", at: rightX - 100, size: 28)
+
+// Scale and draw window screenshot offset slightly right of centre
+let winWidth = CGFloat(windowImage.width)
+let winHeight = CGFloat(windowImage.height)
+let winScale = (canvasWidth * 0.4) / winWidth
+let scaledWinWidth = winWidth * winScale
+let scaledWinHeight = winHeight * winScale
+let winX = (canvasWidth - scaledWinWidth) / 2 + 400
+let availableHeight = canvasHeight - menuBarHeight
+let winY = (availableHeight - scaledWinHeight) / 2 + 150
+context.draw(
+    windowImage,
+    in: CGRect(x: winX, y: winY, width: scaledWinWidth, height: scaledWinHeight))
+
+// Save
+guard let resultImage = context.makeImage(),
+    let destination = CGImageDestinationCreateWithURL(
+        URL(fileURLWithPath: outputPath) as CFURL,
+        "public.png" as CFString,
+        1,
+        nil
+    )
+else {
+    fputs("Failed to create output\n", stderr)
+    exit(1)
+}
+
+CGImageDestinationAddImage(destination, resultImage, nil)
+guard CGImageDestinationFinalize(destination) else {
+    fputs("Failed to write output\n", stderr)
+    exit(1)
+}

diff --git a/Scripts/screenshots-mac.sh b/Scripts/screenshots-mac.sh

@@ -0,0 +1,161 @@
+#!/bin/bash
+set -euo pipefail
+
+DO_COMPOSE=false
+while getopts "c" opt; do
+  case $opt in
+    c) DO_COMPOSE=true ;;
+    *) ;;
+  esac
+done
+shift $((OPTIND - 1))
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+SECRETS="$REPO_ROOT/.asc/secrets.sh"
+
+if [[ ! -f "$SECRETS" ]]; then
+    echo "Error: $SECRETS not found. See Scripts/secrets.sh.example for the required format."
+    exit 1
+fi
+
+source "$SECRETS"
+
+DEV_P12="$REPO_ROOT/.asc/dev.p12"
+TMP_KEYCHAIN="$REPO_ROOT/.asc/build.keychain-db"
+
+echo "==> Setting up temporary keychain..."
+security delete-keychain "$TMP_KEYCHAIN" 2>/dev/null || true
+security create-keychain -p "$TMP_KEYCHAIN_PASS" "$TMP_KEYCHAIN"
+security unlock-keychain -p "$TMP_KEYCHAIN_PASS" "$TMP_KEYCHAIN"
+security set-keychain-settings -lut 21600 "$TMP_KEYCHAIN"
+security import "$DEV_P12" -k "$TMP_KEYCHAIN" -P "$DEV_P12_PASS" \
+    -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild
+security set-key-partition-list -S apple-tool:,apple:,codesign:,productbuild: \
+    -s -k "$TMP_KEYCHAIN_PASS" "$TMP_KEYCHAIN"
+security list-keychains -d user -s "$TMP_KEYCHAIN" ~/Library/Keychains/login.keychain-db
+
+cleanup_keychain() {
+    echo "==> Restoring keychain search list..."
+    security list-keychains -d user -s ~/Library/Keychains/login.keychain-db
+    security default-keychain -d user -s ~/Library/Keychains/login.keychain-db
+    security delete-keychain "$TMP_KEYCHAIN" 2>/dev/null || true
+}
+trap cleanup_keychain EXIT
+
+SCREENSHOT_TMP="/tmp/listless-screenshots"
+FRAMED_TMP="/tmp/listless-framed"
+MARKETING_DIR="$(pwd)/Marketing"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# Mac ASC canvas dimensions (16:10)
+CANVAS_WIDTH=2880
+CANVAS_HEIGHT=1800
+
+CAPTIONS=(
+  "Syncs via iCloud"
+  "Dark Mode"
+  "Natural Selection"
+)
+
+XCRESULT_DIR=$(mktemp -d)
+
+# Run screenshot tests (attachments stored in xcresult bundle)
+echo "==> Running macOS screenshot tests..."
+xcodebuild test \
+  -scheme "Listless macOS" \
+  -destination "platform=macOS" \
+  -only-testing:"Listless macOS UI Tests/ListlessMacScreenshots" \
+  -resultBundlePath "${XCRESULT_DIR}/result.xcresult" \
+  2>&1
+
+# Extract attachments from xcresult bundle
+echo "==> Extracting screenshots from xcresult..."
+rm -rf "${SCREENSHOT_TMP}"
+mkdir -p "${SCREENSHOT_TMP}"
+xcrun xcresulttool export attachments \
+  --path "${XCRESULT_DIR}/result.xcresult" \
+  --output-path "${SCREENSHOT_TMP}"
+
+# Rename attachments using manifest.json (attachment name -> file)
+osascript -l JavaScript -e "
+var manifest = JSON.parse($.NSString.alloc.initWithDataEncoding(
+  $.NSData.dataWithContentsOfFile('${SCREENSHOT_TMP}/manifest.json'),
+  $.NSUTF8StringEncoding).js);
+var fm = $.NSFileManager.defaultManager;
+manifest.forEach(function(entry) {
+  (entry.attachments || []).forEach(function(att) {
+    var exported = att.exportedFileName || '';
+    var suggested = att.suggestedHumanReadableName || '';
+    if (exported && suggested) {
+      var name = suggested.replace(/_\d+_[A-F0-9-]+\.png$/, '');
+      var src = '${SCREENSHOT_TMP}/' + exported;
+      var dst = '${SCREENSHOT_TMP}/' + name + '.png';
+      fm.moveItemAtPathToPathError(src, dst, null);
+    }
+  });
+});"
+
+rm -rf "${XCRESULT_DIR}"
+
+if ! ls "${SCREENSHOT_TMP}"/*.png 1>/dev/null 2>&1; then
+  echo "No screenshots found in ${SCREENSHOT_TMP}."
+  exit 1
+fi
+
+mkdir -p "${MARKETING_DIR}"
+
+if [ "$DO_COMPOSE" = true ]; then
+  DESKTOP_TMP="/tmp/listless-desktop"
+  mkdir -p "${DESKTOP_TMP}"
+  mkdir -p "${FRAMED_TMP}"
+
+  echo ""
+  echo "Composing desktop images..."
+  n=0
+  for file in "${SCREENSHOT_TMP}"/0*.png; do
+    swift "${SCRIPT_DIR}/screenshots-mac-desktop.swift" "$file" "${DESKTOP_TMP}/desktop_${n}.png" "$(pwd)/sequoia-light.jpg"
+    echo "  desktop_${n}.png"
+    n=$((n + 1))
+  done
+
+  echo "Framing screenshots..."
+  n=0
+  for file in "${DESKTOP_TMP}"/desktop_*.png; do
+    shortcuts run "Frame Screenshots" -i "$file" -o "${FRAMED_TMP}/framed_${n}.png"
+    echo "  Framed screenshot ${n}"
+    n=$((n + 1))
+  done
+
+  rm -rf "${DESKTOP_TMP}"
+
+  echo "Composing final images..."
+  n=0
+  for file in "${FRAMED_TMP}"/framed_*.png; do
+    caption="${CAPTIONS[$n]:-}"
+    swift "${SCRIPT_DIR}/screenshots-ios-compose.swift" "$file" "${MARKETING_DIR}/mac_${n}.png" "$caption" "$CANVAS_WIDTH" "$CANVAS_HEIGHT"
+    echo "  mac_${n}.png — ${caption}"
+    n=$((n + 1))
+  done
+
+  rm -rf "${FRAMED_TMP}"
+else
+  OUTPUT_DIR="$(pwd)"
+  echo ""
+  n=0
+  for file in "${SCREENSHOT_TMP}"/0*.png; do
+    cp "$file" "${OUTPUT_DIR}/mac_${n}.png"
+    n=$((n + 1))
+  done
+fi
+
+rm -rf "${SCREENSHOT_TMP}"
+
+if [ "$DO_COMPOSE" = true ]; then
+  echo ""
+  echo "Screenshots saved to ${MARKETING_DIR}/"
+  ls -la "${MARKETING_DIR}"/mac_*.png
+else
+  echo ""
+  echo "Screenshots saved to ${OUTPUT_DIR}/"
+  ls -la "${OUTPUT_DIR}"/mac_*.png
+fi

diff --git a/Scripts/screenshots-watch.sh b/Scripts/screenshots-watch.sh

@@ -0,0 +1,96 @@
+#!/bin/bash
+set -euo pipefail
+
+DO_COMPOSE=false
+while getopts "c" opt; do
+  case $opt in
+    c) DO_COMPOSE=true ;;
+    *) ;;
+  esac
+done
+shift $((OPTIND - 1))
+
+MAJOR="${1:-11}"
+RUNTIME=$(xcrun simctl list runtimes available \
+  | grep "watchOS ${MAJOR}\." \
+  | sed 's/.*watchOS \([0-9.]*\).*/\1/' \
+  | sort -t. -k1,1n -k2,2n \
+  | tail -1)
+
+if [ -z "$RUNTIME" ]; then
+  echo "No available watchOS ${MAJOR}.x simulator runtime found." >&2
+  exit 1
+fi
+
+DEVICE=$(xcrun simctl list devices available "watchOS ${RUNTIME}" \
+  | grep "Apple Watch" \
+  | head -1 \
+  | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/')
+
+if [ -z "$DEVICE" ]; then
+  echo "No available Apple Watch simulator found for watchOS ${RUNTIME}." >&2
+  exit 1
+fi
+
+echo "Using ${DEVICE}, watchOS ${RUNTIME}"
+
+# Boot simulator if needed
+UDID=$(xcrun simctl list devices available "watchOS ${RUNTIME}" \
+  | grep "$DEVICE" \
+  | head -1 \
+  | sed 's/.*(\([A-F0-9-]*\)).*/\1/')
+
+xcrun simctl boot "$UDID" 2>/dev/null || true
+
+SCREENSHOT_TMP="/tmp/listless-screenshots"
+FRAMED_TMP="/tmp/listless-framed"
+MARKETING_DIR="$(pwd)/Marketing"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+CAPTION="Get Up and Do"
+
+# Run screenshot tests (writes to SCREENSHOT_TMP)
+xcodebuild test \
+  -scheme "Listless watchOS" \
+  -destination "platform=watchOS Simulator,name=${DEVICE},OS=${RUNTIME}" \
+  -only-testing:"Listless watchOS UI Tests/ListlessWatchScreenshots" \
+  2>&1
+
+if ! ls "${SCREENSHOT_TMP}"/*.png 1>/dev/null 2>&1; then
+  echo "No screenshots found in ${SCREENSHOT_TMP}."
+  exit 1
+fi
+
+mkdir -p "${MARKETING_DIR}"
+
+if [ "$DO_COMPOSE" = true ]; then
+  mkdir -p "${FRAMED_TMP}"
+
+  echo ""
+  echo "Framing screenshot..."
+  file="${SCREENSHOT_TMP}/01-items.png"
+  shortcuts run "Frame Screenshots" -i "$file" -o "${FRAMED_TMP}/framed_0.png"
+  echo "  Framed screenshot"
+
+  echo "Composing final image..."
+  swift "${SCRIPT_DIR}/screenshots-ios-compose.swift" "${FRAMED_TMP}/framed_0.png" "${MARKETING_DIR}/watch_0.png" "$CAPTION" 422 514
+  echo "  watch_0.png — ${CAPTION}"
+
+  rm -rf "${FRAMED_TMP}"
+else
+  OUTPUT_DIR="$(pwd)"
+  echo ""
+  cp "${SCREENSHOT_TMP}/01-items.png" "${OUTPUT_DIR}/watch_0.png"
+fi
+
+rm -rf "${SCREENSHOT_TMP}"
+
+if [ "$DO_COMPOSE" = true ]; then
+  echo ""
+  echo "Screenshots saved to ${MARKETING_DIR}/"
+  ls -la "${MARKETING_DIR}"/watch_*.png
+else
+  echo ""
+  echo "Screenshots saved to ${OUTPUT_DIR}/"
+  ls -la "${OUTPUT_DIR}"/watch_*.png
+fi

diff --git a/Tests/UI/ListlessMacScreenshots.swift b/Tests/UI/ListlessMacScreenshots.swift

@@ -0,0 +1,179 @@
+import CoreGraphics
+import XCTest
+
+@MainActor
+final class ListlessMacScreenshots: XCTestCase {
+    var app: XCUIApplication!
+
+    override func tearDownWithError() throws {
+        app.terminate()
+    }
+
+    // MARK: - Helpers
+
+    private func launchApp(_ additionalArgs: [String] = []) {
+        app = XCUIApplication()
+        let appearance = additionalArgs.contains("SCREENSHOT_DARK") ? "SCREENSHOT_DARK" : "SCREENSHOT_LIGHT"
+        app.launchArguments = ["UI_TESTING", appearance] + additionalArgs.filter { $0 != "SCREENSHOT_DARK" }
+        app.launch()
+    }
+
+    var draftTextField: XCUIElement {
+        app.textFields["draft-row-append"]
+    }
+
+    func itemText(_ title: String) -> XCUIElement {
+        app.textFields.matching(
+            NSPredicate(format: "identifier BEGINSWITH 'item-text-' AND value == %@", title)
+        ).firstMatch
+    }
+
+    func createItem(_ title: String) {
+        let textField = draftTextField
+        if !textField.exists {
+            app.typeKey("n", modifierFlags: .command)
+            if !textField.waitForExistence(timeout: 2) {
+                XCTFail("Draft text field should appear after Cmd+N")
+                return
+            }
+        }
+        textField.click()
+        textField.typeText(title)
+        textField.typeKey(.return, modifierFlags: [])
+    }
+
+    func itemCheckbox(at index: Int) -> XCUIElement {
+        app.buttons.matching(identifier: "item-checkbox").element(boundBy: index)
+    }
+
+    func navigateToItem(at index: Int) {
+        app.typeKey(.escape, modifierFlags: [])
+        for _ in 0...index {
+            app.typeKey(.downArrow, modifierFlags: [])
+        }
+    }
+
+    func cmdClickRow(withText title: String) {
+        let textField = itemText(title)
+        XCTAssertTrue(textField.waitForExistence(timeout: 2))
+        let coord = textField.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0.5))
+            .withOffset(CGVector(dx: -40, dy: 0))
+        let point = coord.screenPoint
+
+        let src = CGEventSource(stateID: .combinedSessionState)
+
+        let mouseDown = CGEvent(
+            mouseEventSource: src, mouseType: .leftMouseDown,
+            mouseCursorPosition: point, mouseButton: .left
+        )!
+        mouseDown.flags = .maskCommand
+        mouseDown.post(tap: .cgSessionEventTap)
+        usleep(50_000)
+
+        let mouseUp = CGEvent(
+            mouseEventSource: src, mouseType: .leftMouseUp,
+            mouseCursorPosition: point, mouseButton: .left
+        )!
+        mouseUp.flags = .maskCommand
+        mouseUp.post(tap: .cgSessionEventTap)
+        usleep(200_000)
+    }
+
+    func saveScreenshot(name: String) {
+        let windowList = CGWindowListCopyWindowInfo(
+            [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID
+        ) as? [[String: Any]] ?? []
+
+        guard let windowID = windowList.first(where: {
+            ($0[kCGWindowOwnerName as String] as? String) == "Listless"
+                && ($0[kCGWindowLayer as String] as? Int) == 0
+        })?[kCGWindowNumber as String] as? Int else {
+            XCTFail("Could not find Listless window")
+            return
+        }
+
+        let tmpFile = NSTemporaryDirectory() + "\(name).png"
+        let process = Process()
+        process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
+        process.arguments = ["-l\(windowID)", tmpFile]
+        try? process.run()
+        process.waitUntilExit()
+
+        guard let data = try? Data(contentsOf: URL(fileURLWithPath: tmpFile)) else {
+            XCTFail("screencapture failed to write \(tmpFile)")
+            return
+        }
+
+        let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.png")
+        attachment.name = name
+        attachment.lifetime = .keepAlways
+        add(attachment)
+
+        try? FileManager.default.removeItem(atPath: tmpFile)
+    }
+
+    // MARK: - Screenshots
+
+    /// Four items with draft row focused: three active, one empty draft row,
+    /// one completed at the bottom.
+    func testScreenshot01_ItemsWithEditing() throws {
+        launchApp()
+
+        createItem("Add items on your iPhone")
+        createItem("Edit items on your iPad")
+        createItem("Reorder items on your Mac")
+        createItem("Complete items on your Watch")
+
+        app.typeKey(.escape, modifierFlags: [])
+        usleep(500_000)
+
+        // Complete the last item
+        let checkbox = itemCheckbox(at: 3)
+        XCTAssertTrue(checkbox.waitForExistence(timeout: 2))
+        checkbox.click()
+        usleep(500_000)
+
+        // Create an empty draft row
+        app.typeKey("n", modifierFlags: .command)
+        XCTAssertTrue(draftTextField.waitForExistence(timeout: 2))
+        usleep(300_000)
+
+        saveScreenshot(name: "01-items")
+    }
+
+    /// Single item in dark mode.
+    func testScreenshot02_DarkMode() throws {
+        launchApp(["SCREENSHOT_DARK"])
+
+        createItem("Ask Alfred about utility belt")
+
+        usleep(500_000)
+
+        saveScreenshot(name: "02-click-to-create")
+    }
+
+    /// Multiple items with discontinuous selection via Cmd+Click.
+    func testScreenshot03_MultipleSelection() throws {
+        launchApp()
+
+        createItem("Write shell script")
+        createItem("Rewrite shell script")
+        createItem("Question life choices")
+        createItem("Cry tragically")
+        createItem("Achieve catharsis")
+
+        app.typeKey(.escape, modifierFlags: [])
+        usleep(500_000)
+
+        // Navigate to first item then select it
+        navigateToItem(at: 0)
+        usleep(200_000)
+
+        // Cmd+Click to add non-adjacent items to selection
+        cmdClickRow(withText: "Question life choices")
+        cmdClickRow(withText: "Achieve catharsis")
+        usleep(300_000)
+
+        saveScreenshot(name: "03-selection")
+    }
+}

diff --git a/Tests/UI/ListlessWatchScreenshots.swift b/Tests/UI/ListlessWatchScreenshots.swift

@@ -0,0 +1,48 @@
+import XCTest
+
+final class ListlessWatchScreenshots: XCTestCase {
+    var app: XCUIApplication!
+
+    static let screenshotDir = "/tmp/listless-screenshots"
+
+    override class func setUp() {
+        super.setUp()
+        try? FileManager.default.createDirectory(
+            atPath: screenshotDir,
+            withIntermediateDirectories: true
+        )
+    }
+
+    override func tearDownWithError() throws {
+        app.terminate()
+    }
+
+    private func launchApp(_ additionalArgs: [String] = []) {
+        app = XCUIApplication()
+        app.launchArguments = ["UI_TESTING"] + additionalArgs
+        app.launch()
+    }
+
+    func saveScreenshot(name: String) {
+        let screenshot = XCUIScreen.main.screenshot()
+        let attachment = XCTAttachment(screenshot: screenshot)
+        attachment.name = name
+        attachment.lifetime = .keepAlways
+        add(attachment)
+
+        let data = screenshot.pngRepresentation
+        let path = "\(Self.screenshotDir)/\(name).png"
+        try? data.write(to: URL(fileURLWithPath: path))
+    }
+
+    func testScreenshot01_Items() throws {
+        launchApp(["SCREENSHOT_SEED"])
+
+        let list = app.collectionViews.firstMatch
+        XCTAssertTrue(list.waitForExistence(timeout: 5))
+
+        usleep(500_000)
+
+        saveScreenshot(name: "01-items")
+    }
+}

diff --git a/project.yml b/project.yml

@@ -200,6 +200,7 @@ targets:
       - path: Tests/UI
         includes:
           - "ListlessMacUITests.swift"
+          - "ListlessMacScreenshots.swift"
     dependencies:
       - target: Listless macOS
     settings:
@@ -207,6 +208,20 @@ targets:
       CODE_SIGN_STYLE: Automatic
       GENERATE_INFOPLIST_FILE: YES
 
+  Listless watchOS UI Tests:
+    type: bundle.ui-testing
+    platform: watchOS
+    sources:
+      - path: Tests/UI
+        includes:
+          - "ListlessWatchScreenshots.swift"
+    dependencies:
+      - target: Listless watchOS
+    settings:
+      PRODUCT_BUNDLE_IDENTIFIER: net.inqk.listless.watchos.uitests
+      CODE_SIGN_STYLE: Automatic
+      GENERATE_INFOPLIST_FILE: YES
+
 schemes:
   Listless iOS:
     build:
@@ -279,6 +294,11 @@ schemes:
           settingsTarget: Listless watchOS
     run:
       config: Debug
+    test:
+      config: Debug
+      targets:
+        - name: Listless watchOS UI Tests
+          parallelizable: false
     profile:
       config: Release
     analyze: