diff --git a/.gitignore b/.gitignore index 81ad03c5..790ea110 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,9 @@ wasm/target/ # Wandb **/wandb/ +# Screenshots +/screenshots/ + # Remotion remotion/node_modules/ remotion/out/ diff --git a/README.md b/README.md index d92cb7e1..b8e53d91 100644 --- a/README.md +++ b/README.md @@ -91,3 +91,53 @@ The project is configured for automatic WASM builds in CI/CD: - **Vercel**: Automatically builds WASM during deployment (see `vercel.json` and `vercel-build.sh`) For detailed deployment instructions, see [README-DEPLOYMENT.md](README-DEPLOYMENT.md). + +## Native Apps (iOS/Android) + +The project uses Capacitor for native mobile builds. + +### Building for iOS + +```bash +# Build static export +bun run build:native + +# Sync with native project +bun run cap:sync + +# Open in Xcode +bun run cap:ios +``` + +From Xcode: Product → Archive → Distribute App + +### Building for Android + +```bash +bun run build:native +bun run cap:sync +bun run cap:android +``` + +## App Store Screenshots + +Automated screenshot generation for App Store Connect using Playwright. + +```bash +# Start dev server +bun run dev + +# Generate screenshots (in another terminal) +bun run screenshots +``` + +Screenshots are saved to `screenshots/appstore/` organized by device: + +| Folder | Device | Resolution | +|--------|--------|------------| +| `iPhone-6.9/` | iPhone 16 Pro Max | 1320 x 2868 | +| `iPhone-6.5/` | iPhone 11 Pro Max | 1242 x 2688 | +| `iPhone-5.5/` | iPhone 8 Plus | 1242 x 2208 | +| `iPad-12.9/` | iPad Pro 12.9" | 2048 x 2732 | + +Screens captured: Home, Play, AI vs AI, Simulation diff --git a/bun.lock b/bun.lock index 7c056afb..bcd91b06 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ }, "devDependencies": { "@capacitor/cli": "^8.0.0", + "@playwright/test": "^1.57.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", @@ -33,6 +34,7 @@ "babel-plugin-react-compiler": "1.0.0", "oxfmt": "0.18.0", "oxlint": "1.33.0", + "playwright": "^1.57.0", "sharp": "^0.34.5", "tailwindcss": "^4", "typescript": "^5", @@ -200,6 +202,8 @@ "@oxlint/win32-x64": ["@oxlint/win32-x64@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ReyR8rNHjKNnO7dxGny9RCPELRAdhm3y780FNBcA07E1wvxSCkB+Mn5db0Pa5bRmxrsU/MTZ/aaBFa+ERXDdXw=="], + "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -390,6 +394,8 @@ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], @@ -482,6 +488,10 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], diff --git a/package.json b/package.json index 1730509d..e491c1e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "knuckletrainer", - "version": "0.1.0", + "version": "1.5.0", "private": true, "scripts": { "dev": "next dev", @@ -19,7 +19,8 @@ "generate-app-icons": "node scripts/generate-app-icons.mjs", "cap:sync": "bunx cap sync", "cap:ios": "bunx cap open ios", - "cap:android": "bunx cap open android" + "cap:android": "bunx cap open android", + "screenshots": "bun run scripts/appstore-screenshots.ts" }, "dependencies": { "@capacitor/android": "^8.0.0", @@ -43,6 +44,7 @@ }, "devDependencies": { "@capacitor/cli": "^8.0.0", + "@playwright/test": "^1.57.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", @@ -50,6 +52,7 @@ "babel-plugin-react-compiler": "1.0.0", "oxfmt": "0.18.0", "oxlint": "1.33.0", + "playwright": "^1.57.0", "sharp": "^0.34.5", "tailwindcss": "^4", "typescript": "^5", diff --git a/scripts/appstore-screenshots.ts b/scripts/appstore-screenshots.ts new file mode 100644 index 00000000..045e2164 --- /dev/null +++ b/scripts/appstore-screenshots.ts @@ -0,0 +1,117 @@ +/** + * App Store Screenshot Generator + * + * Captures screenshots at required iOS device sizes for App Store Connect. + * Run with: bun run screenshots + * + * Prerequisites: + * - Dev server running: bun run dev + * - Playwright installed: bun add -d playwright + */ + +import { chromium, type Page } from "playwright"; +import { mkdir } from "fs/promises"; +import { join } from "path"; + +// App Store required device sizes +const DEVICES = { + "iPhone-6.9": { + name: "iPhone 16 Pro Max", + width: 1320, + height: 2868, + scale: 3, + }, + "iPhone-6.5": { + name: "iPhone 11 Pro Max", + width: 1242, + height: 2688, + scale: 3, + }, + "iPhone-5.5": { + name: "iPhone 8 Plus", + width: 1242, + height: 2208, + scale: 3, + }, + "iPad-12.9": { + name: "iPad Pro 12.9", + width: 2048, + height: 2732, + scale: 2, + }, +} as const; + +// Screens to capture +const SCREENS = [ + { name: "01-home", path: "/" }, + { name: "02-play", path: "/play" }, + { name: "03-ai-vs-ai", path: "/ai-vs-ai" }, + { name: "04-simulation", path: "/simulation" }, +] as const; + +const BASE_URL = process.env.BASE_URL || "http://localhost:3000"; +const OUTPUT_DIR = join(process.cwd(), "screenshots", "appstore"); + +async function waitForApp(page: Page) { + await page.waitForLoadState("networkidle"); + // Extra time for animations to settle + await page.waitForTimeout(1000); +} + +async function takeScreenshots() { + console.log("šŸ“ø App Store Screenshot Generator\n"); + console.log(`Base URL: ${BASE_URL}`); + console.log(`Output: ${OUTPUT_DIR}\n`); + + // Create output directories + for (const deviceKey of Object.keys(DEVICES)) { + await mkdir(join(OUTPUT_DIR, deviceKey), { recursive: true }); + } + + const browser = await chromium.launch(); + + for (const [deviceKey, device] of Object.entries(DEVICES)) { + console.log(`\nšŸ“± ${device.name} (${device.width}x${device.height})`); + + const context = await browser.newContext({ + viewport: { + width: Math.round(device.width / device.scale), + height: Math.round(device.height / device.scale), + }, + deviceScaleFactor: device.scale, + isMobile: deviceKey.startsWith("iPhone"), + hasTouch: true, + }); + + const page = await context.newPage(); + + for (const screen of SCREENS) { + const url = `${BASE_URL}${screen.path}`; + console.log(` → ${screen.name}`); + + await page.goto(url); + await waitForApp(page); + + const filename = `${screen.name}.png`; + await page.screenshot({ + path: join(OUTPUT_DIR, deviceKey, filename), + fullPage: false, + }); + } + + await context.close(); + } + + await browser.close(); + + console.log("\nāœ… Screenshots saved to:", OUTPUT_DIR); + console.log("\nDevice folders:"); + for (const [key, device] of Object.entries(DEVICES)) { + console.log(` ${key}/ - ${device.name}`); + } +} + +takeScreenshots().catch((error) => { + console.error("Screenshot generation failed:", error); + process.exit(1); +});