diff --git a/Design/ScreenshotKit/README.md b/Design/ScreenshotKit/README.md new file mode 100644 index 0000000..ed373db --- /dev/null +++ b/Design/ScreenshotKit/README.md @@ -0,0 +1,50 @@ +# ScreenshotKit — App Store screenshots + +Produces the eight 6.9" (1320×2868) App Store pages described in +[docs/plans/app-store-screenshots.md](../../docs/plans/app-store-screenshots.md): +warm notebook page, green tracked kicker on a hairline rule, regular New York +headline, the real app as a flat photo in a warm-ink bezel, and the SproutMark +growing one stage per page. + +## 1. Capture the raw screens + +```bash +UD=$(xcrun simctl list devices available | grep -m1 "iPhone 17 Pro Max" | grep -oE "[0-9A-F-]{36}") +xcrun simctl boot "$UD" +xcrun simctl status_bar "$UD" override --time 9:41 --batteryState charged \ + --batteryLevel 100 --wifiBars 3 --cellularBars 4 +# frame 5 needs a fictional receipt in the photo library: +xcrun simctl addmedia "$UD" Design/ScreenshotKit/fixtures/receipt-corner-market.png + +xcodebuild test -project Savely.xcodeproj -scheme Savely \ + -destination "platform=iOS Simulator,id=$UD" \ + -only-testing:SavelyUITests/StoreScreenshotTourUITests \ + -parallel-testing-enabled NO -resultBundlePath build/Tour.xcresult +xcrun xcresulttool export attachments --path build/Tour.xcresult --output-path build/tour +Design/ScreenshotKit/collect.sh build/tour build/captures +``` + +The tour launches the app with `-SavelyScreenshotSeed` (DEBUG only, +`Savely/Utilities/ScreenshotSeed.swift`), which **wipes** the Simulator store and +seeds the fictional dataset — never run it against a device with real data. + +## 2. Compose the pages + +```bash +swift Design/ScreenshotKit/compose.swift build/captures build/store +``` + +Renders with SwiftUI's `ImageRenderer` on macOS, so the type is the real +New York / SF Pro (nothing embedded, nothing licensed for the web). Copy lives +in `compose.swift` — one file, one source of truth. + +## 3. Before upload + +```bash +sips -g hasAlpha build/store/*.png # must be "no"; flatten if not: +magick in.png -background '#f6f4ee' -alpha remove -alpha off out.png +``` + +Upload only the **6.9" slot** (Apple scales every smaller iPhone from it); +the project is iPhone-only (`TARGETED_DEVICE_FAMILY = 1`), so no iPad set is +required. Run the 25% zoom legibility check before submitting. diff --git a/Design/ScreenshotKit/collect.sh b/Design/ScreenshotKit/collect.sh new file mode 100755 index 0000000..bc31893 --- /dev/null +++ b/Design/ScreenshotKit/collect.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Renames the XCTest attachments exported by `xcresulttool export attachments` +# into the slugs compose.swift expects. +# collect.sh +set -euo pipefail +IN="${1:?usage: collect.sh }" +OUT="${2:?usage: collect.sh }" +mkdir -p "$OUT" +python3 - "$IN" "$OUT" <<'PY' +import json, os, shutil, sys +src, out = sys.argv[1], sys.argv[2] +manifest = json.load(open(os.path.join(src, "manifest.json"))) +for test in manifest: + for att in test.get("attachments", []): + name = att.get("suggestedHumanReadableName", "") + if name[:2].isdigit(): + slug = name.split("_")[0] + shutil.copy(os.path.join(src, att["exportedFileName"]), os.path.join(out, slug + ".png")) + print(slug) +PY diff --git a/Design/ScreenshotKit/compose.swift b/Design/ScreenshotKit/compose.swift new file mode 100644 index 0000000..f3b045f --- /dev/null +++ b/Design/ScreenshotKit/compose.swift @@ -0,0 +1,235 @@ +#!/usr/bin/env swift +// +// compose.swift — Savely App Store screenshot composer +// +// Renders the eight 6.9" pages (1320×2868) of docs/plans/app-store-screenshots.md +// from raw Simulator captures, using the real system fonts (New York / SF Pro) +// through SwiftUI's ImageRenderer on macOS. Nothing is embedded, nothing is +// redrawn inside the captures — the screenshot is placed, scaled and clipped. +// +// swift Design/ScreenshotKit/compose.swift +// +// Captures expected in (from StoreScreenshotTourUITests): +// 01-goal-on-track.png 02-privacy.png 03-payday.png 04-expense.png +// 05-receipt-review.png 06-month-trend.png 07-goal-behind.png 08-home-dark.png +// Output: savely-en-US-01-goal.png … savely-en-US-08-night.png (RGBA; flatten to +// RGB before upload — see Design/ScreenshotKit/README.md). +// + +import AppKit +import SwiftUI + +// MARK: - Tokens (DESIGN.md) + +struct Theme { + let bg, ink, inkSoft, green, line, bezel, bezelLine: Color + let leafPrimary, leafSecondary, seed: Color + static let light = Theme( + bg: hex(0xF6F4EE), ink: hex(0x1A1A17), inkSoft: hex(0x55524C), green: hex(0x2F6B4A), + line: Color(red: 30 / 255, green: 25 / 255, blue: 15 / 255).opacity(0.08), + bezel: hex(0x1A1A17), bezelLine: .clear, + leafPrimary: hex(0x2F6B4A), leafSecondary: hex(0x37795A), seed: hex(0xA8741C) + ) + static let dark = Theme( + bg: hex(0x161512), ink: hex(0xF2EFE7), inkSoft: hex(0xC6C1B5), green: hex(0x78B58F), + line: Color(red: 242 / 255, green: 239 / 255, blue: 231 / 255).opacity(0.10), + bezel: hex(0x262420), bezelLine: Color(red: 242 / 255, green: 239 / 255, blue: 231 / 255).opacity(0.10), + leafPrimary: hex(0x78B58F), leafSecondary: hex(0x5D9C7C), seed: hex(0xDEA64A) + ) + static func hex(_ v: UInt32) -> Color { + Color(red: Double((v >> 16) & 0xFF) / 255, green: Double((v >> 8) & 0xFF) / 255, blue: Double(v & 0xFF) / 255) + } +} + +// MARK: - Frames (copy lives here on purpose: one file, one source of truth) + +struct Frame { + let n: Int, slug: String, capture: String, kicker: String, headline: String, sub: String + let stage: Int // sprout growth stage 1…8 + let dark: Bool +} + +let frames: [Frame] = [ + Frame(n: 1, slug: "goal", capture: "01-goal-on-track", kicker: "Savings", + headline: "Your goal.\nA real pace.", + sub: "On track or behind — from what you actually put in.", stage: 1, dark: false), + Frame(n: 2, slug: "privacy", capture: "02-privacy", kicker: "Your data", + headline: "No account.\nNo cloud.", + sub: "Your money data lives on your iPhone and never leaves it.", stage: 2, dark: false), + Frame(n: 3, slug: "payday", capture: "03-payday", kicker: "Payday", + headline: "Save before\nyou spend.", + sub: "Log your pay, tap Yes, and part of it goes to your goal.", stage: 3, dark: false), + Frame(n: 4, slug: "expense", capture: "04-expense", kicker: "Everyday money", + headline: "Log it in\nseconds.", + sub: "Amount, a category, done. The rest is optional.", stage: 4, dark: false), + Frame(n: 5, slug: "receipt", capture: "05-receipt-review", kicker: "Receipts", + headline: "Scan it.\nIt stays here.", + sub: "Total, merchant, date — read on your iPhone, never uploaded.", stage: 5, dark: false), + Frame(n: 6, slug: "month", capture: "06-month-trend", kicker: "The month", + headline: "Bad months\ncount too.", + sub: "Real trends, month over month. Drops included.", stage: 6, dark: false), + Frame(n: 7, slug: "pace", capture: "07-goal-behind", kicker: "The pace", + headline: "The date\nis honest.", + sub: "The ETA comes from real deposits. Behind is shown, not hidden.", stage: 7, dark: false), + Frame(n: 8, slug: "night", capture: "08-home-dark", kicker: "At night", + headline: "The notebook,\nunder a lamp.", + sub: "Dark mode keeps the warmth. Same calm.", stage: 8, dark: true), +] + +// MARK: - Sprout (SproutMark.swift geometry, 120-unit space) + +func pt(_ x: CGFloat, _ y: CGFloat, _ r: CGRect) -> CGPoint { + CGPoint(x: r.minX + x / 120 * r.width, y: r.minY + y / 120 * r.height) +} +struct StemShape: Shape { + func path(in r: CGRect) -> Path { + var p = Path(); p.move(to: pt(60, 87, r)) + p.addCurve(to: pt(60, 56, r), control1: pt(52, 76, r), control2: pt(68, 68, r)); return p + } +} +struct SeedShape: Shape { + func path(in r: CGRect) -> Path { + let c = pt(60, 92, r); let rad = 9 / 120 * r.width + return Path(ellipseIn: CGRect(x: c.x - rad, y: c.y - rad, width: rad * 2, height: rad * 2)) + } +} +struct LeafShape: Shape { + let left: Bool + func path(in r: CGRect) -> Path { + var p = Path(); p.move(to: pt(60, 56, r)) + if left { + p.addCurve(to: pt(36, 26, r), control1: pt(42, 54, r), control2: pt(34, 40, r)) + p.addCurve(to: pt(60, 56, r), control1: pt(52, 28, r), control2: pt(60, 42, r)) + } else { + p.addCurve(to: pt(82, 28, r), control1: pt(74, 52, r), control2: pt(82, 40, r)) + p.addCurve(to: pt(60, 56, r), control1: pt(68, 30, r), control2: pt(60, 42, r)) + } + p.closeSubpath(); return p + } +} + +/// Growth stages: 1 seed · 2 stem 35 % · 3 stem 100 % · 4 + left leaf 45 % · +/// 5 left leaf 100 % · 6 + right leaf 45 % · 7 full · 8 full (dark tokens). +struct Sprout: View { + let stage: Int, theme: Theme, size: CGFloat + var stem: CGFloat { stage <= 1 ? 0 : (stage == 2 ? 0.35 : 1) } + var leftLeaf: CGFloat { stage <= 3 ? 0 : (stage == 4 ? 0.45 : 1) } + var rightLeaf: CGFloat { stage <= 5 ? 0 : (stage == 6 ? 0.45 : 1) } + var body: some View { + let tip = UnitPoint(x: 60 / 120, y: 56 / 120) + ZStack { + StemShape().trim(from: 0, to: stem) + .stroke(theme.leafPrimary, style: StrokeStyle(lineWidth: size * 5.5 / 120, lineCap: .round)) + SeedShape().fill(theme.seed) + LeafShape(left: true).fill(theme.leafPrimary).scaleEffect(leftLeaf, anchor: tip) + LeafShape(left: false).fill(theme.leafSecondary).scaleEffect(rightLeaf, anchor: tip) + } + .frame(width: size, height: size) + } +} + +// MARK: - Page + +let W: CGFloat = 1320, H: CGFloat = 2868 +let margin: CGFloat = 96 +let ruleY: CGFloat = 192 +let sproutSize: CGFloat = 240 +let bezelWidth: CGFloat = 1002, bezelInset: CGFloat = 26, bezelTop: CGFloat = 980 +let screenWidth: CGFloat = bezelWidth - 2 * bezelInset // 950 +let screenHeight: CGFloat = screenWidth * H / W // 2064 +let bezelRadius: CGFloat = 160, screenRadius: CGFloat = 134 + +struct Page: View { + let frame: Frame + let shot: NSImage + var theme: Theme { frame.dark ? .dark : .light } + + var body: some View { + ZStack(alignment: .topLeading) { + theme.bg + + // Header rule (full bleed) — the one "ruled paper" cue. + Rectangle().fill(theme.line).frame(width: W, height: 3).offset(y: ruleY) + + // Kicker — the app's own tracked label, in green as ink. + Text(frame.kicker.uppercased()) + .font(.system(size: 36, weight: .semibold)) + .tracking(3.6) + .foregroundStyle(theme.green) + .offset(x: margin, y: 118) + + // Growth mark: seed tangent to the rule, right-aligned to the margin. + Sprout(stage: frame.stage, theme: theme, size: sproutSize) + .offset(x: W - margin - sproutSize, y: ruleY - sproutSize * 101 / 120 + 1.5) + + // Headline + sub + VStack(alignment: .leading, spacing: 28) { + Text(frame.headline) + .font(.system(size: 136, weight: .regular, design: .serif)) + .foregroundStyle(theme.ink) + .lineSpacing(-4) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 1128, alignment: .leading) + Text(frame.sub) + .font(.system(size: 46, weight: .regular)) + .foregroundStyle(theme.inkSoft) + .lineSpacing(10) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 980, alignment: .leading) + } + .offset(x: margin, y: 236) + + // The photo: flat, untilted, tucked under the page edge. + device + .offset(x: margin, y: bezelTop) + } + .frame(width: W, height: H) + .clipped() + } + + var device: some View { + ZStack(alignment: .top) { + RoundedRectangle(cornerRadius: bezelRadius, style: .continuous) + .fill(theme.bezel) + .overlay(RoundedRectangle(cornerRadius: bezelRadius, style: .continuous).stroke(theme.bezelLine, lineWidth: 3)) + Image(nsImage: shot) + .resizable() + .interpolation(.high) + .frame(width: screenWidth, height: screenHeight) + .clipShape(RoundedRectangle(cornerRadius: screenRadius, style: .continuous)) + .offset(y: bezelInset) + // Dynamic Island as part of the bezel silhouette. + Capsule().fill(theme.bezel).frame(width: 270, height: 80).offset(y: bezelInset + 18) + } + .frame(width: bezelWidth, height: screenHeight + 2 * bezelInset + 200, alignment: .top) + } +} + +// MARK: - Main + +let args = CommandLine.arguments +guard args.count >= 3 else { + FileHandle.standardError.write(Data("usage: compose.swift \n".utf8)) + exit(2) +} +let capturesDir = URL(fileURLWithPath: args[1]) +let outDir = URL(fileURLWithPath: args[2]) +try? FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) + +MainActor.assumeIsolated { + for frame in frames { + let shotURL = capturesDir.appendingPathComponent(frame.capture + ".png") + guard let shot = NSImage(contentsOf: shotURL) else { + print("!! missing capture \(shotURL.path)"); continue + } + let renderer = ImageRenderer(content: Page(frame: frame, shot: shot)) + renderer.scale = 1 + renderer.proposedSize = ProposedViewSize(width: W, height: H) + guard let cg = renderer.cgImage else { print("!! render failed \(frame.n)"); continue } + let rep = NSBitmapImageRep(cgImage: cg) + guard let png = rep.representation(using: .png, properties: [:]) else { continue } + let name = String(format: "savely-en-US-%02d-%@.png", frame.n, frame.slug) + let out = outDir.appendingPathComponent(name) + do { try png.write(to: out); print("wrote \(name) \(cg.width)x\(cg.height)") } catch { print("!! \(error)") } + } +} diff --git a/Design/ScreenshotKit/fixtures/receipt-corner-market.png b/Design/ScreenshotKit/fixtures/receipt-corner-market.png new file mode 100644 index 0000000..d792b62 Binary files /dev/null and b/Design/ScreenshotKit/fixtures/receipt-corner-market.png differ diff --git a/Savely.xcodeproj/project.pbxproj b/Savely.xcodeproj/project.pbxproj index c98d9f2..ea1fb0c 100644 --- a/Savely.xcodeproj/project.pbxproj +++ b/Savely.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 120AE56C41CBD54B070A8436 /* ScreenshotSeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11E18C2383669F09FCC4748 /* ScreenshotSeed.swift */; }; + F25F23C6EEAA47853BFA137D /* StoreScreenshotTourUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */; }; C5DDD46B40453371BD90C01A /* ReceiptScanUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */; }; 7479BDFD4C96ED1C9E05A119 /* ReceiptOCRIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */; }; 31A69355DA4D561A928D09D7 /* DocumentCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DA345E04E3ACA1DEDFCDD2 /* DocumentCameraView.swift */; }; @@ -120,6 +122,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + F11E18C2383669F09FCC4748 /* ScreenshotSeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotSeed.swift; sourceTree = ""; }; + 46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreScreenshotTourUITests.swift; sourceTree = ""; }; C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptScanUITests.swift; sourceTree = ""; }; 4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptOCRIntegrationTests.swift; sourceTree = ""; }; D2DA345E04E3ACA1DEDFCDD2 /* DocumentCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentCameraView.swift; sourceTree = ""; }; @@ -304,6 +308,7 @@ 1D88B7D62CC9FA28009B6538 /* Utilities */ = { isa = PBXGroup; children = ( + F11E18C2383669F09FCC4748 /* ScreenshotSeed.swift */, D2C90599A224DF05C1C7BC95 /* ReceiptOCR.swift */, 5A440101E954A364F752379E /* Receipt */, F774124079B5BEC3DD37C743 /* AutoMoveSuggestion.swift */, @@ -530,6 +535,7 @@ 1DC258212CC07DCF0002DDE3 /* SavelyUITests */ = { isa = PBXGroup; children = ( + 46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */, C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */, 1DC258222CC07DCF0002DDE3 /* SavelyUITests.swift */, 1DC258242CC07DCF0002DDE3 /* SavelyUITestsLaunchTests.swift */, @@ -736,6 +742,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 120AE56C41CBD54B070A8436 /* ScreenshotSeed.swift in Sources */, 31A69355DA4D561A928D09D7 /* DocumentCameraView.swift in Sources */, 15C9F87752F242860A062A9C /* ReceiptScanFlowView.swift in Sources */, 6BEBD87A257D196FC2B5F572 /* ReceiptReviewView.swift in Sources */, @@ -834,6 +841,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F25F23C6EEAA47853BFA137D /* StoreScreenshotTourUITests.swift in Sources */, C5DDD46B40453371BD90C01A /* ReceiptScanUITests.swift in Sources */, 1DC258252CC07DCF0002DDE3 /* SavelyUITestsLaunchTests.swift in Sources */, 1DC258232CC07DCF0002DDE3 /* SavelyUITests.swift in Sources */, @@ -1024,7 +1032,7 @@ SUPPORTS_MACCATALYST = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -1060,7 +1068,7 @@ SUPPORTS_MACCATALYST = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -1082,7 +1090,7 @@ SUPPORTS_MACCATALYST = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Savely.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Savely"; }; name = Debug; @@ -1105,7 +1113,7 @@ SUPPORTS_MACCATALYST = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Savely.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Savely"; }; name = Release; @@ -1126,7 +1134,7 @@ SUPPORTS_MACCATALYST = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; TEST_TARGET_NAME = Savely; }; name = Debug; @@ -1147,7 +1155,7 @@ SUPPORTS_MACCATALYST = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; TEST_TARGET_NAME = Savely; }; name = Release; diff --git a/Savely/Utilities/ScreenshotSeed.swift b/Savely/Utilities/ScreenshotSeed.swift new file mode 100644 index 0000000..1100831 --- /dev/null +++ b/Savely/Utilities/ScreenshotSeed.swift @@ -0,0 +1,106 @@ +// +// ScreenshotSeed.swift +// Savely +// +// DEBUG-only fictional dataset for App Store screenshots. Applied once at +// launch when the app is started with `-SavelyScreenshotSeed` (see +// docs/plans/app-store-screenshots.md §3 for why these exact figures: every +// caption in the store must be provable on the screen it sits over). +// +// It WIPES the local store first — it exists for the Simulator only and is +// compiled out of Release builds. Never run it against real data. +// + +#if DEBUG +import Foundation +import SwiftData + +enum ScreenshotSeed { + static let launchArgument = "-SavelyScreenshotSeed" + + static var isRequested: Bool { + CommandLine.arguments.contains(launchArgument) + } + + /// Replaces everything in `context` with the screenshot dataset. + @MainActor + static func apply(to context: ModelContext, now: Date = Date(), calendar: Calendar = .current) throws { + try context.delete(model: DepositModel.self) + try context.delete(model: GoalModel.self) + try context.delete(model: IncomeModel.self) + try context.delete(model: ExpenseModel.self) + + func daysAgo(_ days: Int, hour: Int = 12) -> Date { + let day = calendar.date(byAdding: .day, value: -days, to: now) ?? now + return calendar.date(bySettingHour: hour, minute: 15, second: 0, of: day) ?? day + } + func date(_ year: Int, _ month: Int, _ day: Int) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day, hour: 12)) ?? now + } + + // Goal A — the favorite. $500 deposited inside GoalPace's 4-week + // window → actual pace $125/wk; deadline Dec 12 2026 → required + // ≈ $82/wk → "On track · by Dec 12, 2026 · $125/wk". + let oaxaca = GoalModel( + name: "Trip to Oaxaca", current: 1010, target: 2400, color: .sage, + isFavorite: true, autoMoveEnabled: true, autoMoveAmount: 150, + deadline: date(2026, 12, 12) + ) + context.insert(oaxaca) + for (offset, amount) in [(3, 125.0), (10, 125.0), (17, 125.0), (24, 125.0)] { + context.insert(DepositModel(goalID: oaxaca.id, amount: amount, date: daysAgo(offset), source: .autoMove)) + } + context.insert(DepositModel(goalID: oaxaca.id, amount: 510, date: daysAgo(60), source: .manual)) + + // Goal B — honestly behind. $100 in the window → $25/wk; deadline + // Mar 1 2027 → required ≈ $53/wk → "Behind"; ETA 59 wk → "1+ year". + let laptop = GoalModel( + name: "Laptop", current: 320, target: 1800, color: .blue, + isFavorite: false, autoMoveEnabled: false, autoMoveAmount: 0, + deadline: date(2027, 3, 1) + ) + context.insert(laptop) + for (offset, amount) in [(6, 50.0), (20, 50.0)] { + context.insert(DepositModel(goalID: laptop.id, amount: amount, date: daysAgo(offset), source: .manual)) + } + context.insert(DepositModel(goalID: laptop.id, amount: 220, date: daysAgo(75), source: .manual)) + + // Goal C — small, on track, no deadline. + let bike = GoalModel(name: "New bike", current: 180, target: 600, color: .yellow) + context.insert(bike) + context.insert(DepositModel(goalID: bike.id, amount: 60, date: daysAgo(9), source: .manual)) + context.insert(DepositModel(goalID: bike.id, amount: 120, date: daysAgo(40), source: .manual)) + + // Income — one paycheck per month for seven months. Last month is + // higher than this month so the 7-month trend shows a real drop. + let paychecks: [Double] = [2200, 2400, 2350, 2500, 2450, 2600, 2280] + for (i, amount) in paychecks.enumerated() { + let monthsAgo = paychecks.count - 1 - i + let base = calendar.date(byAdding: .month, value: -monthsAgo, to: now) ?? now + var comps = calendar.dateComponents([.year, .month], from: base) + comps.day = min(12, calendar.component(.day, from: now)) // never in the future + comps.hour = 9 + let payday = calendar.date(from: comps) ?? base + context.insert(IncomeModel(incomeDescription: "Paycheck", amount: amount, date: payday, source: "Paycheck")) + } + context.insert(IncomeModel(incomeDescription: "Freelance", amount: 340, date: daysAgo(19), source: "Freelance")) + + // Expenses — this month, small and ordinary. Merchants are fictional. + let expenses: [(String, Double, Int, ExpenseCategory)] = [ + ("Café Norte", 7.50, 0, .coffee), + ("Corner Market", 23.80, 1, .food), + ("Bus pass", 14.00, 2, .transit), + ("Pharmacy", 32.10, 4, .shopping), + ("Café Norte", 6.75, 5, .coffee), + ("Taquería del Sol", 18.40, 6, .food), + ("Corner Market", 41.25, 9, .food), + ("Movie night", 24.00, 12, .other), + ] + for (name, amount, offset, category) in expenses { + context.insert(ExpenseModel(expenseDescription: name, amount: amount, date: daysAgo(offset, hour: 18), category: category.rawValue)) + } + + try context.save() + } +} +#endif diff --git a/Savely/Views/SplashScreenView.swift b/Savely/Views/SplashScreenView.swift index 1380e14..43fd133 100755 --- a/Savely/Views/SplashScreenView.swift +++ b/Savely/Views/SplashScreenView.swift @@ -6,6 +6,7 @@ import SwiftUI /// sequence is skipped for Reduce Motion users (single crossfade instead). struct SplashScreenView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.modelContext) private var modelContext @State private var isSplashEnded = false // Sprout @@ -104,9 +105,23 @@ struct SplashScreenView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .onAppear(perform: runAnimation) + .onAppear(perform: seedForScreenshotsIfRequested) } } + /// DEBUG + `-SavelyScreenshotSeed` only: replace the store with the + /// fictional App Store dataset before the main screens appear. + private func seedForScreenshotsIfRequested() { + #if DEBUG + guard ScreenshotSeed.isRequested else { return } + do { + try ScreenshotSeed.apply(to: modelContext) + } catch { + print("Screenshot seed failed: \(error)") + } + #endif + } + /// The sprout mark, drawn natively so each part can animate: /// stem below seed (matches the icon's layer order), leaves anchored /// at the stem tip (60,56 in the 120-unit design space). diff --git a/SavelyUITests/StoreScreenshotTourUITests.swift b/SavelyUITests/StoreScreenshotTourUITests.swift new file mode 100644 index 0000000..4a9696b --- /dev/null +++ b/SavelyUITests/StoreScreenshotTourUITests.swift @@ -0,0 +1,137 @@ +// +// StoreScreenshotTourUITests.swift +// SavelyUITests +// +// Not a test of behaviour — the App Store screenshot tour. Launches the app +// with the fictional `-SavelyScreenshotSeed` dataset, walks the eight +// frames of docs/plans/app-store-screenshots.md and attaches a full- +// resolution capture of each. Run on the iPhone 17 Pro Max simulator +// (1320×2868 native) after `xcrun simctl status_bar … override --time 9:41`: +// +// xcodebuild test -scheme Savely -only-testing:SavelyUITests/StoreScreenshotTourUITests \ +// -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \ +// -parallel-testing-enabled NO -resultBundlePath build/Tour.xcresult +// xcrun xcresulttool export attachments --path build/Tour.xcresult --output-path build/tour +// +// Frame 5 (receipt review) needs a fictional receipt image in the +// Simulator's photo library (`simctl addmedia`). Skipped on CI like the +// rest of SavelyUITests. +// + +import XCTest + +final class StoreScreenshotTourUITests: XCTestCase { + + private var app: XCUIApplication = XCUIApplication() + + private func launch(dark: Bool = false) { + app = XCUIApplication() + app.launchArguments += ["-isOnboardingComplete", "YES", "-SavelyScreenshotSeed", "-darkModeEnabled", dark ? "YES" : "NO"] + app.launch() + XCTAssertTrue(app.buttons["Add"].waitForExistence(timeout: 25), "the main tab bar did not appear") + } + + @MainActor + func testFrame1And7GoalDetails() throws { + launch() + app.buttons["Goals"].firstMatch.tap() + // The goal card is an accessibility container, so tap its name. + let oaxaca = app.staticTexts["Trip to Oaxaca"] + XCTAssertTrue(oaxaca.waitForExistence(timeout: 8), "seeded goal missing — was -SavelyScreenshotSeed applied?") + snap("02-goals-list") + oaxaca.tap() + XCTAssertTrue(app.navigationBars["Goal"].waitForExistence(timeout: 8), "goal detail did not open") + snap("01-goal-on-track") + app.navigationBars.buttons.firstMatch.tap() + + let laptop = app.staticTexts["Laptop"] + XCTAssertTrue(laptop.waitForExistence(timeout: 8)) + laptop.tap() + XCTAssertTrue(app.navigationBars["Goal"].waitForExistence(timeout: 8)) + snap("07-goal-behind") + } + + @MainActor + func testFrame2Privacy() throws { + launch() + app.buttons["Me"].firstMatch.tap() + _ = app.buttons["Me"].firstMatch.waitForExistence(timeout: 2) + // Scroll to the bottom so the Data & privacy block sits high enough to + // survive the device crop in the composed page. + for _ in 0..<9 { app.swipeUp() } + XCTAssertTrue(app.staticTexts["Your data never leaves this iPhone."].waitForExistence(timeout: 5)) + snap("02-privacy") + } + + @MainActor + func testFrame3PaydayAndFrame4Expense() throws { + launch() + app.buttons["Add"].tap() + let income = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Log income'")).firstMatch + XCTAssertTrue(income.waitForExistence(timeout: 5)) + income.tap() + XCTAssertTrue(app.staticTexts["Log income"].waitForExistence(timeout: 5)) + for key in ["1", "2", "4", "0"] { app.buttons[key].firstMatch.tap() } + _ = app.buttons["YES"].waitForExistence(timeout: 3) + snap("03-payday") + app.buttons["Back"].firstMatch.tap() + + let expense = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Log expense'")).firstMatch + XCTAssertTrue(expense.waitForExistence(timeout: 5)) + expense.tap() + XCTAssertTrue(app.staticTexts["Log expense"].waitForExistence(timeout: 5)) + for key in ["7", "Decimal point", "5", "0"] { app.buttons[key].firstMatch.tap() } + snap("04-expense") + } + + @MainActor + func testFrame5ReceiptReview() throws { + launch() + app.buttons["Add"].tap() + let scan = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Scan a receipt'")).firstMatch + XCTAssertTrue(scan.waitForExistence(timeout: 5)) + scan.tap() + let picker = app.buttons["Choose from Photos"] + XCTAssertTrue(picker.waitForExistence(timeout: 8)) + picker.tap() + let photo = app.images.matching(NSPredicate(format: "label BEGINSWITH 'Photo'")).firstMatch + guard photo.waitForExistence(timeout: 60) else { + throw XCTSkip("no receipt photo in the Simulator library — add one with simctl addmedia") + } + photo.tap() + XCTAssertTrue(app.staticTexts["Check the receipt"].waitForExistence(timeout: 30)) + snap("05-receipt-review") + } + + @MainActor + func testFrame6MonthTrend() throws { + launch() + app.buttons["Money"].firstMatch.tap() + let income = app.buttons["Income"].firstMatch + XCTAssertTrue(income.waitForExistence(timeout: 8)) + income.tap() + let trend = app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] '7-month trend'")).firstMatch + XCTAssertTrue(trend.waitForExistence(timeout: 8), "income trend not found") + snap("06-month-trend") + app.buttons["Expenses"].firstMatch.tap() + _ = app.staticTexts["Expenses"].waitForExistence(timeout: 3) + snap("06b-expenses") + } + + @MainActor + func testFrame8HomeDarkAndHomeLight() throws { + launch(dark: true) + _ = app.staticTexts["Recent"].waitForExistence(timeout: 5) + snap("08-home-dark") + launch(dark: false) + _ = app.staticTexts["Recent"].waitForExistence(timeout: 5) + snap("00-home-light") + } + + private func snap(_ name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/docs/plans/app-store-screenshots.md b/docs/plans/app-store-screenshots.md new file mode 100644 index 0000000..70940bf --- /dev/null +++ b/docs/plans/app-store-screenshots.md @@ -0,0 +1,232 @@ +# App Store screenshots — direction & production plan + +**Date:** 2026-08-17 +**Status:** direction chosen and PRODUCED — the en-US set is rendered (`Design/ScreenshotKit`, output in `build/upload/`). es-MX set deferred (owner: English-only for this release). +**Owner decision needed:** none blocking — the choices below are recommendations +with the alternatives kept; flip anything before production starts. + +Research method: three parallel researchers (Apple specs & review rules verified on +developer.apple.com; a survey of ~20 finance/indie/editorial App Store pages opened +live; copy craft for en-US + es-MX), three independent art-direction proposals against +DESIGN.md/PRODUCT.md, then a judge scoring fit / distinctness / store effectiveness / +honesty / producibility. Full research dump: session artifact "Savely storefront". + +--- + +## 0. TL;DR + +- **Direction: "Seed to Sprout" on the notebook page.** Every screenshot is one page of + the same warm notebook (ground `#f6f4ee`, one full-bleed hairline rule under a small + green tracked kicker, a two-line *regular* New York headline, one SF Pro sentence) with + the real app as a flat, untilted photo in a thin warm-ink bezel tucked under the page + edge. The only thing that changes from page to page besides the copy is the + **SproutMark growing one stage per page** — seed on 1, stem on 2–3, first leaf 4–5, + second leaf 6–7, full sprout under a lamp (dark mode) on 8. It is the splash + animation's own geometry laid across the gallery: "a well-made notebook that + something is growing in", literally. +- Why it is *ours*: not Alisio (no colored bands, no bold sans, no dark UI), not Fingo + (no gradients, no tilt, no shouting), not Thirds (Thirds floats UI cards on cream with a + heavy serif and no device; Savely tucks a framed photo, keeps the serif at weight 400, + puts a green kicker on a rule, and grows a plant), and not the 2025-26 "cream + + serif + orange kicker" default that Monarch/Matter now own (green kicker, sprout, + ink bezel, no italics, warm `#f6f4ee` not ivory). +- **Ledger rule** (from PRODUCT.md, applied to the store): every state a caption names — + "on track", "behind", "a drop", "never uploaded", a figure — must be visible in the UI + on that same page. This decides which screen each frame shows. +- **8 frames, en-US + Spanish (Mexico)**, master at 1320×2868 (6.9"), Apple scales the + rest. Light 1–7, dark 8. Order 1 goal → 2 privacy → 3 payday; PPO-test payday-first + and a full-dark set later. +- **Blocking prerequisites before shooting** (§5): es-419 gaps (~139 strings + hard-coded + English literals on the exact screens we show), the truth gate for "No account. No + cloud." (Config.plist with the OpenAI key is in the Resources phase; ATS exception for + openai.com; stray GoogleService-Info.plist), `TARGETED_DEVICE_FAMILY = 1,2` (iPad set + or go iPhone-only), a seeded fictional dataset, and dark mode via `-darkModeEnabled YES`. + +--- + +## 1. Facts that shape the plan (verified 2026-08-17) + +| Fact | Consequence | +|---|---| +| iPhone: only the **6.9"** set is effectively required (1320×2868 / 1290×2796 / 1260×2736, portrait); every smaller size scales down from it. 1–10 images, PNG/JPG, **no alpha**. | One master set per language. Skip 6.5"/6.3"/6.1"/5.5". Optional 4.7" (750×1334) only if SE users matter. | +| iPad 13" (2064×2752) is required **if the app runs on iPad**; the project has `TARGETED_DEVICE_FAMILY = "1,2"`. | Set it to `1` (iPhone-only) or budget an iPad adaptation. | +| Search results show the **first 3 portrait screenshots** (1 if landscape); Apple: "make sure these highlight the essence of your app"; one Dark Mode screenshot recommended. | Portrait; frames 1–3 carry the whole pitch; frame 8 dark. | +| Review 2.3.3: screenshots must show the app in use — not merely title art / splash. Overlays and text allowed. 2.3.7: no prices/"free"/"best". 2.3.9: fictional data, not a real person's. | Every frame shows real UI; the privacy frame shows a settings screen, not a slogan card; no laurels/user counts; seeded fictional data. | +| App Store Connect languages: **Spanish (Mexico)** (default for MX + 16 LatAm storefronts, and an additional language on the US storefront) — there is no es-419 slot. | Upload en-US + es-MX; the es-MX set serves LatAm and US Spanish speakers. | +| Product Page Optimization: ≤3 treatments, 90 days, per locale, submittable without a new build (if no icon change). | Full-dark set and "payday-first" order are PPO treatments, not part of v1. | +| iOS 27 "Creative Assets" (product-page header + search-results image) announced at WWDC26, specs pending. | Keep the same grammar (kicker + rule + sprout) ready for a header asset later; iOS 26 users still see frames 1–3. | +| Legibility math (derived, not Apple's): a 1320px canvas is ≈118pt wide in a 3-up search result → a 136–144px headline reads ≈12–13pt. | Headline ≥136px, ≤5 words, exactly 2 lines, in the top 25% of the canvas. | +| Dark mode in Savely is an **in-app override** (`.preferredColorScheme` from `@AppStorage("darkModeEnabled")`); `simctl ui appearance dark` does nothing. | Launch with `-darkModeEnabled YES` for frame 8. | + +--- + +## 2. The system ("Seed to Sprout on the notebook page") + +All coordinates on the 1320×2868 master. Everything is left-aligned at x=96 on every +frame; nothing alternates, nothing pans across frames — patience means the same +position on every page. + +**Page** +- Ground: `bg #f6f4ee` (dark frame: `bg-dark #161512`). No texture, grain, vignette or gradient. +- Header rule: 3px `line` rgba(30,25,15,.08) full-bleed at **y=192** (dark: `line-dark` rgba(242,239,231,.10)). The only "ruled paper" cue. +- Kicker: x=96, baseline ≈ y 156, **SF Pro Semibold 36px, uppercase, tracking +0.10em, Meadow Green `#2f6b4a`** (dark `#78b58f`). ≤3 words. It is the app's own 11pt tracked label scaled up — the one and only kicker style. No tinted tile, no glyph. +- Headline: y 256–540, **New York Regular 136–144px**, line-height 1.04, tracking −0.005em, ink `#1a1a17` (dark `#f2efe7`), max-width 1128, ≤5 words, **hand-broken to exactly two lines** on every frame so the horizon never moves. Never bold, never italic. +- Sub: 28px below, **SF Pro Regular 44–46px** / 1.3, `ink-soft #55524c` (dark `#c6c1b5`), max-width 980, ≤12 words, ≤2 lines. Sentence case, periods, no exclamation marks. +- Growth mark: SproutMark geometry (SproutMark.swift, 120-unit space) in a **~180px box right-aligned to x=1224, seed tangent to the header rule** (box top ≈ 192 − 180·101/120 ≈ 40) — the plant grows out of the notebook line. Stages: 1 seed only · 2 stem 35% · 3 stem 100% · 4 + left leaf 45% · 5 left leaf 100% · 6 + right leaf 45% · 7 full · 8 full in dark tokens (leaves `#78b58f`/`#5d9c7c`, seed `#dea64a`). Flat fills, no outline, no gradient, never bigger, never a mascot. +- Device ("the photo"): flat, untilted, left-flush. Bezel x=96→1098 (1002 wide), y=1024→off canvas, 26px warm ink `#1a1a17`, outer radius 160; screen 950 wide = the source capture ×0.72, radius 134, `overflow:hidden`; Dynamic Island 270×80 in the same ink as part of the bezel silhouette. The canvas bottom crops the tab bar/home indicator under the page edge. Dark frame: bezel `surface-dark #262420` + a `line-dark` hairline outside it (an ink bezel vanishes on the dark ground). No shadow, no reflection, no Apple photographic titanium. +- Right of the device the page keeps its empty margin (x 1098–1224) — the notebook margin. + +**Color discipline (One-Green Rule at poster scale):** green appears only *as ink* — kicker + sprout — plus whatever the real UI carries. Never a green ground/band/frame. Amber/clay/sky/lilac appear only *inside* the screenshot (tiles, chips, the clay "Behind"). Ink-not-gray for every muted text. + +**Banned:** colored bands (Alisio), gradients/blobs/tilt (Fingo/YNAB/Copilot), silver photographic bezels, drop shadows, floating Dynamic-Island pill without a bezel, callouts/arrows/pop-out cards (v1), laurels/press/user counts, illustration or mascot beyond the four SproutMark shapes, confetti, emoji, italic or bold serif, a second kicker style, prices/"free"/"best"/"#1", a brand-only slide, page folios. + +**Contrast:** ink on ground ≈16:1, ink-soft ≈7:1, green kicker ≈5.6:1 (light); `#f2efe7`/`#78b58f` on `#161512` ≈15:1 / 7:1 (dark) — all AA+. + +--- + +## 3. Sequence & copy (en-US · es-MX) + +Kickers are page headers, headlines are ≤5 words / 2 lines, subs ≤12 words. Spanish is +written as its own sentence (tú, no exclamation marks, "recibo" not "ticket", +"quincena" is deliberately Mexican). Every sub obeys the ledger rule. + +| # | Screen (must show…) | Sprout | Kicker | Headline | Sub | +|---|---|---|---|---|---| +| 1 | **GoalDetailView** of the favorite goal — ring, status line "On track · by Dec 12 · $125/wk", pace pills. (The Dashboard hero card only says "PACE · On track", so the detail view is the honest choice.) | seed | SAVINGS · AHORRO | Your goal.
A real pace. · Tu meta.
Ritmo real. | On track or behind — from what you actually put in. · A tiempo o atrasada, según lo que de verdad depositas. | +| 2 | **Profile → Data & privacy** ("Your data never leaves this iPhone." — es-419 exists). PPO alternate: onboarding "Yours, and only yours" (needs es-419 first). | stem 35% | YOUR DATA · TUS DATOS | No account.
No cloud. · Sin cuenta.
Sin nube. | Your money data lives on your iPhone and never leaves it. · Tus datos viven en tu iPhone y nunca salen de ahí. | +| 3 | **Log income sheet** with the green-soft auto-move banner, un-armed ("Auto-move $150 to Trip to Oaxaca from this paycheck?" + YES pill), keypad cut at its last row. | stem 100% | PAYDAY · DÍA DE PAGO | Save before
you spend. · Ahorra antes
de gastar. | Log your pay, tap Yes, and part of it goes to your goal. · Registra tu quincena, toca Sí y una parte se va a tu meta. | +| 4 | **Log expense keypad**, Display amount, Coffee chip selected (amber tile visible). | left leaf 45% | EVERYDAY MONEY · GASTO DIARIO | Log it in
seconds. · Anótalo en
segundos. | Amount, a category, done. The rest is optional. · Monto, una categoría y listo. Lo demás es opcional. | +| 5 | **Receipt review sheet** ("We read this — is it right?"), fictional merchant "Corner Market" / "Abarrotes La Esquina", total, date. Reached via Photos import of a synthetic receipt (Simulator has no camera). Never the live viewfinder. | left leaf 100% | RECEIPTS · RECIBOS | Scan it.
It stays here. · Escanéalo.
Se queda aquí. | Total, merchant, date — read on your iPhone, never uploaded. · Total, comercio y fecha. Se leen en tu iPhone y nunca se suben. | +| 6 | **Money tab**, 7-month trend with a genuinely lower month (July > August), month total. | right leaf 45% | THE MONTH · EL MES | Bad months
count too. · Los meses malos
también cuentan. | Real trends, month over month. Drops included. · Tendencia real, mes a mes. Bajadas incluidas. | +| 7 | **GoalDetailView of a Behind goal** — clay status line "Behind · by Mar 1, 2027 · $25/wk", ETA pill "1+ year". (Goal rows only say "Behind"; the detail view proves the caption.) | full | THE PACE · EL RITMO | The date
is honest. · La fecha
no miente. | The ETA comes from real deposits. Behind is shown, not hidden. · La fecha sale de depósitos reales. Si vas atrasado, se ve. | +| 8 | **Home, dark mode** (`-darkModeEnabled YES`), same seeded state as frame 1. | full, dark | AT NIGHT · DE NOCHE | The notebook,
under a lamp. · La libreta,
de noche. | Dark mode keeps the warmth. Same calm. · El modo oscuro conserva la calidez. La misma calma. | + +Order rationale: all three researchers and the judge agree privacy is the stated +differentiator and belongs in the search trio; PPO treatment B tests 1 → 3 → 2 +(payday-first) since casual savers may respond more to the active benefit. + +**Fictional dataset (2.3.9-safe, one state for all frames; ×10 in MXN for es-MX):** +Goal A "Trip to Oaxaca / Viaje a Oaxaca" target $2,400, saved $1,010 (42%), $500 deposited +inside the last 4 weeks (GoalPace window) → $125/wk, deadline Dec 12 2026 → On track. +Goal B "Laptop" target $1,800, saved $320, $100 in the last 4 weeks → $25/wk, deadline +Mar 1 2027 → Behind, ETA 59 wk → "1+ year". Payday +$1,240, auto-move $150 to Goal A +(keep the ES auto-move under $1,000 until the banner formats thousands). Expense $7.50 +Coffee. Receipt "Corner Market" $23.80 dated the capture day. Income July > August so +the trend badge is genuinely negative. Fictional merchants only; no real names. + +--- + +## 4. Alternatives considered (kept for the record) + +- **Marginalia (the ruled notebook page).** Same page as above but with a static sprout + colophon and a "marginal clipping" (a 1.15× pixel crop of one UI row pinned over the + device corner) on task frames. Best token discipline; lost on distinctness — kicker + above serif on warm ground with a static mark is the Thirds/Monarch grammar, and the + clipping reads as a template callout at thumbnail size. Its page composition is what + we kept. +- **Honest Ledger.** Typography-led: headline + a 216px pull-figure lifted from the + screen ("$125 /wk", "0 accounts", "−12%") + a frameless full-bleed native crop of one + hero object; two dark frames (2 and 8). Its "ledger check" rule is what we kept; the + giant figures pull toward fintech "big number" energy (not Discreet), frameless UI on + cream is structurally Thirds, and date-sensitive figures make re-shoots brittle. +- Judge scores (fit / distinct / store / voice / producible): Marginalia 5·3·4·4·4 = 20, + Seed to Sprout 4·4·4·4·4 = 20, Honest Ledger 4·3·3·4·3 = 17 → tie-break on "its own + style" → Seed to Sprout on Marginalia's page. + +--- + +## 5. Prerequisites (do these before any capture) + +1. **Localization of the frames we show.** ~139 catalog entries are en-only, several on + these exact screens (Log expense/income, Recent, Expenses, Achievements, 7-month + trend, Scan a receipt), and these are hard-coded English literals: `On track` / + `Behind` / `1+ year` / `/wk` (GoalPace, GoalDetailView), `Morning/Afternoon/Evening` + (Dashboard greeting), the auto-move banner text, and date patterns + (`EEEE · MMMM d`, `MMM d, yyyy` render "mar 12, 2027" in es_MX). Also normalize the + ~23 old usted/exclamation strings to tú, and "Escanear Recibo" → "Escanear recibo". + Without this the es-MX set leaks English on frames 1, 3, 6, 7, 8. +2. **Truth gate for "No account. No cloud."** — `Savely/Config.plist` (OPENAI_API_KEY) is + in the app's Resources build phase even though tips are flagged off: remove it from + the target (CI already generates a placeholder — adjust) and rotate the key; drop the + `openai.com` ATS exception from Info.plist; delete the leftover + `GoogleService-Info.plist`; App Privacy label = "Data Not Collected". Do not write + "no network" in copy while OpenAIClient is compiled — "No account. No cloud." is as + far as the claim goes. +3. **iPad:** set `TARGETED_DEVICE_FAMILY = 1` (iPhone-only) or plan a 13" (2064×2752) + adaptation of the same page. +4. **Seed dataset behind a launch argument** (e.g. `-SavelyScreenshotSeed en|es`): wipes + and seeds SwiftData with §3's dataset, pins "today" for stable greeting/month/ETA, + marks Goal A favorite. Never Iván's real data (house rule + 2.3.9). Wipe after. +5. **Capture rig:** iPhone 17 Pro Max simulator (1320×2868 native); + `xcrun simctl status_bar booted override --time 9:41 --batteryState charged + --batteryLevel 100 --wifiBars 3 --cellularBars 4`; `-AppleLanguages (es-MX) + -AppleLocale es_MX` for the ES run; `-darkModeEnabled YES` for frame 8; + `SavelyUITests/StoreScreenshotTourUITests` (already in the repo) as the driver — + extend it to the exact frames and read `XCTAttachment`s out with + `xcrun xcresulttool export attachments`. Capture in the submission week so month + headers are true; never edit pixels inside a capture. + +--- + +## 6. Production pipeline (for the Opus session) + +1. **Composer:** one HTML template (`Design/ScreenshotKit/page.html`, 1320×2868) with + the tokens as CSS variables, `data-theme="light|dark"`, `copy.json` keyed by locale + and frame, `stage` 1–8 selecting the SproutMark SVG (traced 1:1 from + SproutMark.swift: stem `M60 87 C52 76 68 68 60 56`, seed r9 at (60,92), the two leaf + curves; stem stages via `stroke-dasharray`, leaf stages via scale about (60,56)). + Device = ink bezel div with `overflow:hidden` and the capture at ×0.72. +2. **Fonts:** render on macOS WebKit so `font-family: ui-serif` → New York and + `-apple-system` → SF Pro (nothing embedded, nothing licensed for web). Playwright: + `npx playwright install webkit`; `page.setViewportSize({width:1320,height:2868})`, + `deviceScaleFactor: 1`, `page.screenshot({type:'png'})`. Check New York's optical + size at 136px against a SwiftUI reference (`.font(.system(size: 45, design: .serif))` + at 3×); if WebKit picks a spindlier cut, use the alternative: a tiny SwiftUI + `ScreenshotComposer` target with `.warmFont`, `Color+Warm` and `SproutMark`, exported + with `ImageRenderer` at 1320×2868 — same result, zero web fonts. Stand-ins for + off-Mac previews only (Fraunces opsz 144 / Newsreader Regular + Inter) — never + sign off line breaks on them. +3. **Export:** 8 PNGs per locale, 1320×2868, sRGB, **no alpha** (`sips -g hasAlpha`; + flatten with `-background '#f6f4ee' -alpha remove` if needed), named + `savely-en-US-01-goal.png` … `savely-es-MX-08-night.png`. Upload only the 6.9" slot + for English (U.S.) and Spanish (Mexico). +4. **Checks before upload:** 25% zoom legibility in both languages; ES two-liners + ("Los meses malos / también cuentan.", "La libreta, / de noche.") hold at ≥128px; + hairlines visible in the ASC preview (go 4px, never darker); ledger check on every + frame; no real merchant/person data; no text cut by the bottom crop. +5. **Later (PPO):** treatment B = full-dark set (all 8 under the lamp; template flips + with `data-theme`); treatment C = payday-first order (1 → 3 → 2). Keep the header + grammar ready for the iOS 27 Creative Assets header. + +--- + +## 7. Open questions for Iván (non-blocking) + +- Keep iPad support (then an iPad set) or go iPhone-only for the App Store release? +- Frame 2: Profile → Data & privacy (real settings, safest under 2.3.3) vs the + onboarding "Yours, and only yours" page (prettier, has the sprout) — recommend + Profile in v1, onboarding as the PPO alternate. +- Any objection to the drawn warm-ink bezel (vs Apple's silver product bezel)? The ink + bezel keeps "warmth by type and tint, not chrome" and is the same on both themes. + +--- + +## 8. Produced (2026-08-18) + +`Design/ScreenshotKit/` renders the set end to end: + +- `Savely/Utilities/ScreenshotSeed.swift` (DEBUG, `-SavelyScreenshotSeed`) wipes the + Simulator store and seeds the fictional dataset of §3 — Trip to Oaxaca 42 % + "On track · by Dec 12, 2026 · $125/wk"; Laptop 17 % "Behind · by Mar 1, 2027 · + $25/wk" with ETA "1+ year"; a $1,240 paycheck offering a $150 auto-move; a + −22 % month-over-month drop in the 7-month trend; fictional merchants only. +- `SavelyUITests/StoreScreenshotTourUITests` drives the eight screens and attaches + the raw 1320×2868 captures; `collect.sh` renames them. +- `compose.swift` renders each page with SwiftUI `ImageRenderer` on macOS, so the + type is the real New York / SF Pro. Copy for all eight frames lives in that file. +- Output flattened to alpha-free sRGB in `build/upload/` (8 × 1320×2868 PNG, + 138–309 KB). Upload to the 6.9" slot only; Apple scales the rest. + +Deferred: the es-MX set (needs the ~139 en-only strings and the hard-coded +`On track`/`Behind`/`1+ year`/`/wk`/greeting/date-pattern literals localized +first), the full-dark PPO treatment, and the payday-first order test.