diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f2cb69..548a917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- **iPhone Duo screenshots.** `apple.iphoneDuo: true` adds the foldable's outer (1398 x 2034) and inner (2007 x 2853) + sets, portrait or landscape, to `shots render`, `shots check` (exact sizes, no alpha) and the gallery. App Store + Connect API 4.5.1 has no Duo `ScreenshotDisplayType`, so `apple screenshots` skips them with a message and the deep + link to the version page's App Previews and Screenshots tab (one constant, `IPHONE_DUO_DISPLAY`, takes the enum + names once Apple adds them). `check` / `validate` note a missing Duo set: optional now, required from April 2027 for + iOS 27.1 SDK builds (a warning after that date, never a blocker), and point Play to the tablet sets (no foldable slot). +- **Required iPhone class follows the console.** `validate` now requires "iPhone with Dynamic Island (medium display)" + (`APP_IPHONE_61`) per locale, met by a 6.9" or 6.5" set that App Store Connect scales down (an ok row, not a + warning). Labels match the console: "iPhone with Dynamic Island (medium display)", "iPhone Duo", "iPad 13" display". + ## 0.8.12 · 2026-10-17 - **Shareable readiness report.** `storebound report` puts `scan`, `check` and `validate` into one self-contained HTML diff --git a/cli/apple/screenshots.ts b/cli/apple/screenshots.ts index 9565c4c..2e7df65 100644 --- a/cli/apple/screenshots.ts +++ b/cli/apple/screenshots.ts @@ -16,6 +16,8 @@ import { numberedImages } from '../play/images'; import { variantRoot } from '../shots/lib'; import { findOrCreate } from '../lib/upsert'; import { pulledChecksum } from '../lib/art-pull'; +import { consoleLink } from '../lib/console-links'; +import { IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API } from '../shots/spec'; import { type Asc, ascClient, keptPrefix, md5, setUpToDate, type J } from './asc'; /** Render size key -> ASC screenshotDisplayType (iPhone sets). */ @@ -28,12 +30,22 @@ export const MEDIUM_DISPLAY: Record = { 'iphone-medium': 'APP_IP /** Display sets this project uploads: the iPhone sets, plus the 6.3" set with apple.iphoneMedium and the 13" iPad set * with apple.ipad. */ -export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean } | undefined): Record => - ({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}) }); +export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean; iphoneDuo?: boolean } | undefined): Record => + ({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}), + ...(apple?.iphoneDuo ? Object.fromEntries(Object.entries(IPHONE_DUO_DISPLAY).filter((e): e is [string, string] => !!e[1])) : {}) }); -/** Display types `validate` requires on every locale: apple.screenshotTypes, else 6.9" iPhone (+ 13" iPad with ipad). */ +/** iPhone Duo size keys `apple screenshots` skips (apple.iphoneDuo on, no API display type yet), with the message. */ +export function skippedDuoSets(apple: { iphoneDuo?: boolean; appId?: string } | undefined): { dirs: string[]; msg: string } | null { + const dirs = apple?.iphoneDuo ? Object.entries(IPHONE_DUO_DISPLAY).filter(([, t]) => !t).map(([k]) => k) : []; + // the console takes them (its own "iPhone Duo" tab): send the owner straight to the version page + return dirs.length ? { dirs, msg: `${dirs.join(', ')}: ${IPHONE_DUO_NOT_IN_API}. Add them in ${consoleLink('asc.version', '> App Previews and Screenshots > iPhone Duo', { appleAppId: apple?.appId })}` } : null; +} + +/** Display types `validate` requires on every locale: apple.screenshotTypes, else the classes App Store Connect's + * "App Previews and Screenshots" tab lists (October 2026): iPhone with Dynamic Island (medium display), filled by + * scaling a 6.9" or 6.5" set down (ACCEPTED_INSTEAD), plus iPad 13" display with ipad. iPhone Duo is optional. */ export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: string[] } | undefined): string[] => - apple?.screenshotTypes ?? ['APP_IPHONE_67', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])]; + apple?.screenshotTypes ?? ['APP_IPHONE_61', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])]; /** Makes one screenshot set (of a version localization, a custom product page localization or a Product Page * Optimization treatment localization) hold exactly `files`, in order: left alone when the MD5s match, else the @@ -73,6 +85,8 @@ export async function run(ctx: Ctx): Promise { const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const version = await c.editableVersion(); if (!version) throw new Error('no editable App Store version (run apple listing --version x.y.z first)'); + const duoSkip = skippedDuoSets(config.apple); + if (duoSkip) warn('apple', duoSkip.msg); const langOf = byAppleLocale(config.languages); const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`); diff --git a/cli/art.ts b/cli/art.ts index f1d7eac..87dc882 100644 --- a/cli/art.ts +++ b/cli/art.ts @@ -408,7 +408,7 @@ type LiveSet = LiveArtSet; type LiveResult = LiveArtResult; const LABELS: Record = { - APP_IPHONE_67: 'App Store iPhone 6.9"', APP_IPHONE_65: 'App Store iPhone 6.5"', APP_IPHONE_61: 'App Store iPhone 6.3"', APP_IPHONE_55: 'App Store iPhone 5.5"', APP_IPAD_PRO_3GEN_129: 'App Store iPad 13"', APP_IPAD_PRO_129: 'App Store iPad 12.9" (2nd gen)', + APP_IPHONE_67: 'App Store iPhone 6.9"', APP_IPHONE_65: 'App Store iPhone 6.5"', APP_IPHONE_61: 'iPhone with Dynamic Island (medium display)', APP_IPHONE_55: 'App Store iPhone 5.5"', APP_IPAD_PRO_3GEN_129: 'iPad 13" display', APP_IPAD_PRO_129: 'App Store iPad 12.9" (2nd gen)', IPHONE_67: 'App Store iPhone app preview', IPAD_PRO_3GEN_129: 'App Store iPad app preview', featureGraphic: 'Play feature graphic', phoneScreenshots: 'Play phone', sevenInchScreenshots: 'Play 7" tablet', tenInchScreenshots: 'Play 10" tablet', tvScreenshots: 'Play TV', wearScreenshots: 'Play Wear OS', }; diff --git a/cli/check.ts b/cli/check.ts index 3f1e5d4..3aa9f70 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -30,6 +30,7 @@ import { allSubscriptions } from './lib/subscriptions'; import { type CopyFile, validateCopy } from './shots/lib'; import { posix, readPulled } from './lib/art-pull'; import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets'; +import { iphoneDuoNote } from './shots/spec'; /** The newest .ipa in a folder (apple build's export), or null. */ function newestIpa(dir: string): string | null { @@ -146,6 +147,8 @@ export async function run(ctx: Ctx): Promise { try { for (const n of (await runScan(ctx.loaded.root, { config: ctx.loaded.path })).report.notes) if (n.startsWith('scan.ignore[')) note(n); } catch (e) { note(`scan.ignore not checked: ${(e as Error).message}`); } } if (config.apple && !config.app.copyright?.trim()) problems.push(COPYRIGHT_MISSING); + const duo = iphoneDuoNote({ apple: !!config.apple, duo: !!config.apple?.iphoneDuo, play: !!config.play }); + if (duo?.level === 'info') { hints.push(duo.msg); log('check', `hint: ${duo.msg}`); } else if (duo) note(duo.msg); // the scaffolder's own placeholders would reach the stores as the app's copyright, contact and legal pages const tph = templatePlaceholders(config as never); if (tph.length) problems.push(`template placeholders still in the config: ${tph.join(', ')} ("Example Studio", example.com): the stores would show them as your copyright, contact address and legal pages; put the real values in`); diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 5c5536f..f64bb93 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -622,6 +622,10 @@ export interface ShipConfig { * pixel-exact art on 6.3" phones, or if Apple stops scaling. (Screenshot specifications, App Store Connect Help.) */ iphoneMedium?: boolean; + /** Also render and check iPhone Duo (foldable) screenshots: outer 1398 x 2034 and inner 2007 x 2853 (landscape too). + * Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. Not uploaded while the API has no + * display type for them (cli/shots/spec.ts IPHONE_DUO_DISPLAY). */ + iphoneDuo?: boolean; /** TestFlight beta groups, the tester CSV (in .storebound/) and Test Information (`storebound apple testflight`). */ testflight?: TfConfig; /** Certificates, profiles and capabilities through the API (`storebound apple signing`), signing style of `apple build`. */ diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index 77d5b79..ddf1d2f 100644 --- a/cli/lib/embedded.ts +++ b/cli/lib/embedded.ts @@ -26,7 +26,7 @@ export const TEMPLATE_TEXT: Record = { "profiles/subscription-app/listing.md": "# Example App · Google Play listing copy\n\nSource for `storebound play listing` (title, short + full description) and `storebound play release` (release notes).\nRules the parser follows:\n- every `## ...` section with a `` line is a listing; one section can serve several Play codes\n- under it, `### ...` headings that contain **title** (optional, defaults to `app.name`), **short**, **full** and\n **what's new**, each followed by one fenced block\n- limits (checked before anything is sent): title 30, short 80, full 4000, what's new 500 characters\nOnly claim what the app really does. No prices, no \"best / #1\", no other apps' names.\n\n---\n\n## English (en-US)\n\n\n### Play short description (max 80)\n```\nWhat the app does for people, in one plain line\n```\n\n### Play full description (max 4000)\n```\nFirst paragraph: the job the app does and why it is easier with it, in two or three sentences.\n\nFEATURES\n• Lists and notes that sync across your devices\n• Share a list with family or friends\n• Export your data or delete your account in Settings, any time\n\nFree to use with up to 3 lists. Example Pro unlocks unlimited lists, sharing and themes: yearly with a 1-week free trial, or monthly. Payment is charged to your store account; the subscription renews automatically unless cancelled at least 24 hours before the end of the period, and you can manage or cancel it in your account settings.\n\nTerms of Use: https://example.com/terms\nPrivacy Policy: https://example.com/privacy\n```\n\n### What's new (max 500)\n```\nFirst release. Thanks for trying it!\n```\n\n## Türkçe (tr-TR)\n\n\n### Play kısa açıklama · short (max 80)\n```\nUygulamanın insanlar için ne yaptığını anlatan sade bir cümle\n```\n\n### Play tam açıklama · full (max 4000)\n```\nİlk paragraf: uygulamanın gördüğü iş ve onunla neden daha kolay olduğu, iki üç cümlede.\n\nÖZELLİKLER\n• Tüm cihazlarında eşitlenen listeler ve notlar\n• Bir listeyi ailen ya da arkadaşlarınla paylaş\n• Verilerini dışa aktar ya da hesabını Ayarlar'dan istediğin an sil\n\n3 listeye kadar ücretsiz. Example Pro sınırsız liste, paylaşım ve tema açar: 1 hafta ücretsiz denemeli yıllık ya da aylık. Ödeme mağaza hesabınızdan alınır; abonelik, dönem bitmeden en az 24 saat önce iptal edilmezse otomatik yenilenir; hesap ayarlarınızdan yönetebilir ya da iptal edebilirsiniz.\n\nKullanım Koşulları: https://example.com/terms\nGizlilik Politikası: https://example.com/privacy\n```\n\n### Yenilikler · what's new (max 500)\n```\nİlk sürüm. Denediğin için teşekkürler!\n```\n", "profiles/subscription-app/storebound.config.ts": "// storebound config for one app (profile \"subscription-app\": accounts plus auto-renewable subscriptions, an\n// analytics SDK, no ads). Every answer below is a starting point: edit it to match your app. Credentials NEVER go\n// here (env vars or .storebound/credentials.json, gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d storebound).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block\n // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is\n // already live: storebound onboard --declarations), answer the rest, then remove the area from this list.\n unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'],\n\n app: {\n name: 'Example App',\n profile: 'subscription-app',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n termsUrl: 'https://example.com/terms', // Terms of Use (EULA): in the app and the metadata (Apple 3.1.2)\n supportUrl: 'https://example.com/support',\n marketingUrl: 'https://example.com',\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // Accounts (account-deletion skill). Remove this block if the app has no sign-up / sign-in at all.\n // Apple 5.1.1(v): deletion must start inside the app. Google Play: in the app AND on a public web page.\n accounts: {\n enabled: true,\n deletionUrl: 'https://example.com/delete-account', // public page, no login needed to request deletion\n inApp: 'Settings > Account > Delete account', // goes into the App Review notes\n // How people sign up: 'username' (a name picked in the app, no password, no email), 'username-password',\n // 'email-password', 'email-link', 'phone', 'oauth', 'other'. `storebound scan` proposes the account data and\n // dataSafety.accountCreation from it; only the 'email-*' methods mean an email address.\n signUp: ['email-password'],\n },\n\n // Sign-in for store reviewers (review-access skill). The demo account itself lives ONLY in\n // .storebound/credentials.json (\"reviewDemoAccount\") or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD.\n reviewAccess: {\n signInRequired: true, // false if every feature works without signing in\n instructions: 'Sign in with the demo account. Sample data is already there; nothing needs a second device.',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n ],\n\n play: {\n packageName: 'com.example.app',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n },\n\n apple: {\n bundleId: 'com.example.app',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n listing: 'store/appstore.md',\n // Export compliance (export-compliance skill): decide it, then uncomment. false ONLY if the app uses no encryption\n // beyond the OS (HTTPS through the system, standard sign-in).\n // usesNonExemptEncryption: false,\n // ipad: true,\n info: {\n categories: { primary: 'PRODUCTIVITY', secondary: 'UTILITIES' },\n ageRating: {}, // every content question NONE / false; change what applies (user-generated content, web access ...)\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n 'Example App keeps lists and notes in sync across devices. Pro (yearly with a 1-week free trial, or monthly) unlocks unlimited lists, sharing and themes.',\n 'Subscriptions: Settings > Pro (or the paywall after the third list). Restore Purchases is on the paywall and in Settings > Pro.',\n 'Privacy policy and Terms of Use: on the paywall and in Settings > About.',\n // With a remote paywall or feature flags (storebound scan warns, 2.3.1) you may add a line like:\n // 'Paywall designs and the prices shown are tested remotely with ; what Pro unlocks does not change after review.',\n // Google Play has no review-notes field: put the same line in reviewAccess.instructions (Play App content >\n // App access, 'Any other information') when the app has a sign-in, and say in the full description what needs\n // a paid subscription (Play: Deceptive behavior, Payments).\n ].join('\\n\\n'),\n },\n },\n\n // Auto-renewable subscriptions (store-subscriptions skill): `storebound apple subscriptions`, `storebound play\n // subscriptions`. Apple: one group, levels rank the plans (1 = highest). Play: one subscription per entry with one\n // auto-renewing base plan. Prices: USD base, Apple equalizes from the USA, Play converts per region; overrides set a\n // region by hand. The disclosure (price per period, renewal, how to cancel, Terms + Privacy links) belongs on the\n // purchase screen and in the descriptions.\n subscriptions: [\n {\n name: 'Example Pro',\n listings: { en: { name: 'Example Pro' }, tr: { name: 'Example Pro' } },\n subscriptions: [\n {\n id: 'com.example.app.pro_yearly', period: 'P1Y', level: 1, usd: 29.99,\n listings: {\n en: { title: 'Pro yearly', description: 'Unlimited lists, sharing and themes.', benefits: ['Unlimited lists', 'Shared lists', 'All themes'] },\n tr: { title: 'Pro yıllık', description: 'Sınırsız liste, paylaşım ve tema.', benefits: ['Sınırsız liste', 'Paylaşılan listeler', 'Tüm temalar'] },\n },\n intro: { type: 'free', duration: 'P1W' }, // 1 week free trial for new subscribers\n reviewScreenshot: 'store/iap-review/paywall.png',\n },\n {\n id: 'com.example.app.pro_monthly', period: 'P1M', level: 2, usd: 3.99,\n overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }],\n listings: {\n en: { title: 'Pro monthly', description: 'Unlimited lists, sharing and themes.', benefits: ['Unlimited lists', 'Shared lists', 'All themes'] },\n tr: { title: 'Pro aylık', description: 'Sınırsız liste, paylaşım ve tema.', benefits: ['Sınırsız liste', 'Paylaşılan listeler', 'Tüm temalar'] },\n },\n reviewScreenshot: 'store/iap-review/paywall.png',\n },\n ],\n },\n ],\n\n // Google Play Data safety. Types not listed = not collected. Typical app with accounts + an analytics SDK acting as\n // your processor (collected, not shared). Edit to match what your app and its SDKs really do.\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // Sign-up methods. Google does not publish these ids: compare with Play Console > App content > Data safety >\n // Export to CSV before the first import. The delete-account URL (PSL_ACCOUNT_DELETION_URL) comes from\n // accounts.deletionUrl; `storebound check` names any other row Play requires.\n accountCreation: ['PSL_ACM_USER_ID_PASSWORD'],\n // Can people delete their data without deleting the account (a data-only page)? true needs partialDeletionUrl.\n // Play's \"can users request deletion?\" is answered yes by accounts.deletionUrl. Ask the owner.\n partialDeletion: false,\n data: {\n PSL_EMAIL: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_ACCOUNT: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_GENERATED_CONTENT: { shared: false, collect: ['functionality'] },\n PSL_USER_INTERACTION: { shared: false, collect: ['analytics'] },\n PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: false, collect: ['analytics'] },\n PSL_DEVICE_ID: { shared: false, collect: ['analytics'] },\n PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality'] }, // subscription status tied to the account\n },\n },\n\n renders: 'store/renders',\n\n shots: {\n style: 'app', // calm gradient, device frame, headline + subline (game: sparkles and badges)\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n // fonts: { title: 'Inter', body: 'Inter' }, // the app style default\n scenes: [\n { id: 'home', palette: 'mist', frames: [{ src: 'home.png' }] },\n { id: 'detail', palette: 'mint', frames: [{ src: 'detail.png' }] },\n { id: 'share', palette: 'sky', frames: [{ src: 'share.png' }] },\n { id: 'privacy', palette: 'slate', frames: [{ src: 'settings.png' }] },\n ],\n feature: { frame: 'home.png', palette: 'mist' },\n // Raw frames from a web build, the iOS Simulator (ios-sim) or an Android emulator (android-emu): sign in to a demo\n // account with realistic sample data, never real user data (store-screenshots skill).\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&demo=1',\n shots: {\n 'home': { path: '/', delayMs: 1000, device: 'both' },\n 'detail': { path: '/lists/1', delayMs: 1000, device: 'both' },\n 'share': { path: '/lists/1/share', delayMs: 1000, device: 'both' },\n 'settings': { path: '/settings', delayMs: 1000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png',\n foreground: 'store/icon/foreground.png',\n background: 'store/icon/background.png',\n out: 'store/icons',\n },\n};\n\nexport default config;\n", "shots-copy.json": "{\n \"en\": {\n \"hero\": { \"title\": [\"Tap. Match.\", \"Repeat!\"], \"badges\": [[\"100+ levels\", \"and more every update\"], [\"Play offline\", \"No account needed\"]] },\n \"play\": { \"eyebrow\": \"Play\", \"title\": [\"Easy to learn,\", \"hard to put down\"], \"sub\": \"One thumb, short rounds, big satisfying moments.\", \"badges\": [[\"Quick rounds\", \"Two minutes each\"], [\"Great!\"]] },\n \"levels\": { \"eyebrow\": \"Worlds\", \"title\": [\"Explore new\", \"worlds\"], \"sub\": \"A fresh look and new rules every ten levels.\", \"badges\": [[\"10 worlds\", \"A new one every 10 levels\"]] },\n \"stars\": { \"eyebrow\": \"Stars\", \"title\": [\"Play smart,\", \"grab 3 stars\"], \"sub\": \"Finish with moves to spare and collect every star.\", \"badges\": [[\"3 stars\"]] }\n },\n \"tr\": {\n \"hero\": { \"title\": [\"Dokun. Eşleştir.\", \"Tekrarla!\"], \"badges\": [[\"100+ bölüm\", \"her güncellemede yenileri\"], [\"İnternetsiz\", \"hesap gerekmez\"]] },\n \"play\": { \"eyebrow\": \"Oyna\", \"title\": [\"Öğrenmesi kolay,\", \"bırakması zor\"], \"sub\": \"Tek parmak, kısa turlar, büyük keyifli anlar.\", \"badges\": [[\"Kısa turlar\", \"Her biri iki dakika\"], [\"Harika!\"]] },\n \"levels\": { \"eyebrow\": \"Dünyalar\", \"title\": [\"Yeni dünyaları\", \"keşfet\"], \"sub\": \"Her on bölümde yeni bir görünüm ve yeni kurallar.\", \"badges\": [[\"10 dünya\", \"Her 10 bölümde yeni biri\"]] },\n \"stars\": { \"eyebrow\": \"Yıldızlar\", \"title\": [\"Akıllı oyna,\", \"3 yıldızı kap\"], \"sub\": \"Hamle artırarak bitir, tüm yıldızları topla.\", \"badges\": [[\"3 yıldız\"]] }\n },\n \"es\": {\n \"hero\": { \"title\": [\"Toca. Combina.\", \"¡Repite!\"], \"badges\": [[\"100+ niveles\", \"y más en cada versión\"], [\"Sin conexión\", \"Sin cuenta\"]] },\n \"play\": { \"eyebrow\": \"Juega\", \"title\": [\"Fácil de aprender,\", \"difícil de soltar\"], \"sub\": \"Un pulgar, rondas cortas, grandes momentos.\", \"badges\": [[\"Rondas cortas\", \"Dos minutos cada una\"], [\"¡Genial!\"]] },\n \"levels\": { \"eyebrow\": \"Mundos\", \"title\": [\"Explora\", \"nuevos mundos\"], \"sub\": \"Un aspecto nuevo y nuevas reglas cada diez niveles.\", \"badges\": [[\"10 mundos\", \"Uno nuevo cada 10 niveles\"]] },\n \"stars\": { \"eyebrow\": \"Estrellas\", \"title\": [\"Juega con cabeza,\", \"logra 3 estrellas\"], \"sub\": \"Termina con movimientos de sobra y consigue todas las estrellas.\", \"badges\": [[\"3 estrellas\"]] }\n },\n \"ja\": {\n \"hero\": { \"title\": [\"タップして、そろえて、\", \"もう一回!\"], \"badges\": [[\"100以上のステージ\", \"アップデートで追加\"], [\"オフラインで遊べる\", \"アカウント不要\"]] },\n \"play\": { \"eyebrow\": \"プレイ\", \"title\": [\"かんたんなのに\", \"やめられない\"], \"sub\": \"片手で遊べる短いラウンド、気持ちいい瞬間がたくさん。\", \"badges\": [[\"短いラウンド\", \"1回2分\"], [\"すごい!\"]] },\n \"levels\": { \"eyebrow\": \"ワールド\", \"title\": [\"新しいワールドを\", \"冒険しよう\"], \"sub\": \"10ステージごとに新しい見た目と新しいルール。\", \"badges\": [[\"10のワールド\", \"10ステージごとに追加\"]] },\n \"stars\": { \"eyebrow\": \"スター\", \"title\": [\"かしこく遊んで\", \"星を3つゲット\"], \"sub\": \"手数を残してクリアし、すべての星を集めよう。\", \"badges\": [[\"星3つ\"]] }\n },\n \"ar\": {\n \"hero\": { \"title\": [\"انقر. طابق.\", \"كرّر!\"], \"badges\": [[\"أكثر من 100 مرحلة\", \"والمزيد مع كل تحديث\"], [\"العب دون اتصال\", \"دون حساب\"]] },\n \"play\": { \"eyebrow\": \"العب\", \"title\": [\"سهلة التعلّم،\", \"صعبة التوقف\"], \"sub\": \"إبهام واحد، جولات قصيرة، ولحظات ممتعة.\", \"badges\": [[\"جولات قصيرة\", \"دقيقتان لكل جولة\"], [\"رائع!\"]] },\n \"levels\": { \"eyebrow\": \"العوالم\", \"title\": [\"استكشف\", \"عوالم جديدة\"], \"sub\": \"شكل جديد وقواعد جديدة كل عشر مراحل.\", \"badges\": [[\"10 عوالم\", \"عالم جديد كل 10 مراحل\"]] },\n \"stars\": { \"eyebrow\": \"النجوم\", \"title\": [\"العب بذكاء،\", \"واجمع 3 نجوم\"], \"sub\": \"أنهِ المرحلة بحركات متبقية واجمع كل النجوم.\", \"badges\": [[\"3 نجوم\"]] }\n }\n}\n", - "storebound.config.ts": "// storebound config for one game. Everything game-specific lives here; credentials NEVER do (they go in env vars or\n// .storebound/credentials.json, which must stay gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d ).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block\n // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is\n // already live: storebound onboard --declarations), answer the rest, then remove the area from this list.\n unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating'],\n\n app: {\n name: 'Example Game',\n profile: 'game',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n supportUrl: 'https://example.com',\n marketingUrl: 'https://example.com',\n // Developer website on Play: the same domain must serve /app-ads.txt if the game shows ads\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n { id: 'es', play: ['es-ES', 'es-419'], apple: 'es-ES' },\n { id: 'ja', play: 'ja-JP', apple: 'ja' },\n { id: 'ar', play: 'ar', apple: 'ar-SA', rtl: true },\n ],\n\n play: {\n packageName: 'com.example.game',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n // read by `storebound scan` (fill in what is true for your account and app):\n // accountType: 'personal', accountCreated: 'YYYY-MM-DD', // new personal accounts: 12 testers x 14 days closed test\n // targetAges: ['13-15', '16-17', '18+'], // App content > Target audience; under 13 = Families policy\n },\n\n apple: {\n bundleId: 'com.example.game',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n // appId: '1234567890', // Apple ID of the app record; looked up by bundle id when left out\n listing: 'store/appstore.md',\n reviewShots: 'store/iap-review',\n // Export compliance (export-compliance skill). false ONLY if the game uses no encryption beyond the OS (HTTPS through\n // the system). Left unset on purpose: decide it, then uncomment. Same answer as ITSAppUsesNonExemptEncryption.\n // usesNonExemptEncryption: false,\n // ipad: true, // the app runs on iPad: 13\" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded\n // iphoneMedium: true, // optional 6.3\" iPhone set (1206 x 2622, iphone-medium); off: Apple scales the 6.5\" set down\n // screenshotTypes: ['APP_IPHONE_67'], // what `storebound validate` requires per locale (default; + APP_IPAD_PRO_3GEN_129 with ipad)\n // TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only.\n testflight: {\n groups: [\n { name: 'Team', internal: true, allBuilds: true },\n { name: 'Friends' },\n ],\n description: { en: 'Example Game beta: try the new levels and tell us what feels too hard.' },\n },\n info: {\n categories: { primary: 'GAMES', primarySubOne: 'GAMES_CASUAL', primarySubTwo: 'GAMES_PUZZLE', secondary: 'ENTERTAINMENT' },\n ageRating: { advertising: true }, // everything else NONE / false\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n 'Single-player game. No account or login is needed; there is no user-generated content or chat.',\n 'In-app purchases: Home > Shop. Restore Purchases is in Settings.',\n 'Ads: optional rewarded videos and interstitials between rounds. The App Tracking Transparency prompt is shown before the ad SDK starts.',\n 'Privacy policy: Settings > Privacy.',\n // With remote config, feature flags or a remotely changed paywall (storebound scan warns, 2.3.1) you may add:\n // 'Shop offers and prices shown come from and change remotely; no feature is switched on or off after review.',\n // Google Play has no review-notes field: put the same line in reviewAccess.instructions (Play App content >\n // App access, 'Any other information') when the app has a sign-in, and say in the full description what needs\n // a paid subscription (Play: Deceptive behavior, Payments).\n ].join('\\n\\n'),\n },\n },\n\n // Price changes later for many products / regions: storebound prices export, edit store/prices.csv, prices import --dry.\n // prices: { csv: 'store/prices.csv', baseTerritory: 'USA' },\n\n // Same ids on both stores. Prices: USD base; Play converts per region, Apple equalizes from the USA. Overrides set a\n // region by hand (Apple needs an existing price point with exactly that value).\n products: [\n {\n id: 'com.example.game.remove_ads', type: 'non_consumable', usd: 2.99,\n overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }],\n listings: {\n en: { title: 'Remove Ads', description: 'Removes interstitial ads for good.' },\n tr: { title: 'Reklamları Kaldır', description: 'Ara reklamları kalıcı olarak kaldırır.' },\n },\n reviewScreenshot: 'store/iap-review/shop.png',\n },\n {\n id: 'com.example.game.coins_small', type: 'consumable', usd: 0.99,\n listings: {\n en: { title: '500 Coins', description: 'A small bag of coins.' },\n tr: { title: '500 Altın', description: 'Küçük bir kese altın.' },\n },\n reviewScreenshot: 'store/iap-review/shop.png',\n },\n ],\n\n // Google Play Data safety. Types not listed = not collected. Purposes: functionality, analytics, communications,\n // fraud, advertising, personalization, account. Typical F2P game with an analytics SDK + ad mediation:\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // No accounts: PSL_ACM_NONE. A guest account with a name picked on the device and a server token (no password, no\n // email): accounts { enabled: true, signUp: ['username'], ... } and ['PSL_ACM_USER_ID_OTHER_AUTH'] here (Play's\n // \"Username and other authentication\"; `storebound scan` proposes it). With accounts Play also requires the\n // delete-account URL (from accounts.deletionUrl). That page also answers \"can users request that their data is\n // deleted?\" with yes (dataDeletion: 'yes' | 'no' | 'auto90' only for apps without one). partialDeletion: true +\n // partialDeletionUrl when a page deletes data without deleting the account. `storebound check` compares every row with Play's\n // current form (cli/data/play-data-safety-form.csv).\n accountCreation: ['PSL_ACM_NONE'],\n data: {\n PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_USER_INTERACTION: { shared: true, collect: ['analytics', 'advertising'], share: ['analytics', 'advertising'] },\n PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['functionality'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_DEVICE_ID: { shared: true, collect: ['functionality', 'analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality', 'fraud'] },\n },\n },\n\n // Ads: `storebound validate --play` checks /app-ads.txt for these lines (copy them from your mediation\n // dashboard's app-ads.txt page). Placeholder line below; replace it.\n // ads: { appAdsTxt: ['example-network.com, pub-0000000000000000, DIRECT, 0000000000000000'] },\n\n renders: 'store/renders',\n\n shots: {\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n logo: 'store/shots/logo.png', // optional: hero scene + feature graphic\n // Google Fonts; both cover Latin Extended (tr, pl, cs, ro ...) and Vietnamese. `shots fonts` checks every glyph of the\n // copy; a character the font lacks needs a font that has it or fallback: ['Noto Sans Display'] (else shots check fails).\n fonts: { title: 'Paytone One', body: 'Montserrat' },\n // phone: { w: 440, h: 902, dpr: 3 }, // raw frame size in CSS px (screen below a 54 px status bar)\n // tablet: { w: 1032, h: 1352, dpr: 2 }, // default with apple.ipad (13\" iPad below its 24 px bar), else 800 x 1240\n scenes: [\n { id: 'hero', palette: 'fire', hero: true, over: 34, frames: [{ src: 'hero.png', rotate: -4 }],\n badges: [{ y: 0.42, side: 'l', icon: 'flag', iconBg: 'red', rot: -4 }, { y: 0.66, side: 'r', icon: 'offline', iconBg: 'red', rot: 3 }] },\n { id: 'play', palette: 'violet', frames: [{ src: 'gameplay.png', rotate: 3 }],\n badges: [{ y: 0.3, side: 'l', icon: 'bolt', iconBg: 'gold', rot: -3 }, { y: 0.7, side: 'r', hot: true, rot: 4 }] },\n { id: 'levels', palette: 'sky', frames: [{ src: 'level-b.png', scale: 0.82 }, { src: 'level-a.png', x: -31, dy: 10, scale: 0.7, rotate: -8 }, { src: 'level-c.png', x: 31, dy: 10, scale: 0.7, rotate: 8 }],\n badges: [{ y: 0.66, side: 'r', icon: 'map', iconBg: 'blue', rot: 3 }] },\n { id: 'stars', palette: 'gold', frames: [{ src: 'results.png', rotate: -3 }],\n badges: [{ y: 0.42, side: 'l', stars: true, rot: -3 }] },\n ],\n feature: { frame: 'hero.png', palette: 'fire' },\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&noads=1&seed=1',\n shots: {\n 'hero': { path: '/?level=12', waitFor: \"window.game && game.state === 'playing'\", delayMs: 1500, device: 'both' },\n 'gameplay': { path: '/?level=3', delayMs: 2500, device: 'both' },\n 'level-a': { path: '/?level=20', delayMs: 2000 },\n 'level-b': { path: '/?level=40', delayMs: 2000 },\n 'level-c': { path: '/?level=60', delayMs: 2000 },\n 'results': { path: '/?level=5&result=3', delayMs: 2000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png', // 1024 opaque\n foreground: 'store/icon/foreground.png', // 1024 RGBA, subject on transparent\n background: 'store/icon/background.png', // 1024 opaque\n out: 'store/icons',\n web: 'public/icons',\n // capacitorAssets: 'native/assets',\n // capacitorCommand: 'bunx @capacitor/assets generate --assetPath native/assets',\n // androidRes: 'native/android/app/src/main/res',\n },\n};\n\nexport default config;\n", + "storebound.config.ts": "// storebound config for one game. Everything game-specific lives here; credentials NEVER do (they go in env vars or\n// .storebound/credentials.json, which must stay gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d ).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block\n // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is\n // already live: storebound onboard --declarations), answer the rest, then remove the area from this list.\n unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating'],\n\n app: {\n name: 'Example Game',\n profile: 'game',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n supportUrl: 'https://example.com',\n marketingUrl: 'https://example.com',\n // Developer website on Play: the same domain must serve /app-ads.txt if the game shows ads\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n { id: 'es', play: ['es-ES', 'es-419'], apple: 'es-ES' },\n { id: 'ja', play: 'ja-JP', apple: 'ja' },\n { id: 'ar', play: 'ar', apple: 'ar-SA', rtl: true },\n ],\n\n play: {\n packageName: 'com.example.game',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n // read by `storebound scan` (fill in what is true for your account and app):\n // accountType: 'personal', accountCreated: 'YYYY-MM-DD', // new personal accounts: 12 testers x 14 days closed test\n // targetAges: ['13-15', '16-17', '18+'], // App content > Target audience; under 13 = Families policy\n },\n\n apple: {\n bundleId: 'com.example.game',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n // appId: '1234567890', // Apple ID of the app record; looked up by bundle id when left out\n listing: 'store/appstore.md',\n reviewShots: 'store/iap-review',\n // Export compliance (export-compliance skill). false ONLY if the game uses no encryption beyond the OS (HTTPS through\n // the system). Left unset on purpose: decide it, then uncomment. Same answer as ITSAppUsesNonExemptEncryption.\n // usesNonExemptEncryption: false,\n // ipad: true, // the app runs on iPad: 13\" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded\n // iphoneMedium: true, // optional 6.3\" iPhone set (1206 x 2622, iphone-medium); off: Apple scales the 6.5\" set down\n // iphoneDuo: true, // optional iPhone Duo (foldable) sets, outer 1398 x 2034 + inner 2007 x 2853; rendered and checked, not uploaded until the API takes them\n // screenshotTypes: ['APP_IPHONE_61'], // what `storebound validate` requires per locale (default: medium display, met by a 6.9\" or 6.5\" set; + APP_IPAD_PRO_3GEN_129 with ipad)\n // TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only.\n testflight: {\n groups: [\n { name: 'Team', internal: true, allBuilds: true },\n { name: 'Friends' },\n ],\n description: { en: 'Example Game beta: try the new levels and tell us what feels too hard.' },\n },\n info: {\n categories: { primary: 'GAMES', primarySubOne: 'GAMES_CASUAL', primarySubTwo: 'GAMES_PUZZLE', secondary: 'ENTERTAINMENT' },\n ageRating: { advertising: true }, // everything else NONE / false\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n 'Single-player game. No account or login is needed; there is no user-generated content or chat.',\n 'In-app purchases: Home > Shop. Restore Purchases is in Settings.',\n 'Ads: optional rewarded videos and interstitials between rounds. The App Tracking Transparency prompt is shown before the ad SDK starts.',\n 'Privacy policy: Settings > Privacy.',\n // With remote config, feature flags or a remotely changed paywall (storebound scan warns, 2.3.1) you may add:\n // 'Shop offers and prices shown come from and change remotely; no feature is switched on or off after review.',\n // Google Play has no review-notes field: put the same line in reviewAccess.instructions (Play App content >\n // App access, 'Any other information') when the app has a sign-in, and say in the full description what needs\n // a paid subscription (Play: Deceptive behavior, Payments).\n ].join('\\n\\n'),\n },\n },\n\n // Price changes later for many products / regions: storebound prices export, edit store/prices.csv, prices import --dry.\n // prices: { csv: 'store/prices.csv', baseTerritory: 'USA' },\n\n // Same ids on both stores. Prices: USD base; Play converts per region, Apple equalizes from the USA. Overrides set a\n // region by hand (Apple needs an existing price point with exactly that value).\n products: [\n {\n id: 'com.example.game.remove_ads', type: 'non_consumable', usd: 2.99,\n overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }],\n listings: {\n en: { title: 'Remove Ads', description: 'Removes interstitial ads for good.' },\n tr: { title: 'Reklamları Kaldır', description: 'Ara reklamları kalıcı olarak kaldırır.' },\n },\n reviewScreenshot: 'store/iap-review/shop.png',\n },\n {\n id: 'com.example.game.coins_small', type: 'consumable', usd: 0.99,\n listings: {\n en: { title: '500 Coins', description: 'A small bag of coins.' },\n tr: { title: '500 Altın', description: 'Küçük bir kese altın.' },\n },\n reviewScreenshot: 'store/iap-review/shop.png',\n },\n ],\n\n // Google Play Data safety. Types not listed = not collected. Purposes: functionality, analytics, communications,\n // fraud, advertising, personalization, account. Typical F2P game with an analytics SDK + ad mediation:\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // No accounts: PSL_ACM_NONE. A guest account with a name picked on the device and a server token (no password, no\n // email): accounts { enabled: true, signUp: ['username'], ... } and ['PSL_ACM_USER_ID_OTHER_AUTH'] here (Play's\n // \"Username and other authentication\"; `storebound scan` proposes it). With accounts Play also requires the\n // delete-account URL (from accounts.deletionUrl). That page also answers \"can users request that their data is\n // deleted?\" with yes (dataDeletion: 'yes' | 'no' | 'auto90' only for apps without one). partialDeletion: true +\n // partialDeletionUrl when a page deletes data without deleting the account. `storebound check` compares every row with Play's\n // current form (cli/data/play-data-safety-form.csv).\n accountCreation: ['PSL_ACM_NONE'],\n data: {\n PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_USER_INTERACTION: { shared: true, collect: ['analytics', 'advertising'], share: ['analytics', 'advertising'] },\n PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['functionality'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_DEVICE_ID: { shared: true, collect: ['functionality', 'analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality', 'fraud'] },\n },\n },\n\n // Ads: `storebound validate --play` checks /app-ads.txt for these lines (copy them from your mediation\n // dashboard's app-ads.txt page). Placeholder line below; replace it.\n // ads: { appAdsTxt: ['example-network.com, pub-0000000000000000, DIRECT, 0000000000000000'] },\n\n renders: 'store/renders',\n\n shots: {\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n logo: 'store/shots/logo.png', // optional: hero scene + feature graphic\n // Google Fonts; both cover Latin Extended (tr, pl, cs, ro ...) and Vietnamese. `shots fonts` checks every glyph of the\n // copy; a character the font lacks needs a font that has it or fallback: ['Noto Sans Display'] (else shots check fails).\n fonts: { title: 'Paytone One', body: 'Montserrat' },\n // phone: { w: 440, h: 902, dpr: 3 }, // raw frame size in CSS px (screen below a 54 px status bar)\n // tablet: { w: 1032, h: 1352, dpr: 2 }, // default with apple.ipad (13\" iPad below its 24 px bar), else 800 x 1240\n scenes: [\n { id: 'hero', palette: 'fire', hero: true, over: 34, frames: [{ src: 'hero.png', rotate: -4 }],\n badges: [{ y: 0.42, side: 'l', icon: 'flag', iconBg: 'red', rot: -4 }, { y: 0.66, side: 'r', icon: 'offline', iconBg: 'red', rot: 3 }] },\n { id: 'play', palette: 'violet', frames: [{ src: 'gameplay.png', rotate: 3 }],\n badges: [{ y: 0.3, side: 'l', icon: 'bolt', iconBg: 'gold', rot: -3 }, { y: 0.7, side: 'r', hot: true, rot: 4 }] },\n { id: 'levels', palette: 'sky', frames: [{ src: 'level-b.png', scale: 0.82 }, { src: 'level-a.png', x: -31, dy: 10, scale: 0.7, rotate: -8 }, { src: 'level-c.png', x: 31, dy: 10, scale: 0.7, rotate: 8 }],\n badges: [{ y: 0.66, side: 'r', icon: 'map', iconBg: 'blue', rot: 3 }] },\n { id: 'stars', palette: 'gold', frames: [{ src: 'results.png', rotate: -3 }],\n badges: [{ y: 0.42, side: 'l', stars: true, rot: -3 }] },\n ],\n feature: { frame: 'hero.png', palette: 'fire' },\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&noads=1&seed=1',\n shots: {\n 'hero': { path: '/?level=12', waitFor: \"window.game && game.state === 'playing'\", delayMs: 1500, device: 'both' },\n 'gameplay': { path: '/?level=3', delayMs: 2500, device: 'both' },\n 'level-a': { path: '/?level=20', delayMs: 2000 },\n 'level-b': { path: '/?level=40', delayMs: 2000 },\n 'level-c': { path: '/?level=60', delayMs: 2000 },\n 'results': { path: '/?level=5&result=3', delayMs: 2000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png', // 1024 opaque\n foreground: 'store/icon/foreground.png', // 1024 RGBA, subject on transparent\n background: 'store/icon/background.png', // 1024 opaque\n out: 'store/icons',\n web: 'public/icons',\n // capacitorAssets: 'native/assets',\n // capacitorCommand: 'bunx @capacitor/assets generate --assetPath native/assets',\n // androidRes: 'native/android/app/src/main/res',\n },\n};\n\nexport default config;\n", "testers.csv": "email,first_name,last_name,groups\n", "web/appstore.svg": "App Store", "web/googleplay.svg": "Google Play", diff --git a/cli/lib/validate-rules.ts b/cli/lib/validate-rules.ts index 4c3b14b..3fd8938 100644 --- a/cli/lib/validate-rules.ts +++ b/cli/lib/validate-rules.ts @@ -3,6 +3,7 @@ import { consoleLink, consoleUrl } from './console-links'; import { type FixAction, fixAppleListing, fixPlayListing, fixReviewDetails } from './fix-actions'; +import { iphoneDuoNote } from '../shots/spec'; export type Level = 'blocker' | 'warn' | 'ok'; @@ -11,11 +12,16 @@ export type Level = 'blocker' | 'warn' | 'ok'; * (either 13" display type). The smaller sizes are scaled down from these. A live app with only the 6.5" set is fine * (store-pitfalls 44). */ export const ACCEPTED_INSTEAD: Record = { + // the console's required iPhone class (medium display): smaller sizes scale down from a larger set automatically + APP_IPHONE_61: ['APP_IPHONE_67', 'APP_IPHONE_65'], APP_IPHONE_67: ['APP_IPHONE_65'], APP_IPHONE_65: ['APP_IPHONE_67'], APP_IPAD_PRO_3GEN_129: ['APP_IPAD_PRO_129'], APP_IPAD_PRO_129: ['APP_IPAD_PRO_3GEN_129'], }; +/** Required class -> larger sets App Store Connect scales down into it ("View All Sizes in Media Manager"). */ +const SCALES_DOWN: Record = { APP_IPHONE_61: ['APP_IPHONE_67', 'APP_IPHONE_65'] }; +const LABEL: Record = { APP_IPHONE_61: 'iPhone with Dynamic Island (medium display)', APP_IPAD_PRO_3GEN_129: 'iPad 13" display' }; export interface Finding { store: 'apple' | 'play'; level: Level; code: string; msg: string; fix?: string; /** Absolute deep link to the console page the message points to (desktop / MCP render it as an Open link). */ url?: string; @@ -58,6 +64,10 @@ export interface AppleSnapshot { expectedLocales: string[]; locales: { locale: string; name?: string; subtitle?: string; description?: string; keywords?: string; screenshots: Record }[]; requiredDisplayTypes: string[]; + /** apple.iphoneDuo (iPhone Duo renders on) and whether the project has a play section, for the Duo note. */ + iphoneDuo?: boolean; + play?: boolean; + today?: Date; reviewDetail: { contactEmail?: string | null; contactPhone?: string | null; contactFirstName?: string | null; contactLastName?: string | null; demoAccountRequired?: boolean; demoAccountName?: string | null; notes?: string | null } | null; /** Age rating declaration attributes (null = not readable). */ ageRating: Record | null; @@ -212,7 +222,8 @@ export function appleRules(s: AppleSnapshot): Finding[] { for (const t of s.requiredDisplayTypes) { if (l.screenshots[t]) continue; const alt = (ACCEPTED_INSTEAD[t] ?? []).find(x => l.screenshots[x]); - if (alt) add('warn', 'screenshots-size', `${l.locale}: no ${t} set; Apple accepts the ${alt} set (${l.screenshots[alt]} screenshots) and scales it for the other sizes. Recommended, not required: add ${t}`, 'storebound apple screenshots'); + if (alt && SCALES_DOWN[t]?.includes(alt)) add('ok', 'screenshots-size', `${l.locale}: ${LABEL[t] ?? t} filled by scaling the ${alt} set down (${l.screenshots[alt]} screenshots)`); + else if (alt) add('warn', 'screenshots-size', `${l.locale}: no ${t} set; Apple accepts the ${alt} set (${l.screenshots[alt]} screenshots) and scales it for the other sizes. Recommended, not required: add ${t}`, 'storebound apple screenshots'); else add('blocker', 'screenshots', `${l.locale}: no ${t} screenshots${ACCEPTED_INSTEAD[t] ? ` (or ${ACCEPTED_INSTEAD[t]!.join(' / ')})` : ''}`, 'storebound apple screenshots'); } } @@ -254,6 +265,9 @@ export function appleRules(s: AppleSnapshot): Finding[] { add(urlOk(s.privacyUrl) ? 'ok' : 'blocker', 'privacy-url', `privacy policy ${s.privacyUrl.url}: ${urlMsg(s.privacyUrl)}`, 'privacy-policy skill'); if (s.profile) f.push(...profileRules('apple', s.profile, { reviewDetail: s.reviewDetail, descriptions: s.locales.map(l => ({ locale: l.locale, text: l.description ?? '' })) })); f.push(...rendersFindings('apple', s.renders)); + // iPhone Duo: optional until April 2027, so a passing note (never a blocker); a warning after that date + const duo = iphoneDuoNote({ apple: true, duo: !!s.iphoneDuo, play: s.play, today: s.today }); + if (duo) add(duo.level === 'info' ? 'ok' : 'warn', 'screenshots-duo', duo.msg, 'apple.iphoneDuo: true, then storebound shots render'); return sortFindings(f); } diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index 318c5eb..67c90f4 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -228,7 +228,7 @@ export const TOOLS: ToolDef[] = [ ...(i.concurrency ? ['--concurrency', String(i.concurrency)] : []), ...(i.mode === 'force' ? ['--force'] : i.mode === 'only-missing' ? ['--only-missing'] : [])] }, { name: 'shots_render', title: 'Render store screenshots', command: ['shots', 'render'], write: true, network: false, idempotent: true, description: 'Store screenshots + Play feature graphic in every size and language from the frames and the copy file (local PNGs, needs Playwright + ffmpeg; slow for many languages). dry_run lists what would be rendered.', - props: { lang, size: { type: 'string', description: 'Comma-separated size keys: ios69, ios65, iphone-medium, ipad13, play, tab7, tab10, feature' }, + props: { lang, size: { type: 'string', description: 'Comma-separated size keys: ios69, ios65, iphone-medium, iphone-duo-outer, iphone-duo-inner, ipad13, play, tab7, tab10, feature' }, scenes: { type: 'string', description: 'Comma-separated scene ids' }, variant, all_variants: { type: 'boolean', default: false, description: 'Base + every variant' }, concurrency: { type: 'integer', minimum: 1, maximum: 32, description: 'Parallel pages (default min(4, CPU cores / 2))' } }, args: i => [...opt('lang', i.lang), ...opt('size', i.size), ...opt('scenes', i.scenes), ...(i.all_variants === true ? ['--variants'] : opt('variant', i.variant)), diff --git a/cli/shots/lib.ts b/cli/shots/lib.ts index 3346f06..a173e0c 100644 --- a/cli/shots/lib.ts +++ b/cli/shots/lib.ts @@ -11,6 +11,9 @@ export const SIZES: Record): Record<' export const PLAY_TABLET_SIZES: readonly string[] = ['tab7', 'tab10']; /** Size keys this project renders by default: Apple sizes only with an apple section (iPad only with apple.ipad, the - * 6.3" iPhone only with apple.iphoneMedium), + * 6.3" iPhone only with apple.iphoneMedium, the iPhone Duo sizes only with apple.iphoneDuo), * Play sizes only with a play section (both when neither is configured); the Play tablet sizes only when `tablets` * (resolved play.tablets, see resolvedTablets in report.ts; unset here = play.tablets ?? true). */ export function defaultSizes(config: Pick, tablets?: boolean): string[] { const apple = !!config.apple || !config.play, play = !!config.play || !config.apple; const tabs = tablets ?? config.play?.tablets ?? true; return Object.keys(SIZES).filter(k => (k === 'ipad13' ? apple && !!config.apple?.ipad : k === 'iphone-medium' ? apple && !!config.apple?.iphoneMedium + : k.startsWith('iphone-duo') ? apple && !!config.apple?.iphoneDuo : k.startsWith('ios') ? apple : PLAY_TABLET_SIZES.includes(k) ? play && tabs : play)); } diff --git a/cli/shots/spec.ts b/cli/shots/spec.ts index 8c8074b..e0663ec 100644 --- a/cli/shots/spec.ts +++ b/cli/shots/spec.ts @@ -40,10 +40,14 @@ export const IMAGE_SPECS: Record = { // 6.3" iPhone with Dynamic Island, medium display (iPhone 14 Pro .. 18 Pro): optional here (apple.iphoneMedium). Apple // scales the 6.5" (Face ID large) set down for it when it is missing. APP_IPHONE_61 is inferred: the enum has no // descriptions and 61 is the only medium iPhone bucket (NOT VERIFIED against an upload). - 'iphone-medium': { store: 'apple', type: 'APP_IPHONE_61', label: 'App Store iPhone 6.3"', sizes: [[1206, 2622], [1179, 2556]], min: 1, max: 10, maxBytes: null, alpha: false }, + 'iphone-medium': { store: 'apple', type: 'APP_IPHONE_61', label: 'iPhone with Dynamic Island (medium display)', sizes: [[1206, 2622], [1179, 2556]], min: 1, max: 10, maxBytes: null, alpha: false }, ios65: { store: 'apple', type: 'APP_IPHONE_65', label: 'App Store iPhone 6.5"', sizes: [[1284, 2778], [1242, 2688]], min: 1, max: 10, maxBytes: null, alpha: false }, // 13" iPad display (iPad Pro M4/M5, iPad Air M2+): required when the app runs on iPad. API display type keeps the old name. - ipad13: { store: 'apple', type: 'APP_IPAD_PRO_3GEN_129', label: 'App Store iPad 13"', sizes: [[2064, 2752], [2048, 2732]], min: 1, max: 10, maxBytes: null, alpha: false }, + ipad13: { store: 'apple', type: 'APP_IPAD_PRO_3GEN_129', label: 'iPad 13" display', sizes: [[2064, 2752], [2048, 2732]], min: 1, max: 10, maxBytes: null, alpha: false }, + // iPhone Duo (foldable, Screenshot specifications, October 2026): outer and inner display, portrait or landscape, + // optional (apple.iphoneDuo). `type` is not an ASC display type: see IPHONE_DUO_DISPLAY. No app preview sizes yet. + 'iphone-duo-outer': { store: 'apple', type: 'IPHONE_DUO_OUTER', label: 'iPhone Duo (outer display)', sizes: [[1398, 2034]], min: 1, max: 10, maxBytes: null, alpha: false }, + 'iphone-duo-inner': { store: 'apple', type: 'IPHONE_DUO_INNER', label: 'iPhone Duo (inner display)', sizes: [[2007, 2853]], min: 1, max: 10, maxBytes: null, alpha: false }, play: { store: 'play', type: 'phoneScreenshots', label: 'Play phone', sizes: [[1080, 1920]], min: 2, max: 8, maxBytes: 8 * MB, alpha: false }, tab7: { store: 'play', type: 'sevenInchScreenshots', label: 'Play 7" tablet', sizes: [[1080, 1920]], min: 0, max: 8, maxBytes: 8 * MB, alpha: false }, tab10: { store: 'play', type: 'tenInchScreenshots', label: 'Play 10" tablet', sizes: [[1440, 2560]], min: 0, max: 8, maxBytes: 8 * MB, alpha: false }, @@ -51,6 +55,23 @@ export const IMAGE_SPECS: Record = { feature: { store: 'play', type: 'featureGraphic', label: 'Play feature graphic', sizes: [[1024, 500]], min: 1, max: 1, maxBytes: 15 * MB, alpha: false }, }; +/** ASC screenshotDisplayType per iPhone Duo size key. null = NOT IN THE API YET: ScreenshotDisplayType in App Store + * Connect API 4.5.1 (spec checked 2026-10-18) has no Duo value (only AppAssetLibraryDisplayClass lists IPHONE_DUO). + * When Apple adds it: regenerate (bun tools/gen-api.ts) and put the exact enum names here; uploads then include them. */ +export const IPHONE_DUO_DISPLAY: Record = { 'iphone-duo-outer': null, 'iphone-duo-inner': null }; +export const IPHONE_DUO_NOT_IN_API = 'App Store Connect does not take iPhone Duo screenshots through the API yet; storebound keeps them in store/renders'; +/** Reportedly required from this date for apps built with the iOS 27.1 SDK or later. */ +export const IPHONE_DUO_REQUIRED_FROM = '2027-04-01'; +const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" / 10" tablet screenshots (tab7, tab10, play.tablets)'; + +/** check / validate note for an iPhone app without iPhone Duo screenshots: info before IPHONE_DUO_REQUIRED_FROM, a + * warning after (never an error: the rule depends on the SDK the build uses). null when Duo renders exist or no iPhone app. */ +export function iphoneDuoNote(o: { apple: boolean; duo: boolean; play?: boolean; today?: Date }): { level: 'info' | 'warn'; msg: string } | null { + if (!o.apple || o.duo) return null; + const late = (o.today ?? new Date()).toISOString().slice(0, 10) >= IPHONE_DUO_REQUIRED_FROM; + return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds${o.play ? `. ${PLAY_FOLDABLE}` : ''}` }; +} + /** Play screenshots in general (any size the store accepts): 320..3840 px per side, long side at most 2x the short. */ export function playScreenshotOk(w: number, h: number): boolean { const lo = Math.min(w, h), hi = Math.max(w, h); diff --git a/cli/validate.ts b/cli/validate.ts index 65e6fe3..b3843c3 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -113,6 +113,7 @@ export async function collectApple(ctx: Ctx): Promise { build, locales, reviewDetail, ageRating, expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x), requiredDisplayTypes: requiredDisplayTypes(config.apple), + iphoneDuo: !!config.apple?.iphoneDuo, play: !!config.play, priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'), availability: !!avail?.data, firstProductGaps, diff --git a/docs/store-art.md b/docs/store-art.md index 5eba830..a9b2567 100644 --- a/docs/store-art.md +++ b/docs/store-art.md @@ -76,6 +76,10 @@ languages, every store size, a review gallery and an app preview video. No store 6. **Video.** `shots record` films the autoplay mode, `shots video` cuts 15-30 s App Store previews (886 x 1920, 1200 x 1600) and a 9:16 / 16:9 promo for YouTube with captions in every language, `apple previews` uploads them. Play takes a YouTube URL in `play.video`. +7. **iPhone Duo (foldable).** `apple.iphoneDuo: true` renders and checks the outer (1398 x 2034) and inner + (2007 x 2853) sets. Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds (`check` / `validate` + note it). The API has no Duo display type yet, so `apple screenshots` skips them with a link to the console's + iPhone Duo tab. Play has no foldable slot: foldables use the 7" / 10" tablet sets. Details: the [store-screenshots](../skills/store-screenshots/SKILL.md) and [app-previews](../skills/app-previews/SKILL.md) skills. diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 8458b2d..236b1a8 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -899,3 +899,15 @@ that fails without the fix (see CONTRIBUTING.md). `cli/lib/scan/rules.ts`, the SDK table `REMOTE_SDKS` in `cli/lib/scan/sdks.ts`). A code obfuscator (DexGuard, iXGuard, a JavaScript obfuscator) is a low `obfuscation` note; ProGuard / R8 minify alone is never reported. `tests/remote-features.test.ts`. + +## 75. iPhone Duo screenshots: in the console, not in the API + +- **Symptom:** App Store Connect shows an "iPhone Duo" tab next to "iPhone with Dynamic Island (medium display)" and + "iPad 13" display", but an upload for it through the API has no display type to use (a guessed one answers 400). +- **Cause:** the API's `ScreenshotDisplayType` (4.5.1) has no Duo value yet; only the asset library enum lists + `IPHONE_DUO`. Duo is optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. +- **Do:** render and check the sets (`apple.iphoneDuo: true`), add them in the console's iPhone Duo tab (the skip + message has the deep link), never guess an enum name. For Play point foldables to the tablet sets; there is no Duo slot. +- **Guard:** `IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts` (null = not in the API; regenerate with + `bun tools/gen-api.ts` and fill in the exact names), `skippedDuoSets` in `cli/apple/screenshots.ts`, the + `screenshots-duo` note in check / validate. `tests/ppo.test.ts`. diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index b6d37ba..b16019a 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -33,6 +33,8 @@ or uploaded as another size. `store/renders` can be large (hundreds of MB) and n |---|---|---| | ios69 | 1320 x 2868 | App Store 6.9" (`APP_IPHONE_67`, "Dynamic Island (large display)"); the set storebound requires | | ios65 | 1284 x 2778 | App Store 6.5" (`APP_IPHONE_65`) | +| iphone-duo-outer | 1398 x 2034 (or 2034 x 1398) | App Store "iPhone Duo" outer display; optional, only with `apple.iphoneDuo: true` (tablet frames) | +| iphone-duo-inner | 2007 x 2853 (or 2853 x 2007) | App Store "iPhone Duo" inner display; same switch | | ipad13 | 2064 x 2752 | App Store 13" iPad (`APP_IPAD_PRO_3GEN_129`), required when the app runs on iPad; only with `apple.ipad: true` | | play | 1080 x 1920 | Play phone (2-8, ratio at most 2:1) | | tab7 | 1080 x 1920 | Play 7" tablet (tablet frames, Android tablet body); optional, see `play.tablets` | @@ -61,6 +63,18 @@ to the 13" iPad screen (1032 x 1352 CSS px @2x below a 24 px bar), so an `iPad P simulator screenshot fits after the bar is cropped. Play tablet sizes (tab7 / tab10) reuse the same tablet frames, drawn in a plain Android tablet body instead of the iPad one. +**iPhone Duo (foldable).** App Store Connect's "App Previews and Screenshots" tab now lists three classes: +"iPhone with Dynamic Island (medium display)" (required for iPhone; a 6.9" or 6.5" set scales down into it, so +`validate` passes with the `ios69` set), "iPhone Duo" and "iPad 13" display" (required with `apple.ipad`). Duo takes an +outer (1398 x 2034) and an inner (2007 x 2853) set, portrait or landscape, PNG / JPEG without alpha, 1-10 each. It is +optional now and reportedly required from April 2027 for builds with the iOS 27.1 SDK or later; `check` / `validate` +print an info note until then (a warning after, never a blocker). `apple.iphoneDuo: true` adds both sizes to +`shots render` / `shots check` / the gallery. The API (4.5.1) has no Duo `ScreenshotDisplayType` yet, so +`apple screenshots` skips the Duo folders with a message and the deep link to the version page; add them in the +console's iPhone Duo tab until then (one constant, `IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts`, takes the names once +the enum has them). No Duo app preview sizes are published. Google Play has no foldable slot: foldables show the +7" / 10" tablet sets below. + **Play tablets are optional.** Play requires the 512 x 512 icon, the 1024 x 500 feature graphic and at least 2 screenshots (answer 9866151 above); 7" / 10" tablet screenshots are optional, but without them the app is not eligible for tablet featuring. `play.tablets` decides: unset = true when tablet renders (tab7/ or tab10/) already exist, else diff --git a/templates/storebound.config.ts b/templates/storebound.config.ts index 38d6827..91a5bab 100644 --- a/templates/storebound.config.ts +++ b/templates/storebound.config.ts @@ -51,7 +51,8 @@ const config: ShipConfig = { // usesNonExemptEncryption: false, // ipad: true, // the app runs on iPad: 13" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded // iphoneMedium: true, // optional 6.3" iPhone set (1206 x 2622, iphone-medium); off: Apple scales the 6.5" set down - // screenshotTypes: ['APP_IPHONE_67'], // what `storebound validate` requires per locale (default; + APP_IPAD_PRO_3GEN_129 with ipad) + // iphoneDuo: true, // optional iPhone Duo (foldable) sets, outer 1398 x 2034 + inner 2007 x 2853; rendered and checked, not uploaded until the API takes them + // screenshotTypes: ['APP_IPHONE_61'], // what `storebound validate` requires per locale (default: medium display, met by a 6.9" or 6.5" set; + APP_IPAD_PRO_3GEN_129 with ipad) // TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only. testflight: { groups: [ diff --git a/tests/ppo.test.ts b/tests/ppo.test.ts index fce61e6..f130eab 100644 --- a/tests/ppo.test.ts +++ b/tests/ppo.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from 'bun:test'; -import { displaySets } from '../cli/apple/screenshots'; +import { displaySets, skippedDuoSets } from '../cli/apple/screenshots'; +import { appleRules } from '../cli/lib/validate-rules'; import { checkCreate, createExperimentReq, defaultName, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq } from '../cli/lib/ppo'; import { validateConfig } from '../cli/lib/config'; import { FORBIDDEN_ARGS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools'; import { requiredSets } from '../cli/shots/report'; import { defaultSizes, SIZES } from '../cli/shots/lib'; -import { checkImage, IMAGE_SPECS, imageInfo } from '../cli/shots/spec'; +import { checkImage, IMAGE_SPECS, imageInfo, IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API, iphoneDuoNote } from '../cli/shots/spec'; import { png } from './fixtures'; describe('product page optimization', () => { @@ -74,3 +75,47 @@ describe('optional 6.3" iPhone size (iphone-medium)', () => { expect(checkImage(imageInfo(png(1320, 2868)), 'iphone-medium').map(i => i.code)).toEqual(['size']); }); }); + +describe('iPhone Duo (foldable) sizes', () => { + test('device table: outer and inner, portrait and landscape; opt-in with apple.iphoneDuo', () => { + expect(SIZES['iphone-duo-outer']).toMatchObject({ w: 1398, h: 2034 }); + expect(SIZES['iphone-duo-inner']).toMatchObject({ w: 2007, h: 2853 }); + expect(defaultSizes({ apple: { bundleId: 'x' } })).not.toContain('iphone-duo-outer'); + expect(defaultSizes({ apple: { bundleId: 'x', iphoneDuo: true } })).toEqual(expect.arrayContaining(['iphone-duo-outer', 'iphone-duo-inner'])); + expect(defaultSizes({ play: { packageName: 'x' }, apple: undefined })).not.toContain('iphone-duo-inner'); + }); + test('shots check: the 4 exact sizes pass, others and alpha fail', () => { + for (const [k, w, h] of [['iphone-duo-outer', 1398, 2034], ['iphone-duo-outer', 2034, 1398], ['iphone-duo-inner', 2007, 2853], ['iphone-duo-inner', 2853, 2007]] as const) + expect(checkImage(imageInfo(png(w, h)), k)).toEqual([]); + expect(checkImage(imageInfo(png(1398, 2035)), 'iphone-duo-outer').map(i => i.code)).toEqual(['size']); + expect(checkImage(imageInfo(png(2007, 2853)), 'iphone-duo-outer').map(i => i.code)).toEqual(['size']); + expect(checkImage(imageInfo(png(2007, 2853, { colorType: 6 })), 'iphone-duo-inner').map(i => i.code)).toEqual(['alpha']); + }); + test('upload: no API display type yet, so the sets are skipped with the message', () => { + expect(Object.values(IPHONE_DUO_DISPLAY).every(t => t === null)).toBe(true); + expect(Object.keys(displaySets({ iphoneDuo: true }))).not.toContain('iphone-duo-outer'); + expect(skippedDuoSets({})).toBeNull(); + expect(skippedDuoSets({ iphoneDuo: true })).toEqual({ dirs: ['iphone-duo-outer', 'iphone-duo-inner'], msg: expect.stringContaining(`iphone-duo-outer, iphone-duo-inner: ${IPHONE_DUO_NOT_IN_API}. Add them in App Store Connect > the iOS version page > App Previews and Screenshots > iPhone Duo: https://`) }); + expect(skippedDuoSets({ iphoneDuo: true, appId: '1234567890' })!.msg).toContain('/apps/1234567890/distribution/ios/version/inflight'); + }); + test('check / validate note: info before April 2027, never a blocker; points Play to the tablet sets', () => { + const before = new Date('2026-10-18'), after = new Date('2027-04-02'); + expect(iphoneDuoNote({ apple: true, duo: true })).toBeNull(); + expect(iphoneDuoNote({ apple: false, duo: false })).toBeNull(); + const n = iphoneDuoNote({ apple: true, duo: false, play: true, today: before })!; + expect(n.level).toBe('info'); + expect(n.msg).toContain('optional now, required from April 2027 for iOS 27.1 SDK builds'); + expect(n.msg).toContain('tablet screenshots'); + expect(iphoneDuoNote({ apple: true, duo: false, today: after })!.level).toBe('warn'); + const base = { version: null, build: null, locales: [], expectedLocales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 } }; + const f = appleRules({ ...base, today: before } as unknown as Parameters[0]).find(x => x.code === 'screenshots-duo')!; + expect(f.level).toBe('ok'); + expect(appleRules({ ...base, iphoneDuo: true, today: before } as unknown as Parameters[0]).some(x => x.code === 'screenshots-duo')).toBe(false); + }); + test('validate: the medium display class is required; a 6.9" set fills it (scaled down), none is a blocker', () => { + const base = { version: null, build: null, expectedLocales: [], requiredDisplayTypes: ['APP_IPHONE_61'], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 } }; + const run = (screenshots: Record) => appleRules({ ...base, locales: [{ locale: 'en-US', description: 'd', keywords: 'k', subtitle: 's', screenshots }] } as unknown as Parameters[0]); + expect(run({ APP_IPHONE_67: 5 }).find(x => x.code === 'screenshots-size')!.level).toBe('ok'); + expect(run({}).find(x => x.code === 'screenshots')!.level).toBe('blocker'); + }); +}); diff --git a/tests/specs.test.ts b/tests/specs.test.ts index ad39819..79db3b4 100644 --- a/tests/specs.test.ts +++ b/tests/specs.test.ts @@ -105,8 +105,8 @@ describe('13" iPad', () => { expect(displaySets({})).toEqual(DISPLAY); expect(displaySets(undefined)).toEqual(DISPLAY); expect(Object.keys(displaySets({ ipad: true }))).toEqual(['ios69', 'ios65', 'ipad13']); - expect(requiredDisplayTypes({})).toEqual(['APP_IPHONE_67']); - expect(requiredDisplayTypes({ ipad: true })).toEqual(['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129']); + expect(requiredDisplayTypes({})).toEqual(['APP_IPHONE_61']); + expect(requiredDisplayTypes({ ipad: true })).toEqual(['APP_IPHONE_61', 'APP_IPAD_PRO_3GEN_129']); expect(requiredDisplayTypes({ ipad: true, screenshotTypes: ['APP_IPHONE_65'] })).toEqual(['APP_IPHONE_65']); expect(defaultSizes({ apple: { bundleId: 'x' } })).not.toContain('ipad13'); expect(defaultSizes({ apple: { bundleId: 'x', ipad: true } })).toContain('ipad13');