From 048c1b870c053ee54df339ae1c58b8275d1731ff Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Fri, 16 Oct 2026 15:56:32 -0700 Subject: [PATCH 1/5] chore(release): bump version to 1.4 Version 1.4 puts every action under your thumb: swipe a reminder right to complete it, left to skip it, or up to open the action menu. A directional arrow guide now rides along with the card as you drag, so you can see which action you are about to trigger before you let go. Pulling down anywhere on the list refreshes it with a branded blue panel and a spinner that stays up just long enough to read as feedback, on the main list as well as the empty and all-done screens. The action-menu button on the phone, Mac, and Apple Watch now shows an ellipsis so it is easier to find, and this release fixes swipes that used to drift the card diagonally or start a refresh while you were only moving sideways. --- SingleThread.xcodeproj/project.pbxproj | 56 +++++++++++++------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/SingleThread.xcodeproj/project.pbxproj b/SingleThread.xcodeproj/project.pbxproj index 057255a7..ab5957a4 100644 --- a/SingleThread.xcodeproj/project.pbxproj +++ b/SingleThread.xcodeproj/project.pbxproj @@ -759,7 +759,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = SingleThread/SingleThread.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -784,7 +784,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThread; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -810,7 +810,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = SingleThread/SingleThread.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -835,7 +835,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThread; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -856,12 +856,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -885,12 +885,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -913,12 +913,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -937,12 +937,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -962,14 +962,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SingleThread; INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "SingleThread needs access to show your reminders."; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.alanvardy.SingleThread; INFOPLIST_KEY_WKWatchOnly = NO; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThread.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -990,14 +990,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SingleThread; INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "SingleThread needs access to show your reminders."; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.alanvardy.SingleThread; INFOPLIST_KEY_WKWatchOnly = NO; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThread.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1018,7 +1018,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = SingleThread/AppGroup.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SingleThreadWidget/Info.plist; @@ -1030,7 +1030,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThread.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1049,7 +1049,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = SingleThread/AppGroup.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SingleThreadWidget/Info.plist; @@ -1061,7 +1061,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThread.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1079,10 +1079,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadWatchUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1101,10 +1101,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadWatchUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1124,10 +1124,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadWatchTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1148,10 +1148,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadWatchTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; From ce94505fa24a1aabcd3a9e9973a4e908f08e72c0 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Fri, 16 Oct 2026 16:14:22 -0700 Subject: [PATCH 2/5] feat(screenshots): language-major output, 6.3" iPhone captures - Write captures under // (was /), with numbered language folders in App Store order: 1-en, 2-zh-Hans, 3-fr, 4-de, 5-ja, 6-es - Capture the iPhone class on iPhone 17 (6.3", 1206 x 2622) instead of iPhone 18 Pro Max (6.9", 1320 x 2868); rename the class to iphone-6.3 and rewrite the committed wallpaper slot fixtures to match - Clear build/appstore-screenshots at the start of every run so a stale device-major tree cannot linger beside the new layout - Accept 1206x2622 / 1179x2556 in the size check, and fix the --help sed range that was truncating the header --- scripts/appstore-screenshots.sh | 61 +++++++++++++----- ...one-6.9-empty.jpg => iphone-6.3-empty.jpg} | Bin ...main-dark.jpg => iphone-6.3-main-dark.jpg} | Bin ...in-light.jpg => iphone-6.3-main-light.jpg} | Bin ...inders.jpg => iphone-6.3-no-reminders.jpg} | Bin ...6.9-options.jpg => iphone-6.3-options.jpg} | Bin .../appstore-wallpapers/manifest.json | 20 +++--- 7 files changed, 55 insertions(+), 26 deletions(-) rename scripts/fixtures/appstore-wallpapers/{iphone-6.9-empty.jpg => iphone-6.3-empty.jpg} (100%) rename scripts/fixtures/appstore-wallpapers/{iphone-6.9-main-dark.jpg => iphone-6.3-main-dark.jpg} (100%) rename scripts/fixtures/appstore-wallpapers/{iphone-6.9-main-light.jpg => iphone-6.3-main-light.jpg} (100%) rename scripts/fixtures/appstore-wallpapers/{iphone-6.9-no-reminders.jpg => iphone-6.3-no-reminders.jpg} (100%) rename scripts/fixtures/appstore-wallpapers/{iphone-6.9-options.jpg => iphone-6.3-options.jpg} (100%) diff --git a/scripts/appstore-screenshots.sh b/scripts/appstore-screenshots.sh index 84fc9157..05b0552e 100755 --- a/scripts/appstore-screenshots.sh +++ b/scripts/appstore-screenshots.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# App Store Connect screenshots — iPhone 6.9" and iPad 13" display classes. +# App Store Connect screenshots — iPhone 6.3" and iPad 13" display classes. # # Per language, per display class, this captures: # @@ -48,9 +48,15 @@ # before the seeded captures, because --ui-testing-app-language is read only # on the --ui-testing path. # -# Output: build/appstore-screenshots///.png +# Output: build/appstore-screenshots///.png # -# The captures are the simulator's native pixels — 1320 x 2868 (6.9") and +# Folders are language-major, so every localization of one device sits in a +# single directory, and each language directory carries the App Store's own +# ordering as a numeric prefix (`language_folder` below): 1-en, 2-zh-Hans, +# 3-fr, 4-de, 5-ja, 6-es. The output tree is cleared at the start of every +# run, so a stale layout can never linger beside the new one. +# +# The captures are the simulator's native pixels — 1206 x 2622 (6.3") and # 2064 x 2752 (13") — which are App Store Connect's accepted pairs for those # slots, so no resize or pad is needed. `--mask ignored` drops the rounded # device mask that the store does not want. @@ -76,12 +82,12 @@ # Wallpapers are keyed by "slot" = -, NOT by language, so # each device/screen pair keeps one fixed photo across all six languages: # -# iphone-6.9-main-light ipad-13-main-light (own photo: the light -# iphone-6.9-main-dark ipad-13-main-dark appearance composites the +# iphone-6.3-main-light ipad-13-main-light (own photo: the light +# iphone-6.3-main-dark ipad-13-main-dark appearance composites the # photo over white, not black) -# iphone-6.9-options ipad-13-options -# iphone-6.9-empty ipad-13-empty ("All Done") -# iphone-6.9-no-reminders ipad-13-no-reminders +# iphone-6.3-options ipad-13-options +# iphone-6.3-empty ipad-13-empty ("All Done") +# iphone-6.3-no-reminders ipad-13-no-reminders # # The set is COMMITTED — scripts/fixtures/appstore-wallpapers/ holds the ten # photos and manifest.json — so a clone captures the same wallpapers with no @@ -110,7 +116,7 @@ # installed on the simulator. REPO_ROOT overrides the root captures land under. # # Usage: scripts/appstore-screenshots.sh [options] [class ...] -# class: iphone-6.9 | ipad-13 | watch-series11-46mm +# class: iphone-6.3 | ipad-13 | watch-series11-46mm # (default: the two iOS classes) set -euo pipefail @@ -178,7 +184,22 @@ watch_locale () { # $1 = language → " " esac } -LANGUAGES=(en de es fr ja zh-Hans) +# App Store Connect's localization order, with the folder label each language +# writes into: language-major output, ordered exactly as the store lists them. +# `es` ships as "6-es", matching the raw-code style of the other five. +language_folder () { # $1 = language → numbered output directory name + case "$1" in + en) echo "1-en" ;; + zh-Hans) echo "2-zh-Hans" ;; + fr) echo "3-fr" ;; + de) echo "4-de" ;; + ja) echo "5-ja" ;; + es) echo "6-es" ;; + *) return 1 ;; + esac +} + +LANGUAGES=(en zh-Hans fr de ja es) # Wallpaper slots per device class, in capture order. Slot names match the # captured file names so a staged photo maps 1:1 onto an output PNG. @@ -186,7 +207,9 @@ SCREENS=(main-light options main-dark empty no-reminders) # class:simctl devicetype name regex DEVICE_TYPES=( - "iphone-6.9:iPhone 18 Pro Max" + # `iPhone 17` is 6.3" (1206 x 2622) — an accepted App Store iPhone slot. The + # `$` keeps the pattern off `iPhone 17 Pro` / `iPhone 17 Pro Max` / `iPhone 17e`. + "iphone-6.3:iPhone 17$" "ipad-13:iPad Pro 13-inch \\(M5\\)" ) @@ -228,7 +251,7 @@ while [ $# -gt 0 ]; do shift ;; -h | --help) - sed -n '2,70p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,120p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; -*) @@ -495,7 +518,7 @@ capture_class () { # $1 = class label, $2 = device type regex prepare_wallpaper for language in "${LANGUAGES[@]}"; do - dir="$OUT/$label/$language" + dir="$OUT/$(language_folder "$language")/$label" mkdir -p "$dir" echo " -> $language" demo_seed=$(demo_reminder_seed "$language") @@ -563,7 +586,7 @@ capture_watch () { # $1 = device type regex xcrun simctl install "$UDID" "$WATCH_APP" for language in "${LANGUAGES[@]}"; do - dir="$OUT/$WATCH_CLASS/$language" + dir="$OUT/$(language_folder "$language")/$WATCH_CLASS" mkdir -p "$dir" echo " -> $language" watch_set_language "$language" @@ -582,9 +605,15 @@ capture_watch () { # $1 = device type regex # ── Run ────────────────────────────────────────────────────────────────────── if [ ${#CLASSES[@]} -eq 0 ]; then - CLASSES=(iphone-6.9 ipad-13) + CLASSES=(iphone-6.3 ipad-13) fi +# Clear the output tree: the language-major layout replaced an older +# device-major one, and stale PNGs would otherwise sit beside (or be swept into +# the size check with) the fresh captures. +rm -rf "$OUT" +mkdir -p "$OUT" + if [ "$FETCH_WALLPAPERS" -eq 1 ]; then echo "==> wallpapers" fetch_wallpapers @@ -635,7 +664,7 @@ for shot in "$OUT"/*/*/*.png; do count=$((count + 1)) dims=$(sips -g pixelWidth -g pixelHeight "$shot" | tail -2 | tr -dc '0-9\n' | paste -sd x -) case "$dims" in - 1320x2868 | 2064x2752 | 416x496) ;; + 1206x2622 | 1179x2556 | 1320x2868 | 2064x2752 | 416x496) ;; *) echo "!! ${shot} is ${dims}" >&2 fail=1 diff --git a/scripts/fixtures/appstore-wallpapers/iphone-6.9-empty.jpg b/scripts/fixtures/appstore-wallpapers/iphone-6.3-empty.jpg similarity index 100% rename from scripts/fixtures/appstore-wallpapers/iphone-6.9-empty.jpg rename to scripts/fixtures/appstore-wallpapers/iphone-6.3-empty.jpg diff --git a/scripts/fixtures/appstore-wallpapers/iphone-6.9-main-dark.jpg b/scripts/fixtures/appstore-wallpapers/iphone-6.3-main-dark.jpg similarity index 100% rename from scripts/fixtures/appstore-wallpapers/iphone-6.9-main-dark.jpg rename to scripts/fixtures/appstore-wallpapers/iphone-6.3-main-dark.jpg diff --git a/scripts/fixtures/appstore-wallpapers/iphone-6.9-main-light.jpg b/scripts/fixtures/appstore-wallpapers/iphone-6.3-main-light.jpg similarity index 100% rename from scripts/fixtures/appstore-wallpapers/iphone-6.9-main-light.jpg rename to scripts/fixtures/appstore-wallpapers/iphone-6.3-main-light.jpg diff --git a/scripts/fixtures/appstore-wallpapers/iphone-6.9-no-reminders.jpg b/scripts/fixtures/appstore-wallpapers/iphone-6.3-no-reminders.jpg similarity index 100% rename from scripts/fixtures/appstore-wallpapers/iphone-6.9-no-reminders.jpg rename to scripts/fixtures/appstore-wallpapers/iphone-6.3-no-reminders.jpg diff --git a/scripts/fixtures/appstore-wallpapers/iphone-6.9-options.jpg b/scripts/fixtures/appstore-wallpapers/iphone-6.3-options.jpg similarity index 100% rename from scripts/fixtures/appstore-wallpapers/iphone-6.9-options.jpg rename to scripts/fixtures/appstore-wallpapers/iphone-6.3-options.jpg diff --git a/scripts/fixtures/appstore-wallpapers/manifest.json b/scripts/fixtures/appstore-wallpapers/manifest.json index 9523eec9..04aea8b0 100644 --- a/scripts/fixtures/appstore-wallpapers/manifest.json +++ b/scripts/fixtures/appstore-wallpapers/manifest.json @@ -34,37 +34,37 @@ "photographerURL": "https://unsplash.com/@markbasarabvisuals", "source": "https://vardy.cc/unsplash/random" }, - "iphone-6.9-empty": { + "iphone-6.3-empty": { "fetchedAt": "2026-10-13T05:32:43Z", - "file": "iphone-6.9-empty.jpg", + "file": "iphone-6.3-empty.jpg", "photographer": "v2osk", "photographerURL": "https://unsplash.com/@v2osk", "source": "https://vardy.cc/unsplash/random" }, - "iphone-6.9-main-dark": { + "iphone-6.3-main-dark": { "fetchedAt": "2026-10-14T17:43:37Z", - "file": "iphone-6.9-main-dark.jpg", + "file": "iphone-6.3-main-dark.jpg", "photographer": "R Ra", "photographerURL": "https://unsplash.com/@renran", "source": "https://vardy.cc/unsplash/random" }, - "iphone-6.9-main-light": { + "iphone-6.3-main-light": { "fetchedAt": "2026-10-14T18:16:57Z", - "file": "iphone-6.9-main-light.jpg", + "file": "iphone-6.3-main-light.jpg", "photographer": "Florian van Duyn", "photographerURL": "https://unsplash.com/@flovayn", "source": "https://vardy.cc/unsplash/random" }, - "iphone-6.9-no-reminders": { + "iphone-6.3-no-reminders": { "fetchedAt": "2026-10-13T05:32:43Z", - "file": "iphone-6.9-no-reminders.jpg", + "file": "iphone-6.3-no-reminders.jpg", "photographer": "Aaron Burden", "photographerURL": "https://unsplash.com/@aaronburden", "source": "https://vardy.cc/unsplash/random" }, - "iphone-6.9-options": { + "iphone-6.3-options": { "fetchedAt": "2026-10-14T18:16:05Z", - "file": "iphone-6.9-options.jpg", + "file": "iphone-6.3-options.jpg", "photographer": "Daniela Kokina", "photographerURL": "https://unsplash.com/@danielakokina", "source": "https://vardy.cc/unsplash/random" From 31e9d054d151c4a8b5227f02df683d5757ada3a3 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Fri, 16 Oct 2026 18:51:14 -0700 Subject: [PATCH 3/5] feat(screenshots): numbered output, device folders, and watch captures iOS stems gain numeric prefixes (1-main-dark ... 6-no-reminders-dark) so the listing's drag-over order is the file-name sort order, and a new 4-options-reminder-dark shot captures the Settings Reminder subscreen. Output device folders are numbered in App Store order: 1-iphone, 2-ipad, 3-watch. class_folder() maps them; the internal class labels stay unchanged because they key the committed wallpaper slots. The watch class joins the default CLASSES. It was opt-in, so a bare ./scripts/appstore-screenshots.sh only ever captured iOS and capture_watch was never reached. Watch stems renumbered to 1-main / 2-more / 3-all-done / 4-no-reminders. Adds the --ui-testing-open-settings-reminder launch seam (LaunchArgument.openSettingsReminder, SettingsRoute, navigationDestination) so the harness can reach the Reminder subscreen simctl cannot tap. LaunchArgumentTests cover flag detection, flag distinctness, and opensSettings(). --- SingleThread/ContentView+Settings.swift | 3 +- SingleThread/ContentView.swift | 11 +- SingleThread/LaunchArgument.swift | 16 +++ SingleThread/SettingsView.swift | 48 ++++++-- SingleThreadTests/LaunchArgumentTests.swift | 31 +++++ scripts/appstore-screenshots.sh | 126 ++++++++++++++------ 6 files changed, 179 insertions(+), 56 deletions(-) diff --git a/SingleThread/ContentView+Settings.swift b/SingleThread/ContentView+Settings.swift index 34b06e32..7dfab669 100644 --- a/SingleThread/ContentView+Settings.swift +++ b/SingleThread/ContentView+Settings.swift @@ -13,7 +13,8 @@ extension ContentView { availableLists: viewModel.store.availableLists, excludedLists: excludedListsBinding, entitlementStore: viewModel.store.entitlementStore, - viewModel: SettingsViewModel()) + viewModel: SettingsViewModel(), + initialRoute: LaunchArgument.isPresent(.openSettingsReminder) ? .reminder : nil) // Standard-suite keys are plain in-memory values; write each change // back to the @AppStorage-backed property so settings survive relaunch. // App-Group keys write straight through their store types instead diff --git a/SingleThread/ContentView.swift b/SingleThread/ContentView.swift index b07618f8..b6fc7803 100644 --- a/SingleThread/ContentView.swift +++ b/SingleThread/ContentView.swift @@ -278,11 +278,12 @@ struct ContentView: View { handleScenePhaseChange(phase) } .task { - // `--ui-testing-open-settings` replays the gear button's action at - // launch. `simctl` cannot tap, so this is the only way screenshot - // automation can reach the options screen; bag first, then the flag, - // because `settingsSheetContent` renders nothing until the bag exists. - if LaunchArgument.isPresent(.openSettings), settingsBag == nil { + // The settings seams replay the gear button's action at launch. + // `simctl` cannot tap, so this is the only way screenshot automation + // reaches the options screen (and, for `openSettingsReminder`, its + // Reminder subscreen); bag first, then the flag, because + // `settingsSheetContent` renders nothing until the bag exists. + if LaunchArgument.opensSettings(), settingsBag == nil { settingsBag = makeSettingsBag() isShowingSettings = true } diff --git a/SingleThread/LaunchArgument.swift b/SingleThread/LaunchArgument.swift index c54a417d..cae9b240 100644 --- a/SingleThread/LaunchArgument.swift +++ b/SingleThread/LaunchArgument.swift @@ -17,8 +17,24 @@ enum LaunchArgument: String { /// until the bag exists. case openSettings = "--ui-testing-open-settings" + /// Presents the root Settings sheet and pushes the "Reminder" subscreen. + /// + /// The subscreen is otherwise reachable only by tapping the Reminder row, + /// which `simctl` cannot do, so the App Store screenshot harness has no other + /// route to it. It opens the sheet exactly as ``openSettings`` does — same + /// bag-then-flag order — and then seeds the navigation path with the row's + /// destination. + case openSettingsReminder = "--ui-testing-open-settings-reminder" + // MARK: Internal + /// Whether either Settings seam is present — the sheet opener or the + /// Reminder-subscreen opener (which opens the sheet too). + static func opensSettings( + in arguments: [String] = ProcessInfo.processInfo.arguments) -> Bool { + isPresent(.openSettings, in: arguments) || isPresent(.openSettingsReminder, in: arguments) + } + /// Whether `argument` was passed to this process. static func isPresent( _ argument: Self, diff --git a/SingleThread/SettingsView.swift b/SingleThread/SettingsView.swift index 537bd62c..a9427878 100644 --- a/SingleThread/SettingsView.swift +++ b/SingleThread/SettingsView.swift @@ -1,6 +1,16 @@ import SingleThreadCore import SwiftUI +// MARK: - SettingsRoute + +/// One pushed destination inside the Settings stack. Only the Reminder row is +/// value-driven: the screenshot harness seeds it through +/// ``LaunchArgument/openSettingsReminder``, and the value form is what lets a +/// `NavigationPath` pre-select it before the stack renders. +enum SettingsRoute: Hashable { + case reminder +} + // MARK: - SettingsView /// Modal settings screen presented from the gear button. Owns no state — @@ -22,7 +32,8 @@ struct SettingsView: View { availableLists: [String], excludedLists: Binding>, entitlementStore: EntitlementStore = EntitlementStore(), - viewModel: SettingsViewModel = SettingsViewModel()) { + viewModel: SettingsViewModel = SettingsViewModel(), + initialRoute: SettingsRoute? = nil) { self.bindings = bindings self.viewModel = viewModel self.store = store @@ -30,12 +41,19 @@ struct SettingsView: View { self.availableLists = availableLists self.entitlementStore = entitlementStore _excludedLists = excludedLists + // Seed the path before the stack renders so a seam-driven launch lands + // on the subscreen in the first frame instead of pushing afterwards. + var path = NavigationPath() + if let initialRoute { + path.append(initialRoute) + } + _path = State(initialValue: path) } // MARK: Internal var body: some View { - NavigationStack { + NavigationStack(path: $path) { List { Section { NavigationLink { @@ -80,16 +98,7 @@ struct SettingsView: View { } .accessibilityIdentifier("settingsNotificationsRow") #endif - NavigationLink { - ReminderSettingsView( - showDate: $bindings.showDate, - showList: $bindings.showList, - showRecurrence: $bindings.showRecurrence, - showAlarms: $bindings.showAlarms, - showCompletionGlow: $bindings.showCompletionGlow, - showCompletionMomentum: $bindings.showCompletionMomentum, - viewModel: viewModel) - } label: { + NavigationLink(value: SettingsRoute.reminder) { SettingsLinkLabel( title: SharedStrings.reminder, systemImage: "bell.badge", @@ -177,6 +186,19 @@ struct SettingsView: View { .accessibilityAddTraits(.isButton) } } + .navigationDestination(for: SettingsRoute.self) { route in + switch route { + case .reminder: + ReminderSettingsView( + showDate: $bindings.showDate, + showList: $bindings.showList, + showRecurrence: $bindings.showRecurrence, + showAlarms: $bindings.showAlarms, + showCompletionGlow: $bindings.showCompletionGlow, + showCompletionMomentum: $bindings.showCompletionMomentum, + viewModel: viewModel) + } + } .localizedNavigationTitle("Settings") .toolbar { ToolbarItem(placement: .confirmationAction) { @@ -195,6 +217,8 @@ struct SettingsView: View { @Environment(\.dismiss) private var dismiss + @State private var path: NavigationPath + @Binding private var excludedLists: Set @Bindable private var bindings: SettingsBindings diff --git a/SingleThreadTests/LaunchArgumentTests.swift b/SingleThreadTests/LaunchArgumentTests.swift index 1e077a53..db01cbd1 100644 --- a/SingleThreadTests/LaunchArgumentTests.swift +++ b/SingleThreadTests/LaunchArgumentTests.swift @@ -28,4 +28,35 @@ struct LaunchArgumentTests { #expect(!LaunchArgument.isPresent(.openSettings, in: ["--ui-testing-open"])) #expect(!LaunchArgument.isPresent(.openSettings, in: ["ui-testing-open-settings"])) } + + @Test + func openSettingsReminderIsDetectedAmongOtherFlags() { + #expect( + LaunchArgument.isPresent( + .openSettingsReminder, + in: ["--ui-testing", "--ui-testing-open-settings-reminder"])) + } + + @Test + func openSettingsReminderIsAbsentWhenNotPassed() { + #expect(!LaunchArgument.isPresent(.openSettingsReminder, in: [])) + #expect(!LaunchArgument.isPresent(.openSettingsReminder, in: ["--ui-testing-open-settings"])) + } + + @Test + func openSettingsAndOpenSettingsReminderAreDistinctFlags() { + #expect(!LaunchArgument.isPresent(.openSettings, in: ["--ui-testing-open-settings-reminder"])) + #expect(!LaunchArgument.isPresent(.openSettingsReminder, in: ["--ui-testing-open"])) + #expect( + !LaunchArgument.isPresent( + .openSettingsReminder, in: ["ui-testing-open-settings-reminder"])) + } + + @Test + func opensSettingsIsTrueForEitherSettingsSeam() { + #expect(LaunchArgument.opensSettings(in: ["--ui-testing-open-settings"])) + #expect(LaunchArgument.opensSettings(in: ["--ui-testing-open-settings-reminder"])) + #expect(!LaunchArgument.opensSettings(in: ["--ui-testing"])) + #expect(!LaunchArgument.opensSettings(in: [])) + } } diff --git a/scripts/appstore-screenshots.sh b/scripts/appstore-screenshots.sh index 05b0552e..440ed897 100755 --- a/scripts/appstore-screenshots.sh +++ b/scripts/appstore-screenshots.sh @@ -2,22 +2,24 @@ # # App Store Connect screenshots — iPhone 6.3" and iPad 13" display classes. # -# Per language, per display class, this captures: +# Per language, per display class, this captures (the numeric prefixes put the +# files in the drag-over order the App Store listing wants): # -# main-light.png main reminder card, light appearance -# options-light.png root Settings sheet, light appearance -# main-dark.png main reminder card, dark appearance -# empty-dark.png "All Done" empty state, dark appearance -# no-reminders-dark.png "No Reminders" empty state, dark appearance +# 1-main-dark.png main reminder card, dark appearance +# 2-empty-dark.png "All Done" empty state, dark appearance +# 3-options-light.png root Settings sheet, light appearance +# 4-options-reminder-dark.png Settings' Reminder subscreen, dark appearance +# 5-main-light.png main reminder card, light appearance +# 6-no-reminders-dark.png "No Reminders" empty state, dark appearance # # The watch class captures a third, separate set — the watch app has no # wallpaper, no settings screen, and no `--seed`, so its demo copy comes from the # watch's own seams in `WatchDemoReminder`: # -# main.png reminder card -# more.png three-action menu — Skip / Reschedule / Delete -# all-done.png "All Done" (`--ui-testing-excluded-list`) -# no-reminders.png "No Reminders" (`--ui-testing-empty`) +# 1-main.png reminder card +# 2-more.png three-action menu — Skip / Reschedule / Delete +# 3-all-done.png "All Done" (`--ui-testing-excluded-list`) +# 4-no-reminders.png "No Reminders" (`--ui-testing-empty`) # # The menu and the empty states exist only behind seams: the menu needs a tap on # the Skip button (`--ui-testing-open-action-menu` presents it at launch), and @@ -31,9 +33,12 @@ # The options screen is presented by the `--ui-testing-open-settings` launch # seam (`LaunchArgument.openSettings`), which replays the gear button's action # at launch — the sheet has no URL scheme or menu command, and `simctl` cannot -# tap, so no other route reaches it. Capturing options therefore needs an app -# build that carries the seam; the script rebuilds by default rather than -# reusing a possibly stale .app (pass --no-build to skip). +# tap, so no other route reaches it. Its Reminder subscreen is reached the same +# way through `--ui-testing-open-settings-reminder` +# (`LaunchArgument.openSettingsReminder`), which seeds the Settings navigation +# path with the Reminder row's destination. Capturing either needs an app build +# that carries the seam; the script rebuilds by default rather than reusing a +# possibly stale .app (pass --no-build to skip). # # The main card shows one localized demo reminder — "Teach the cat how to do my # taxes" and its five translations, in `demo_reminder_seed` below. It travels in @@ -48,12 +53,14 @@ # before the seeded captures, because --ui-testing-app-language is read only # on the --ui-testing path. # -# Output: build/appstore-screenshots///.png +# Output: build/appstore-screenshots///.png # # Folders are language-major, so every localization of one device sits in a # single directory, and each language directory carries the App Store's own # ordering as a numeric prefix (`language_folder` below): 1-en, 2-zh-Hans, -# 3-fr, 4-de, 5-ja, 6-es. The output tree is cleared at the start of every +# 3-fr, 4-de, 5-ja, 6-es. The device directory inside it is numbered the same +# way (`class_folder` below) so the three display classes sort as +# 1-iphone, 2-ipad, 3-watch. The output tree is cleared at the start of every # run, so a stale layout can never linger beside the new one. # # The captures are the simulator's native pixels — 1206 x 2622 (6.3") and @@ -89,6 +96,9 @@ # iphone-6.3-empty ipad-13-empty ("All Done") # iphone-6.3-no-reminders ipad-13-no-reminders # +# `4-options-reminder-dark` is the same Settings sheet pushed one level in, so +# it stages the `options` photo rather than adding a slot of its own. +# # The set is COMMITTED — scripts/fixtures/appstore-wallpapers/ holds the ten # photos and manifest.json — so a clone captures the same wallpapers with no # network and nothing to draw. Override the directory with WALLPAPER_DIR to use @@ -117,7 +127,7 @@ # # Usage: scripts/appstore-screenshots.sh [options] [class ...] # class: iphone-6.3 | ipad-13 | watch-series11-46mm -# (default: the two iOS classes) +# (default: every class — both iOS classes and the watch) set -euo pipefail @@ -201,9 +211,34 @@ language_folder () { # $1 = language → numbered output directory name LANGUAGES=(en zh-Hans fr de ja es) -# Wallpaper slots per device class, in capture order. Slot names match the -# captured file names so a staged photo maps 1:1 onto an output PNG. -SCREENS=(main-light options main-dark empty no-reminders) +# App Store Connect's device order, mirrored onto the output directory inside +# each language folder. The class labels stay the internal keys (they key the +# committed wallpaper slots, e.g. `iphone-6.3-main-light`), so only the +# directory name is remapped here. +class_folder () { # $1 = class label → numbered output directory name + case "$1" in + iphone-6.3) echo "1-iphone" ;; + ipad-13) echo "2-ipad" ;; + watch-series11-46mm) echo "3-watch" ;; + *) return 1 ;; + esac +} + +# Capture order per device class, as `:`. The +# stems carry a numeric prefix so the listing's drag-over order is the file +# name's sort order; slots stay unprefixed because they key the committed +# wallpaper set (see the wallpaper section above), not the output names. +# +# `options` is staged twice on purpose: `4-options-reminder-dark` is the Reminder +# subscreen of the same sheet, so it shares the sheet's photo. +SCREENS=( + "main-dark:1-main-dark" + "empty:2-empty-dark" + "options:3-options-light" + "options:4-options-reminder-dark" + "main-light:5-main-light" + "no-reminders:6-no-reminders-dark" +) # class:simctl devicetype name regex DEVICE_TYPES=( @@ -251,7 +286,7 @@ while [ $# -gt 0 ]; do shift ;; -h | --help) - sed -n '2,120p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,128p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; -*) @@ -298,7 +333,7 @@ slot_names () { # every slot for the device classes in play, as - for entry in "${DEVICE_TYPES[@]}"; do label=${entry%%:*} for screen in "${SCREENS[@]}"; do - echo "$label-$screen" + echo "$label-${screen%%:*}" done done } @@ -456,7 +491,7 @@ prepare_wallpaper () { # never capture before a fresh photo is on disk { for screen in "${SCREENS[@]}"; do - slot="$LABEL-$screen" + slot="$LABEL-${screen%%:*}" if ! python3 -c ' import json, os, sys @@ -518,27 +553,42 @@ capture_class () { # $1 = class label, $2 = device type regex prepare_wallpaper for language in "${LANGUAGES[@]}"; do - dir="$OUT/$(language_folder "$language")/$label" + dir="$OUT/$(language_folder "$language")/$(class_folder "$label")" mkdir -p "$dir" echo " -> $language" demo_seed=$(demo_reminder_seed "$language") prime_language "$language" - stage_slot "$label" main-light - xcrun simctl ui "$UDID" appearance light - shoot "$dir/main-light.png" \ + + # Capture order follows the file-name prefixes in SCREENS, so appearance is + # set explicitly before each shot rather than once per language. + xcrun simctl ui "$UDID" appearance dark + stage_slot "$label" main-dark + shoot "$dir/1-main-dark.png" \ --seed "$demo_seed" --ui-testing-noop-settle -showSwipePrompt NO + + stage_slot "$label" empty + shoot "$dir/2-empty-dark.png" --seed "$ALL_DONE_SEED" + + xcrun simctl ui "$UDID" appearance light stage_slot "$label" options - shoot "$dir/options-light.png" \ + shoot "$dir/3-options-light.png" \ --ui-testing --ui-testing-noop-settle --ui-testing-app-language "$language" \ --ui-testing-open-settings - stage_slot "$label" main-dark + xcrun simctl ui "$UDID" appearance dark - shoot "$dir/main-dark.png" \ + stage_slot "$label" options + shoot "$dir/4-options-reminder-dark.png" \ + --ui-testing --ui-testing-noop-settle --ui-testing-app-language "$language" \ + --ui-testing-open-settings-reminder + + xcrun simctl ui "$UDID" appearance light + stage_slot "$label" main-light + shoot "$dir/5-main-light.png" \ --seed "$demo_seed" --ui-testing-noop-settle -showSwipePrompt NO - stage_slot "$label" empty - shoot "$dir/empty-dark.png" --seed "$ALL_DONE_SEED" + + xcrun simctl ui "$UDID" appearance dark stage_slot "$label" no-reminders - shoot "$dir/no-reminders-dark.png" --no-reminders + shoot "$dir/6-no-reminders-dark.png" --no-reminders done xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true @@ -586,17 +636,17 @@ capture_watch () { # $1 = device type regex xcrun simctl install "$UDID" "$WATCH_APP" for language in "${LANGUAGES[@]}"; do - dir="$OUT/$(language_folder "$language")/$WATCH_CLASS" + dir="$OUT/$(language_folder "$language")/$(class_folder "$WATCH_CLASS")" mkdir -p "$dir" echo " -> $language" watch_set_language "$language" - watch_shot "$dir/main.png" --ui-testing \ + watch_shot "$dir/1-main.png" --ui-testing \ --ui-testing-title "$(demo_reminder_title "$language")" --ui-testing-notes "" - watch_shot "$dir/more.png" --ui-testing \ + watch_shot "$dir/2-more.png" --ui-testing \ --ui-testing-title "$(demo_reminder_title "$language")" --ui-testing-notes "" \ --ui-testing-open-action-menu - watch_shot "$dir/all-done.png" --ui-testing --ui-testing-excluded-list Errands - watch_shot "$dir/no-reminders.png" --ui-testing --ui-testing-empty + watch_shot "$dir/3-all-done.png" --ui-testing --ui-testing-excluded-list Errands + watch_shot "$dir/4-no-reminders.png" --ui-testing --ui-testing-empty done xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true @@ -605,7 +655,7 @@ capture_watch () { # $1 = device type regex # ── Run ────────────────────────────────────────────────────────────────────── if [ ${#CLASSES[@]} -eq 0 ]; then - CLASSES=(iphone-6.3 ipad-13) + CLASSES=(iphone-6.3 ipad-13 watch-series11-46mm) fi # Clear the output tree: the language-major layout replaced an older From b930282e963172caa0255ba97d617429f7a48e69 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Fri, 16 Oct 2026 19:02:27 -0700 Subject: [PATCH 4/5] fix(periphery): read SettingsView.path directly so the scan sees it used Periphery under Xcode 27 misses $-projection-only @State and reported the navigation path as unused, failing the stage-fatal Periphery stage of ./scripts/test.sh before any test ran. ci.yml also pins xcode-version 27.0, so this reproduces in CI too. The property is used, so a periphery:ignore would be wrong (and is itself a finding on 3.5+). Instead give it a direct read: clearing the seeded route on disappear, matching the ContentView.isShowingSettings precedent. Also means a re-presented Settings opens at its root rather than a stale subscreen. --- SingleThread/SettingsView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/SingleThread/SettingsView.swift b/SingleThread/SettingsView.swift index a9427878..adddf16b 100644 --- a/SingleThread/SettingsView.swift +++ b/SingleThread/SettingsView.swift @@ -210,6 +210,16 @@ struct SettingsView: View { } } .modifier(TextSizeModifier(textSize: bindings.textSize)) + .onDisappear { + // Drop the seeded route when the sheet goes away so a re-presented + // Settings opens at its root. Reading `path` directly — not only + // through `$path` — is also what keeps it visible to Periphery, + // which misses `$`-projection-only `@State` under Xcode 27 and would + // otherwise report the property as unused. + if !path.isEmpty { + path = NavigationPath() + } + } } // MARK: Private From cd60c401fdb6e893af48bb45df1761fd6343be2a Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 17 Oct 2026 06:39:43 -0700 Subject: [PATCH 5/5] test: raise the AI-sort coordinator await horizon to 25 s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reRanksAfterSilentFallbackClearsTheDigest poll expired at the 20 s bound and then failed its 'emitted.count == 3' expectation, even though 'ranker.callCount == 2' passed — the coordinator re-ranked correctly, only the third onRankingUpdated emission missed the horizon. The test passes at ~20.7 s on a quiet host, so 20 s had no headroom. Pre-existing timing marginality: the file is unchanged on main and the release branch touches no SingleThreadCore code. Widening the poll bound is the same remedy the suite has applied before (79c63461, 5d274386). --- SingleThreadTests/AISortCoordinatorTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/SingleThreadTests/AISortCoordinatorTests.swift b/SingleThreadTests/AISortCoordinatorTests.swift index ae0384f7..c8154a6a 100644 --- a/SingleThreadTests/AISortCoordinatorTests.swift +++ b/SingleThreadTests/AISortCoordinatorTests.swift @@ -569,7 +569,9 @@ struct AISortCoordinatorTests { /// Polls until `condition` holds (or `timeout` elapses). Under parallel Swift /// Testing on macOS the coordinator's debounced MainActor task can be delayed /// past any fixed sleep, so the tests must await the *condition*, not a duration. - private func waitUntil(timeout: Duration = .seconds(20), _ condition: () -> Bool) async { + /// 25 s leaves headroom over the observed ~20.7 s worst case on a loaded host; + /// at the previous 20 s bound the poll expired and the caller's `#expect` failed. + private func waitUntil(timeout: Duration = .seconds(25), _ condition: () -> Bool) async { let clock = ContinuousClock() let deadline = clock.now + timeout while !condition(), clock.now < deadline {