diff --git a/.claude/skills/app-store-assets/SKILL.md b/.claude/skills/app-store-assets/SKILL.md new file mode 100644 index 0000000..af8ee93 --- /dev/null +++ b/.claude/skills/app-store-assets/SKILL.md @@ -0,0 +1,125 @@ +--- +name: app-store-assets +description: Create, review and validate App Store product page assets and metadata against Apple's official 2026 rules (iOS 27) — screenshots, app preview videos, app icon (Icon Composer / Liquid Glass), iOS 27 creative assets (product page header, search results), In-App Event media, promoted In-App Purchase images, plus app name, subtitle, keywords, description, promotional text, What's New, custom product pages and product page optimization. Use this whenever the user is preparing an App Store submission or update, making or checking screenshots or preview videos, designing an app icon, writing App Store copy or keywords (ASO), fixing a metadata rejection (Guideline 2.3.x, 4.1, 5.2), or asks which sizes/specs/limits Apple requires — even if they never say "App Store assets". +--- + +# App Store Assets + +Help the user ship App Store assets and metadata that (1) App Store Connect accepts, (2) App Review approves, and (3) actually convert. Everything here comes from Apple's official documentation (fetched October 2026); the reference files cite the source pages. + +## How to work + +1. **Classify the request.** Most requests are one of: plan the whole product page · write copy/keywords · produce screenshots · produce an app preview · design/check the app icon · creative assets (header/search results) · In-App Event media · promoted IAP image · review existing assets/metadata · fix a rejection. +2. **Ground it in the real app.** Before writing copy or planning screenshots, look at the project: README, existing metadata docs, main SwiftUI views/tabs, `Info.plist`, `TARGETED_DEVICE_FAMILY` in the `.xcodeproj` (iPhone only = `1`, iPad = `2`, both = `1,2`), localizations (`*.lproj`, `Localizable.xcstrings`), StoreKit/IAP code, ads SDKs, login requirements. Assets must reflect the app's *actual* core experience — inventing features is a Guideline 2.3.1 violation, and you can't write a good story about an app you haven't looked at. +3. **Load only the reference you need** (table below). Specs change often, so quote numbers from the reference file rather than from memory. +4. **Produce the deliverable**, then **validate it with the scripts** whenever there are files or text to check. +5. **Report** with each issue tagged by severity, and say which official rule backs it. + +| Task | Read | +|---|---| +| Pixel sizes, formats, durations, required device sizes, file limits | `references/specs.md` | +| What gets rejected (App Review 2.3 / 4.1 / 5.2) and Apple marketing/trademark rules | `references/review-rules.md` | +| Name, subtitle, keywords, description, promo text, What's New, IAP text, categories, localization, CPP, PPO | `references/product-page.md` | +| Screenshots, app previews, creative assets, In-App Event media — design & storytelling | `references/visual-assets.md` | +| App icon (Icon Composer, layers, appearances, sizes) | `references/app-icon.md` | +| Capturing on Simulator, status bar, exporting previews with ffmpeg | `references/production.md` | + +## Severity levels + +Use these consistently in every review so the user knows what is urgent: + +- **BLOCKER** — App Store Connect refuses the upload, or it's an explicit App Review Guideline violation (likely rejection). +- **WARNING** — Breaks an Apple best practice or marketing guideline; hurts conversion or risks a reviewer flag. +- **TIP** — Optional improvement for conversion/ASO. + +Label anything that is your own heuristic (not written by Apple) as such. Don't present a heuristic as an Apple rule. + +## Non-negotiables (always in mind) + +These are the rules most often broken. They're short on purpose; details are in the references. + +- **Truthful**: screenshots show the app *in use* (not just splash/login/title art) — 2.3.3. Previews use only screen captures of the app itself — 2.3.4. Nothing promises features the app doesn't have — 2.3.1. +- **Paid content disclosed**: if featured items/levels/features need an IAP or subscription, say so in description, screenshots and previews — 2.3.2. +- **No prices, discounts, URLs or © in visual assets**; no prices or irrelevant terms in name/subtitle/keywords — 2.3.7 + asset best practices. +- **No other platforms**: no Android, Google Play, other marketplaces, their logos or devices — 2.3.10. +- **No Apple badges or awards you can't claim**: no "Editor's Choice", "App of the Day", "Apple Design Award", no implied Apple endorsement — 5.2.4. No unverifiable claims ("#1", "best", fake awards). +- **4+ for everything shown**: icons, screenshots, previews and creative assets must suit a 4+ rating even if the app is 17+ — 2.3.8. +- **Rights & privacy**: you must own/licence every image, font, song, map tile and brand shown; use fictional account data, never a real person's — 2.3.9. +- **No trademarks of others** in name, icon or keywords (no competitor names) — 2.3.7, 4.1(c), 5.2.1. +- **Images: no alpha channel** (screenshots and creative assets). Icon: square, unmasked, layered via Icon Composer. +- **Localize** text overlays for every language you ship; missing localized media falls back to another language. + +## Quick specs (most common — full tables in `references/specs.md`) + +| Asset | Size | Notes | +|---|---|---| +| iPhone screenshots — 6.9" (Dynamic Island large) | **1320×2868** (also 1290×2796, 1260×2736) | Best "master" size; smaller iPhones scale from it | +| iPhone screenshots — 6.1" (Dynamic Island medium) | **1206×2622** or 1179×2556 | ASC lists this as the required iPhone size — upload a set | +| iPhone screenshots — 6.5" (Face ID large) | 1284×2778 or 1242×2688 | Required if no 6.9" set is provided | +| iPad screenshots — 13" | **2064×2752** or 2048×2732 | Required if the app runs on iPad | +| Screenshots count / format | 1–10 per device size & language | .png/.jpg/.jpeg, RGB, no transparency | +| App preview (all modern iPhones) | **886×1920** portrait / 1920×886 landscape | 15–30 s, ≤30 fps, H.264 10–12 Mbps or ProRes 422 HQ, ≤500 MB, up to 3 per size/language | +| App preview (iPad 13"/11") | 1200×1600 / 1600×1200 | Same video rules | +| App icon | 1024×1024 square layers | Icon Composer `.icon`; system masks corners | +| Product page header (iOS 27) | 21:9 **3840×1646** (or 16:9 5244×2950 .png) | Image or 5–30 s looping video, 30/60 fps | +| Search results asset (iOS 27) | 3:2 **1920×1280 → 3840×2560** (or 16:9 5244×2950 .png) | Image or 5–30 s video, always muted | +| In-App Event card / details | 16:9 1920×1080→3840×2160 / 9:16 1080×1920→2160×3840 | Video 15–30 s, 30/60 fps, ≤500 MB | +| Promoted IAP image | 1024×1024 | JPG/PNG, flattened, no rounded corners, not a screenshot, no text | + +| Text field | Limit | +|---|---| +| App name | 2–30 chars | +| Subtitle | 30 chars | +| Promotional text | 170 chars (editable anytime, no review of a new version) | +| Description | 4000 chars, plain text | +| Keywords | 100 **bytes**, comma-separated, no space after commas | +| What's New | 4000 chars | +| IAP display name / description | 30 / 45 chars (App Store Connect field limits) | +| In-App Event name / short / long | 30 / 50 / 120 chars | + +## Validation scripts + +Both scripts are dependency-free Python 3 (Pillow and ffprobe are used if present for extra checks). Run them from the skill directory or with absolute paths. + +```bash +# Images and videos: matches each file to the App Store slot(s) it fits, +# flags wrong sizes, alpha channels, bad formats, preview length/fps/size. +python3 scripts/check_assets.py path/to/screenshots/ path/to/preview.mp4 [--ipad] + +# Text metadata from a JSON file (see the docstring for the schema): +# limits, keyword bytes/duplicates/waste, forbidden or risky terms. +python3 scripts/check_metadata.py metadata.json +``` + +`check_metadata.py --template > metadata.json` prints a starter file. If the user's metadata lives in Markdown (e.g. `docs/APP_STORE_METADATA.md`), transcribe the fields into that JSON first rather than eyeballing character counts — counts by eye are unreliable, and keywords are measured in UTF-8 bytes, not characters. + +## Deliverable formats + +**Review / audit** — lead with a one-line verdict, then: + +``` +| Severity | Asset / field | Problem | Fix | Rule | +|---|---|---|---|---| +| BLOCKER | Keywords | 112 bytes (max 100) | drop "kebab" (already in name) … | ASC limit | +``` + +Then the corrected text or the exact sizes to re-export. + +**Screenshot plan** — one row per screenshot, in order: `# · screen to capture · caption (≤ ~6 words, per language) · what it proves · notes (IAP disclosure, dark mode…)`. The first 3 carry the story because they appear in search results. + +**Copy** — give every field ready to paste with its character count (bytes for keywords), per language. + +**Preview storyboard** — beats with timestamps (0–3 s hook … loop back), poster frame choice, overlay text, audio plan, and the export command. + +## Things that trip people up + +- The App Store checks *exact* pixel sizes. A 1290×2796 screenshot is accepted; 1290×2795 is rejected. Simulator screenshots of the right device are already exact — don't resize them unless the target size differs. +- App previews use *accepted* resolutions (e.g. 886×1920), not the device's native resolution. Raw Simulator recordings must be re-encoded. +- Promotional text doesn't affect search ranking; keywords, name and subtitle do. Don't repeat words across name, subtitle and keywords — they're already indexed (app name and company name are searchable automatically). +- Changing the app icon needs a new binary; screenshots/previews need a new version **unless** you go through Asset Library / custom product pages / product page optimization; creative assets can be approved standalone via Asset Library and swapped on a live version without a new submission. +- Poster frames can't be edited after the preview is approved (changing it means resubmitting the preview). +- iOS 27 creative assets are optional and *separate* from screenshots: they can show brand/seasonal/lifestyle imagery, but still must relate to the app's functionality and follow the same 4+/no-prices/no-other-platforms rules. + +## Language + +Write in the user's language. App Store copy should be in the storefront's language (e.g. `pt-PT` copy for Portugal uses European Portuguese, not Brazilian), and keep Apple trademarks in English (App Store, iPhone, Apple Watch). diff --git a/.claude/skills/app-store-assets/evals/evals.json b/.claude/skills/app-store-assets/evals/evals.json new file mode 100644 index 0000000..8d84088 --- /dev/null +++ b/.claude/skills/app-store-assets/evals/evals.json @@ -0,0 +1,46 @@ +{ + "skill_name": "app-store-assets", + "evals": [ + { + "id": 1, + "prompt": "Revê a metadata da App Store que tenho em docs/APP_STORE_METADATA.md (KebabLocator) e diz-me tudo o que o App Store Connect ou a App Review vão rejeitar antes de eu submeter.", + "expected_output": "Audit table with severities. Must catch: keywords are 101 bytes (>100), keywords repeat 'kebab'/'near' already in name/subtitle, '©' typed in copyright, only 6.5\" screenshots planned (6.1\" Dynamic Island medium set required), 'best' unverifiable claim in promo text. Gives corrected keyword string ≤100 bytes and the exact sizes to export.", + "files": ["docs/APP_STORE_METADATA.md"], + "assertions": [ + "Flags the keywords field as over 100 bytes", + "Flags words in keywords that already appear in the name or subtitle", + "Says the copyright field must not include the © symbol", + "Says an iPhone 6.1\" Dynamic Island (medium) screenshot set (1206×2622 or 1179×2556) is required", + "Provides a corrected keyword string of at most 100 bytes", + "Uses BLOCKER/WARNING/TIP severities with a rule reference" + ] + }, + { + "id": 2, + "prompt": "I'm launching GolfTracker PT (SwiftUI golf app with WHS handicap, rounds, stats, Apple Watch scorecard; runs on iPhone and iPad; Pro subscription unlocks advanced stats). Plan my App Store screenshots: order, what screen, captions in en-US and pt-PT, and the exact pixel sizes I need to export.", + "expected_output": "Ordered screenshot plan (≤10) with strongest benefit first, captions ≤ ~6 words in en-US and European Portuguese, disclosure that advanced stats need Pro, exact sizes 1320×2868 (6.9\"), 1206×2622 (6.1\"), 2064×2752 (iPad 13\"), plus Apple Watch sizes if a Watch app ships, simulator + status bar guidance.", + "files": [], + "assertions": [ + "Lists 1320×2868 for iPhone 6.9\"", + "Lists 1206×2622 or 1179×2556 for iPhone 6.1\"", + "Lists 2064×2752 or 2048×2732 for iPad 13\"", + "Notes that features behind the Pro subscription must be disclosed (2.3.2)", + "Provides pt-PT captions (European Portuguese, e.g. 'tu' forms, not Brazilian 'você')", + "Emphasises that the first 3 screenshots appear in search results" + ] + }, + { + "id": 3, + "prompt": "App Store Connect won't accept my app preview. It's a 42 second screen recording from the iPhone 17 Pro Max simulator, 1320x2868 at 60fps, no sound. Fix it — give me the exact command.", + "expected_output": "Explains the three problems (resolution must be 886×1920, max 30 fps, 15–30 s) plus missing audio track; gives an ffmpeg command producing 886×1920 30 fps H.264 High L4.0 ~11 Mbps with silent stereo AAC 256k and -t 30; suggests poster frame at 5 s and re-checking with check_assets.py.", + "files": [], + "assertions": [ + "States the accepted resolution is 886×1920", + "States the frame rate must be at most 30 fps", + "States the length must be between 15 and 30 seconds", + "Provides an ffmpeg command with scale to 886:1920, fps=30 and a 30 s limit", + "Adds a stereo AAC audio track (silent) in the command" + ] + } + ] +} diff --git a/.claude/skills/app-store-assets/references/app-icon.md b/.claude/skills/app-store-assets/references/app-icon.md new file mode 100644 index 0000000..b6b540a --- /dev/null +++ b/.claude/skills/app-store-assets/references/app-icon.md @@ -0,0 +1,60 @@ +# App icon — design & production + +Sources (fetched October 2026): +- HIG App icons: https://developer.apple.com/design/human-interface-guidelines/app-icons +- Icon Composer: https://developer.apple.com/icon-composer/ +- Add an app icon (App Store Connect): https://developer.apple.com/help/app-store-connect/manage-app-information/add-an-app-icon +- WWDC25 "Say hello to the new look of app icons", WWDC26 "Icon Composer for Beginners Group Lab" + +Specs table: `specs.md` §5. + +## How icons work now (Liquid Glass) + +iOS, iPadOS, macOS and watchOS icons are **layered**: a background layer + one or more foreground layers. The system adds Liquid Glass effects — specular highlights, refraction, translucency, shadows — that adapt to size, lighting and appearance and can change between OS versions. tvOS uses 2–5 parallax layers; visionOS a background + 1–2 layers rendered in 3D. + +**Workflow:** draw foreground layers in your design tool → import into **Icon Composer** (bundled with Xcode) → set background (solid or gradient), placement, glass effects, groupings → annotate default / dark / mono (clear & tinted) variants → preview across platforms and OS versions → export the `.icon` and add it to the Xcode project. tvOS/visionOS: image stacks in the asset catalog (Parallax Previewer/Exporter from Apple Design Resources). + +The icon ships **in the binary** — changing it needs a new app version. Alternate icons for PPO tests must also be in the binary. + +## Layer rules + +- **Square, unmasked layers** (rectangular for tvOS). The system applies corner/circle masks. Pre-masked layers break highlights and look jagged. +- **Clearly defined edges** on foreground shapes — no soft/feathered edges. +- **Vary opacity** in foreground layers for depth; import fully opaque layers and tune transparency in Icon Composer. +- **Background**: prefer Icon Composer's solid/gradient; if importing one, it must be full-bleed and opaque. Gradients must react well to system lighting. +- **Prefer vectors** (SVG/PDF); outline text. PNG for mesh gradients/raster art (lossless). +- **Don't bake in effects** — no specular highlights, drop shadows between layers, bevels, blurs, glows. The system's effects are dynamic; yours are static and clash. If you must, test in Icon Composer, Simulator/Device Hub and on device. +- Group layers in Icon Composer to apply glass effects at group level. + +## Design rules + +- **Simplicity** — one concept that captures the app's essence, minimal shapes, simple background. You don't need to fill the canvas. +- **Centered** primary content (critical for circular visionOS/watchOS masks). Use the grids in Apple's production templates. +- **Filled, overlapping shapes** with transparency/blur read as depth; outlined shapes look flat. +- **Text only if essential** to the brand — it isn't accessible or localizable and is tiny. A single mnemonic letter can work. Never "Play", "Watch", "New", "For visionOS". +- **Illustration over photo**; don't replicate UI components or screenshots. Avoid very thin lines and sharp corners (they vanish at small sizes). +- **No Apple hardware replicas.** No other developer's icon/brand (4.1(c)). Must be 4+ (2.3.8). +- **Consistent across platforms** so people recognize it everywhere. +- **watchOS**: avoid black backgrounds (blends into the display) — lighten them. +- **visionOS**: no background shapes meant to look like holes/concave areas. +- **tvOS**: keep a safe zone; foreground layers get cropped more during parallax; text above other layers. + +## Appearances (iOS / iPadOS / macOS) + +People choose **default, dark, clear (light/dark) or tinted (light/dark)**. You can design each; the system generates any you don't provide. + +- Keep the **same core features** across appearances — don't swap elements in and out. +- Dark: derive from the light icon; complementary, subdued colors; avoid excessively bright imagery; colored foreground on dark background gives best contrast. +- Clear and tinted are even more subdued — the shape must carry recognition on its own. +- **Alternate icons** (user-selectable in settings) each need their own dark/clear/tinted variants and are subject to App Review. + +## Review checklist for an icon + +- [ ] Recognizable at 29 pt / Spotlight size (shrink it to ~60 px and squint) +- [ ] Single focal concept, centered, survives circular mask +- [ ] No text (or one essential mnemonic), no photo, no UI screenshot +- [ ] Layers square, unmasked, crisp-edged; no baked shadows/glows +- [ ] Looks right in default, dark, clear, tinted (Icon Composer preview) +- [ ] Distinct from competitors and from Apple's own icons; no Apple hardware +- [ ] 4+ appropriate; rights to all artwork +- [ ] Matches the palette/tone of screenshots and creative assets diff --git a/.claude/skills/app-store-assets/references/product-page.md b/.claude/skills/app-store-assets/references/product-page.md new file mode 100644 index 0000000..9ad2a54 --- /dev/null +++ b/.claude/skills/app-store-assets/references/product-page.md @@ -0,0 +1,133 @@ +# Product page metadata — copy, keywords, ASO, CPP, PPO + +Sources (fetched October 2026): +- Creating your product page: https://developer.apple.com/app-store/product-page/ +- App information reference: https://developer.apple.com/help/app-store-connect/reference/app-information/app-information +- Platform version information: https://developer.apple.com/help/app-store-connect/reference/app-information/platform-version-information +- Promoting IAPs: https://developer.apple.com/app-store/promoting-in-app-purchases/ +- In-App Events: https://developer.apple.com/app-store/in-app-events/ +- Custom product pages: https://developer.apple.com/app-store/custom-product-pages/ +- Product page optimization: https://developer.apple.com/app-store/product-page-optimization/ + +## Contents +1. What's indexed for search (and what isn't) +2. Field-by-field guidance +3. Keywords: how to fill 100 bytes well +4. In-App Purchases on the product page +5. In-App Events +6. Localization +7. Custom product pages (CPP) +8. Product page optimization (PPO) +9. Ratings & reviews + +--- + +## 1. What's indexed for search + +| Indexed (per Apple docs) | Not indexed / not for keywords | +|---|---| +| App name, subtitle, keywords, developer/company name ("Your app is searchable by app name and company name"). Promoted IAPs can also surface in search. | Promotional text ("doesn't affect your app's search ranking"); description ("don't add unnecessary keywords" — it *is* used for web search engines) | + +Consequences: +- Never repeat a word across name, subtitle and keywords — it's already indexed once; repetition wastes characters. +- Don't put the app name or company name in keywords (already searchable). +- Promotional text is for news, not keywords. + +## 2. Field-by-field + +### App name — 2–30 characters +Simple, memorable, easy to spell, hints at what the app does, distinctive. Avoid generic terms or names close to existing apps (2.3.7, 4.1). Can be localized. Brand first; a short descriptor after a dash is common ("Kebab Locator – Food Map") but every word must be true and non-promotional. + +### Subtitle — 30 characters +A concise phrase that explains the value — **not** "world's best app". Highlight a feature or typical use that resonates. Shown under the name everywhere on the App Store. Can be changed with each new version (test it). No prices, no other apps, no unverifiable claims (2.3.7). + +### Promotional text — 170 characters +Sits above the description. **Editable anytime without a new version.** Use for current news: a new feature, a seasonal event, a limited-time sale (wording, not a specific price is safest). Doesn't affect search. + +### Description — 4000 characters, plain text +- Ideal shape: one concise, informative paragraph, then a short list of main features. +- **The first sentence matters most** — it's what people read before "more". +- Say what makes the app unique and why people will love it, in the brand's tone, with the audience's vocabulary. +- If mentioning an accolade, put it at the end (or in promo text). Must be real. +- No keyword stuffing. **No specific prices** (they vary per region and are already shown). +- Disclose IAP/subscription requirements for featured items (2.3.2). +- Plain text; line breaks OK; no HTML. Emoji bullets are allowed but use them sparingly and consistently (heuristic, not an Apple rule). + +### What's New — 4000 characters +Not available on v1.0; required afterwards. List new features/content in order of importance; credit user feedback ("You asked, we built…"); a call to action is fine. Significant changes must be described — "bug fixes and improvements" only for genuine maintenance releases (2.3.12). + +### Category — primary + secondary +The primary category drives discoverability and which tab the app appears in. An irrelevant category can cause rejection (2.3.5). Games/Stickers can add up to two subcategories. Note: in iOS 27, Entertainment/Games categories also place the app into **Time Allowance** groups for parental controls. + +### Screenshots & previews +See `visual-assets.md`. The first 1–3 screenshots appear in search results when there's no preview (and no iOS 27 search creative asset), so they must carry the essence of the app. If the app supports Dark Mode, consider at least one dark screenshot. + +### Copyright +"2026 Your Name" — App Store Connect adds the © automatically; typing © yourself duplicates it. + +## 3. Keywords — 100 bytes + +Rules (App Store Connect + product page article): +- Comma-separated, **no spaces after commas**; spaces inside a phrase are fine (`real estate`). +- Each keyword longer than 2 characters. +- Limit is **100 bytes** of UTF-8 — accented letters (ã, ç, é, ü) cost 2 bytes each. Non-Latin scripts cost more. +- Don't include: plurals of words already present, the word "app", category names, duplicates, special characters like # or @ (unless part of the brand), the app's own name or company name. +- Never: trademarks, celebrity names or protected terms you don't own; **competitor app names**; irrelevant or offensive terms. Misuse is a common rejection reason (2.3.7). + +Strategy: +- Search matches words from name, subtitle and keywords in combination, so prefer **single words** over phrases: with "Kebab" in the name, the keywords `near,doner,shawarma` already cover "kebab near", "doner kebab", "shawarma near" — spending bytes on `kebab near me,doner kebab` repeats words. (Combination behaviour is widely observed ASO practice; Apple only documents that keywords, name and company name are searched.) +- Balance popular, competitive terms ("food", "restaurant") with specific, low-competition ones ("doner", "falafel"). +- Localize keywords per storefront — not a literal translation of English; use what locals search. +- Each localization has its own 100 bytes. Several storefronts index more than one language (e.g. the U.S. storefront also indexes Spanish (Mexico)), so a secondary localization can hold extra keywords — this is widely used ASO practice, not something Apple documents in the guidelines; label it as a heuristic if you suggest it. + +## 4. In-App Purchases on the product page + +- Up to **20** promoted IAPs/subscriptions shown (two sections), order is configurable. +- Each needs: display name (**≤30 chars** in App Store Connect; include duration for subscriptions; avoid generic names like "100 gems" — tie it to the app), description (**≤45 chars**; clear benefit), and a **1024×1024** promotional image (see `specs.md` §6). +- IAPs can appear in search results and on Today/Games/Apps tabs; tapping leads to the product page. Consumables don't appear in search. +- App must handle the purchase on launch (`SKPaymentTransactionObserver` / StoreKit 2 `PurchaseIntent`), presenting the payment sheet immediately. + +## 5. In-App Events + +| Field | Limit | Style | +|---|---|---| +| Event name | 30 | Title case; the event's actual name, not a tagline or CTA | +| Short description | 50 | Sentence case; shown on the card | +| Long description | 120 | Sentence case; details page; may mention rewards | + +- Badges: Challenge, Competition, Live Event, Major Update, New Season, Premiere, Special Event (+ In-Game Offer, Now On Sale, Try Before You Buy for featured game offers in the Games app). Don't repeat the badge type in the name. +- Up to 31 days long; promotable up to 14 days before start; up to 15 approved and 10 published at once; submittable without a new app version. +- Event purpose (for recommendations, not shown): all users / attract new / keep active informed / bring back lapsed. +- Deep link (universal link preferred, no URL shorteners) must land on the event. +- Bad candidates: daily tasks/rewards, price-only promotions, general app awareness. +- Media specs: `specs.md` §4. Design guidance: `visual-assets.md`. + +## 6. Localization + +Localize: name, subtitle, keywords, description, promo text, What's New, screenshots (text overlays!), previews, creative assets. Tailor keywords to each market's values and search terms. If a localized preview is missing, the next best language is shown. For Portugal use **pt-PT** wording (e.g. "telemóvel", "ecrã", "perto de ti"), not pt-BR. + +## 7. Custom product pages (CPP) + +- Up to **70** extra product pages (iPhone/iPad), each with its own **URL** (`?ppid=…`). +- Vary screenshots, app previews, promotional text — and (iOS 27) creative assets like the header. +- Assign **keywords** so the CPP shows in search for those terms instead of the default page; each keyword combination must be unique to one page and match the page's intent. +- Optional **deep link** per page (iOS/iPadOS 18+) to open the matching in-app destination. +- Submitted for review independently of an app update; uploadable via the App Store Connect API. +- Use in Apple Ads (Today tab, Search tab, search result variations), StoreKit ads (`SKOverlay` `customProductPageIdentifier`), emails, social. +- Apple's stat: referring people to a CPP gives +2.5 pp conversion on average (156% vs the 1.6% default page average). +- Measure in App Analytics → Acquisition (impressions, downloads, conversion, retention, proceeds per paying user). + +## 8. Product page optimization (PPO) + +- Test **up to 3 treatments** vs the original: app icon, screenshots, app previews (and iOS 27 creative assets). +- Shown to a random share of iOS/iPadOS 15+ users; each person keeps seeing the same treatment. +- One test at a time, runs up to **90 days**, can't be edited once started. Name ≤ 64 chars. +- New treatment metadata needs App Review approval; tests without alternate icons can be submitted without a new app version. **Alternate icons must be in the shipped binary** (all variants). +- Apply a winner only after ≥ **90% confidence**; applying ends the test. Change one variable per treatment so the result is interpretable. + +## 9. Ratings & reviews + +- `SKStoreReviewController` / SwiftUI `requestReview` — the system shows the prompt **at most 3 times per 365 days**. Ask after a positive moment, never after an error. +- Respond to reviews in App Store Connect; the reviewer is notified and can update. +- Ratings are per territory; you can reset the summary rating when releasing a new version. +- iOS 27: LLM-generated **review summaries** appear on product pages in more storefronts. diff --git a/.claude/skills/app-store-assets/references/production.md b/.claude/skills/app-store-assets/references/production.md new file mode 100644 index 0000000..d4d22df --- /dev/null +++ b/.claude/skills/app-store-assets/references/production.md @@ -0,0 +1,125 @@ +# Production — capturing and exporting assets + +Practical commands for a Mac with Xcode. Simulator screenshots of the right device already have the exact pixel size App Store Connect expects, which is the easiest way to avoid "invalid size" upload errors. + +## Contents +1. Which Simulator for which size +2. Clean status bar +3. Screenshots +4. Recording an app preview +5. Exporting the preview with ffmpeg +6. Creative asset / event video export +7. Validate before uploading + +--- + +## 1. Which Simulator for which size + +| Target slot | Simulator | Output | +|---|---|---| +| iPhone 6.9" | iPhone 17 Pro Max (or 16 Pro Max) | 1320×2868 | +| iPhone 6.1" Dynamic Island medium | iPhone 17 / 17 Pro (or 16 Pro) | 1206×2622 | +| iPhone 6.1" (older accepted size) | iPhone 16 / 15 / 15 Pro / 14 Pro | 1179×2556 | +| iPhone 6.5" | iPhone 11 Pro Max / XS Max (if installed) | 1242×2688 | +| iPad 13" | iPad Pro 13-inch (M5/M4) | 2064×2752 | +| iPad 11" | iPad Pro 11-inch (M5/M4) | 1668×2420 | + +List what's installed: `xcrun simctl list devices available`. + +## 2. Clean status bar + +Apple's marketing guidelines ask for full Wi-Fi/cellular and full battery. Simulator can force this (run after the device boots, re-run after a reboot): + +```bash +xcrun simctl status_bar booted override \ + --time "9:41" \ + --dataNetwork wifi --wifiMode active --wifiBars 3 \ + --cellularMode active --cellularBars 4 --operatorName "" \ + --batteryState charged --batteryLevel 100 +# undo: +xcrun simctl status_bar booted clear +``` + +## 3. Screenshots + +```bash +# Light and dark captures of the current screen +xcrun simctl ui booted appearance light +xcrun simctl io booted screenshot --type=png "01-home-light.png" +xcrun simctl ui booted appearance dark +xcrun simctl io booted screenshot --type=png "01-home-dark.png" + +# Other languages: launch the app in a given locale +xcrun simctl launch booted com.example.app -AppleLanguages "(pt-PT)" -AppleLocale pt_PT +``` + +Simulator PNGs have no alpha channel issues, but anything that went through a design tool might: export as PNG without transparency (or JPEG). Strip alpha with Pillow if needed: + +```python +from PIL import Image +im = Image.open("in.png"); bg = Image.new("RGB", im.size, (0, 0, 0)) +bg.paste(im, mask=im.split()[-1] if im.mode in ("RGBA", "LA") else None); bg.save("out.png") +``` + +For repeatable multi-language screenshot runs, a UI test that navigates to each screen and calls `XCUIScreen.main.screenshot()` (or fastlane `snapshot`) beats manual capture. + +Composing captions: any design tool works (Figma, Sketch, Pixelmator, Keynote), as long as the export is **exactly** one of the accepted sizes. Build the canvas at the final size (e.g. 1320×2868) rather than scaling afterwards. + +## 4. Recording an app preview + +```bash +# Simulator (stop with Ctrl-C) +xcrun simctl io booted recordVideo --codec=h264 --force raw-preview.mov +``` + +Or record a real device with QuickTime Player (File → New Movie Recording → choose the iPhone as camera). Real device = real performance and haptic-free smooth scrolling; Simulator = clean status bar control. Keep the raw capture longer than needed and cut in iMovie / Final Cut Pro, then export with ffmpeg so the specs are exact. + +## 5. Exporting the preview with ffmpeg + +Target: **886×1920** (modern iPhones, portrait), ≤30 fps, H.264 High ≤ L4.0, 10–12 Mbps, stereo AAC 256 kbps 44.1/48 kHz, 15–30 s, ≤500 MB. + +```bash +# iPhone portrait preview from a Simulator/QuickTime recording +ffmpeg -y -i edited.mov \ + -vf "scale=886:1920:flags=lanczos,setsar=1,fps=30,format=yuv420p" \ + -c:v libx264 -profile:v high -level:v 4.0 -preset slow \ + -b:v 11M -maxrate 12M -bufsize 24M \ + -c:a aac -b:a 256k -ar 44100 -ac 2 \ + -t 30 -movflags +faststart preview-886x1920.mp4 +``` + +If the edit has **no audio track**, add a silent stereo track (App Store Connect's preview spec lists audio as stereo AAC with "all tracks enabled"; a silent track avoids ambiguity — this is a safety measure, not an explicit Apple requirement): + +```bash +ffmpeg -y -i edited.mov -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 \ + -map 0:v:0 -map 1:a:0 -shortest \ + -vf "scale=886:1920:flags=lanczos,setsar=1,fps=30,format=yuv420p" \ + -c:v libx264 -profile:v high -level:v 4.0 -b:v 11M -maxrate 12M -bufsize 24M \ + -c:a aac -b:a 256k -ar 44100 -ac 2 -t 30 -movflags +faststart preview-886x1920.mp4 +``` + +Other targets: swap the size — landscape iPhone `1920:886`, iPad `1200:1600` / `1600:1200`, Mac/Apple TV `1920:1080`. + +Source aspect check: modern iPhones are ~19.5:9 and 886×1920 is ~0.4615 — a 1320×2868 recording (0.4603) scales with negligible distortion. For a mismatched source, use `scale=886:1920:force_original_aspect_ratio=increase,crop=886:1920` instead. + +Poster frame: defaults to 5 s — make sure the frame at 0:05 is strong, or set it in Media Manager before submitting. + +## 6. Creative asset / event video export + +Product page header (21:9, 3840×1646), search results (3:2, e.g. 2880×1920), event card (16:9) / details (9:16). 30 or 60 fps, 5–30 s (header/search) or 15–30 s (events). Loop seamlessly — end on the frame you start with, or crossfade the last 0.5 s into the first. + +```bash +ffmpeg -y -i master.mov \ + -vf "scale=3840:1646:force_original_aspect_ratio=increase,crop=3840:1646,fps=30,format=yuv420p" \ + -c:v libx264 -profile:v high -preset slow -crf 18 -an -t 20 -movflags +faststart header-3840x1646.mp4 +``` + +(Header/search videos are muted by default; add audio only if you designed it as an optional texture layer.) + +## 7. Validate before uploading + +```bash +python3 /scripts/check_assets.py ./AppStore/ --ipad +``` + +It reports, per file: the App Store slot(s) the size fits, alpha channels, wrong formats, and — with ffprobe — duration, fps, codec, audio and file size against the preview/creative/event rules, plus whether the required screenshot sizes are covered. diff --git a/.claude/skills/app-store-assets/references/review-rules.md b/.claude/skills/app-store-assets/references/review-rules.md new file mode 100644 index 0000000..bf5e627 --- /dev/null +++ b/.claude/skills/app-store-assets/references/review-rules.md @@ -0,0 +1,124 @@ +# What gets rejected — App Review & Apple marketing rules + +Sources (fetched October 2026): +- App Review Guidelines: https://developer.apple.com/app-store/review/guidelines/ (§2.3, §4.1, §5.1.4, §5.2) +- App Store asset best practices: https://developer.apple.com/app-store/asset-best-practices/ +- App Store Marketing Guidelines: https://developer.apple.com/app-store/marketing/guidelines/ + +Use the guideline number when you report an issue so the user can look it up or quote it back to App Review. + +## Contents +1. Guideline 2.3 Accurate Metadata (full summary) +2. Other guidelines that hit metadata (4.1, 5.1.4, 5.2) +3. Asset best-practice rules (creative assets, screenshots, previews) +4. Marketing guidelines: devices, status bar, trademarks, badge +5. Rejection quick-scan checklist + +--- + +## 1. Guideline 2.3 — Accurate Metadata + +> Customers should know what they're getting when they download or buy your app, so make sure all your app metadata, including privacy information, your app description, screenshots, and previews accurately reflect the app's core experience and remember to keep them up-to-date with new versions. + +| § | Rule (paraphrased closely) | What to check | +|---|---|---| +| 2.3.1(a) | No hidden/dormant/undocumented features; describe new features in **Notes for Review** with specificity. Misleading marketing (content/services not offered, false price) — even outside the App Store — can mean removal and account termination. | Every claim in copy/screens exists in the build being reviewed. | +| 2.3.1(b) | Egregious or repeated dishonesty → removal from the Developer Program. | — | +| 2.3.2 | If featured items, levels, subscriptions etc. need extra purchases, the **description, screenshots and previews must say so clearly**. Promoted IAP name/screenshot/description must suit a public audience. | Subscription/IAP/paywalled features shown? → disclosure text (e.g. "Requires Premium"). | +| 2.3.3 | Screenshots show the **app in use** — not only title art, login page or splash screen. Text/image overlays allowed (e.g. touch point, Apple Pencil). | No login-screen or splash-only screenshots. | +| 2.3.4 | Previews may only use **video screen captures of the app itself** (Messages for stickers/iMessage). Narration and video/text overlays are allowed. | No live-action, no device footage, no renders of features. | +| 2.3.5 | Choose the most appropriate category; Apple may change it. | Category matches core function. | +| 2.3.6 | Answer age-rating questions honestly. | — | +| 2.3.7 | Unique name; accurate keywords; **no trademarked terms, popular app names, pricing info or irrelevant phrases** to game search. Name ≤ 30 chars. Name, subtitle, screenshots and previews must not include **prices, terms, or descriptions not specific to that metadata type**. Subtitles: no inappropriate content, **no references to other apps, no unverifiable claims**. Apple may modify keywords. | "Free", "€0.99", "50% off", "best", "#1", competitor names in name/subtitle/keywords. | +| 2.3.8 | Icons, screenshots and previews (incl. IAP icons) must be **4+**, even if the app is rated higher. "For Kids"/"For Children" reserved for the Kids category. Name and icons (small, large, Watch, alternate) should be similar to avoid confusion. | Violence, gore, weapons aimed at characters, suggestive imagery. | +| 2.3.9 | You must secure rights to **all materials** in icons, screenshots, previews. Use **fictional account information**, not a real person's data. | Real names/emails/phones/addresses, real users' photos, licensed music/fonts/maps. | +| 2.3.10 | Focus on Apple platforms; **no names, icons or imagery of other mobile platforms or alternative app marketplaces** (unless approved interactive functionality). No irrelevant information. | "Also on Android", Google Play badge, Android phone frames. | +| 2.3.11 | Pre-order apps must be complete and not materially different from what's advertised. | — | +| 2.3.12 | **What's New** must clearly describe new features/changes; generic text is fine only for bug fixes/security/performance. | "Bug fixes" on a release that added features → reject. | +| 2.3.13 | In-App Events: must fit an event type, metadata accurate and **about the event**, happen at the dates set, deep link goes to the right place. | — | + +## 2. Other guidelines that hit metadata + +| § | Rule | +|---|---| +| 4.1(a) | Don't copy another app or make minor changes to its name/UI. | +| 4.1(b) | Impersonating other apps/services violates the Code of Conduct. | +| 4.1(c) | Don't use **another developer's icon, brand or product name** in your icon or name without approval. | +| 5.1.4(b) | Apps not in the Kids category can't use terms in name, subtitle, icon, screenshots or description implying children are the main audience. | +| 5.2.1 | No protected third-party material (trademarks, copyrighted works) without permission; no misleading/copycat names or metadata. | +| 5.2.4(a) | Don't suggest Apple is a source/supplier of the app or endorses it. | +| 5.2.4(b) | Editor's Choice badge is applied by Apple automatically — never add it yourself. | +| 5.2.5 | Don't look confusingly similar to an Apple product, interface (e.g. Finder), app (App Store, Messages…) or advertising theme. No Apple emoji in apps/extensions. Activity rings must not mimic the Activity control. Apple Weather data needs WeatherKit attribution. | + +## 3. Asset best-practice rules (asset-best-practices page) + +These apply to **all** visual assets — screenshots, previews and the iOS 27 creative assets: + +- **Don't include specific pricing, discounts, website URLs, copyright symbols** (keeps assets global and evergreen). +- **Avoid unverifiable claims** — awards or recognition the app hasn't actually received. +- **No logos or references to other platforms or marketplaces.** +- **No Apple-designated recognitions** — Editor's Choice, App of the Day, Game of the Day, Apple Design Award winner (Apple already shows these). +- Content outside the app is allowed in *creative assets* but **must still relate to the app's general functionality**. +- **4+ for all assets**; no explicit/harmful imagery, profanity, illegal drugs, self-harm, hate speech, stereotypes, sexually suggestive content, inflammatory religious commentary, or misquoted religious texts. +- Games with violence may use action imagery but **no gore, graphic imagery, or weapons pointed directly at a person or at the viewer**. +- Build for a global audience; localize images and videos per region. +- Screenshots: stay on the core user experience — **don't use a whole screenshot for accolades or call-to-action statements**. + +From "Show more with app previews": +- Don't film people interacting with a device (over-the-shoulder, fingers tapping). Stay inside the app. +- Show more gameplay than cutscenes. +- Transitions must not imply functionality the app doesn't have; prefer dissolves/fades. +- Avoid seasonal/timely references that age the video ("new for spring", dates) and specific prices. +- **Disclose** if shown features require IAP, a subscription or login (in footage or end frame). + +From In-App Events guidance: +- No all-caps, no "!!", no "the best"/"#1", no specific prices, no calls to action as event names, no text/logos in event media where possible, no borders/gradients. + +## 4. Marketing guidelines (devices, screens, trademarks) + +Applies to screenshots and creative assets that show devices, and to any external marketing. + +**Device imagery** +- Only use **Apple-provided product bezels** (Apple Design Resources), latest-generation devices the app supports. +- Use product images as-is: no added reflections/shadows/graphics entering or leaving the screen; don't crop, tilt, obstruct, animate, flip or spin them. Promotional copy goes **beside** devices, not on top. +- Keep correct relative scale between products. Never show an Apple product next to a competitor's device. +- No 3D renders or illustrations of Apple products (except instructional), no generic devices with Apple-specific details (Home button, sensor housing, switches). +- Don't feature or obscure the Apple logo; no cases/covers. + +**Screen content** +- Show the app as it actually looks, on the **latest OS**. No blank screens. +- Fictional account info only. You're responsible for rights to everything on screen (incl. store names/locations). +- **Status bar** (iPhone/iPad): full network designator, **full Wi-Fi**, **full battery**. Show 5G only if the app is optimized for it. Full-screen apps may cover the status bar. +- Notifications: a single notification on the Lock Screen, from your app only. Don't show the Home Screen — except your widget when no third-party content is visible. + +**Wording & trademarks** +- "*App name* for iPhone" / "works with iPhone" — never "*iPhone App name*". Don't mention iOS/macOS/watchOS/tvOS in marketing. Don't call Apple devices "smartphones/tablets". +- Write names exactly: iPhone, iPad (lowercase i), Apple Watch, Apple TV, Apple Vision Pro (no "the", never "headset"; say *spatial computing* app, not AR/VR/XR/MR), App Store ("on the App Store", never "Apple App Store" or "at the App Store"), Apple Pay, Siri, iMessage, Apple In-App Purchase. +- Keep Apple trademarks in English even in localized copy. +- Never start a URL with an Apple trademark (`company.com/app/iphone` ok, `iphoneapp.com` not ok). + +**"Download on the App Store" badge** (external marketing — never inside App Store screenshots) +- Black badge preferred; white only when black is too heavy and it's the only badge. With other stores' badges, App Store badge goes first. +- Only Apple's artwork; only "Download on the"/"Pre-order on the" is localized; "App Store" stays English. +- One badge per layout, subordinate position, not modified/angled/animated, no ™. +- Clear space ≥ ¼ badge height (⅒ in tiny mobile banners). Min size 40 px on screen, 10 mm in print. + +## 5. Rejection quick-scan checklist + +Run through this on any review. Each line maps to a rule above. + +- [ ] Every screenshot shows the app in use; none is just splash/login/title art (2.3.3) +- [ ] Preview is 100% screen capture of the app; no hands/devices/live action (2.3.4) +- [ ] Every claimed feature exists in this build (2.3.1) +- [ ] IAP/subscription/login-gated features are disclosed in copy, screenshots and previews (2.3.2) +- [ ] No prices, "% off", "free trial €X", currency symbols, URLs or © in any visual asset (2.3.7, best practices) +- [ ] Name/subtitle/keywords: no prices, no "free" as a pricing term, no competitor or trademark names, no "best/#1" (2.3.7) +- [ ] No Android/Google Play/other store names, logos or devices (2.3.10) +- [ ] No Apple awards/badges, no implied Apple endorsement, no Apple emoji (5.2.4, 5.2.5) +- [ ] All imagery 4+; no gore/weapons aimed at people (2.3.8) +- [ ] Fictional data only — no real names, emails, phone numbers, faces you don't have rights to (2.3.9) +- [ ] Rights cleared for photos, fonts, music, map tiles, third-party logos/brands (2.3.9, 5.2.1) +- [ ] "For Kids"/"For Children" only if in Kids category (2.3.8, 5.1.4) +- [ ] What's New lists real changes (2.3.12) +- [ ] Icon doesn't reuse another brand's icon/name (4.1(c)) +- [ ] Device frames are Apple's official bezels, untouched; status bar full Wi-Fi/battery (marketing guidelines) diff --git a/.claude/skills/app-store-assets/references/specs.md b/.claude/skills/app-store-assets/references/specs.md new file mode 100644 index 0000000..436abd3 --- /dev/null +++ b/.claude/skills/app-store-assets/references/specs.md @@ -0,0 +1,239 @@ +# App Store technical specifications + +Source pages (fetched October 2026 — re-check them if something looks off, Apple updates these when new devices ship): +- Screenshots: https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications +- App previews: https://developer.apple.com/help/app-store-connect/reference/app-information/app-preview-specifications +- Creative assets: https://developer.apple.com/help/app-store-connect/reference/app-information/creative-assets-specifications/ +- In-App Event media: https://developer.apple.com/help/app-store-connect/reference/in-app-events/in-app-event-media-and-audio-specifications +- App icons (HIG): https://developer.apple.com/design/human-interface-guidelines/app-icons +- IAP information: https://developer.apple.com/help/app-store-connect/reference/in-app-purchases-and-subscriptions/in-app-purchase-information + +## Contents +1. Screenshots +2. App previews +3. Creative assets (product page header, search results) — iOS 27 +4. In-App Event media +5. App icon +6. Promoted In-App Purchase image +7. Text field limits +8. Recommended minimal upload set + +--- + +## 1. Screenshots + +**General rules** +- At least 1, up to **10 per device size** (per localization). +- Formats: `.jpeg`, `.jpg`, `.png`. **No alpha channels or transparency.** +- Must follow the App Review Guidelines (2.3.3: show the app in use). +- If the UI is the same across sizes and localizations, provide only the highest required resolution; App Store Connect scales down. +- Sizes are checked exactly — any other pixel size is rejected on upload. + +**Required (official wording)** +- iPhone: *at least one screenshot for iPhone with Dynamic Island (medium display)*. +- iPhone with Face ID (large display): *required if the app runs on iPhone and screenshots for iPhone with Dynamic Island (large display) aren't provided*. +- iPad (if the app supports iPadOS): *at least one screenshot for iPad 13-inch display*. +- Mac / Apple TV / Apple Vision Pro / Apple Watch: required only for apps on those platforms. +- iPhone Duo: starting **April 2027**, required for any app built with the **iOS 27.1 SDK or later**. + +### iPhone + +| Display class | Devices | Portrait | Landscape | Fallback if missing | +|---|---|---|---|---| +| iPhone Duo — outer | iPhone Duo | 1398×2034 | 2034×1398 | — (required from Apr 2027 w/ iOS 27.1 SDK) | +| iPhone Duo — inner | iPhone Duo | 2007×2853 | 2853×2007 | — | +| Dynamic Island, large (6.9") | iPhone Air, 18 Pro Max, 17 Pro Max, 16 Pro Max, 16 Plus, 15 Pro Max, 15 Plus, 14 Pro Max | 1260×2736 · 1290×2796 · **1320×2868** | 2736×1260 · 2796×1290 · 2868×1320 | scaled from Face ID large | +| Face ID, large (6.5") | 14 Plus, 13 Pro Max, 12 Pro Max, 11 Pro Max, 11, XS Max, XR | 1284×2778 · 1242×2688 | 2778×1284 · 2688×1242 | scaled from Dynamic Island large | +| Dynamic Island, medium (6.1"/6.3") | 18 Pro, 17 Pro, 17, 16 Pro, 16, 15 Pro, 15, 14 Pro | 1179×2556 · **1206×2622** | 2556×1179 · 2622×1206 | scaled from Face ID large | +| Face ID, medium (5.8"/6.1") | 17e, 16e, 14, 13 Pro, 13, 13 mini, 12 Pro, 12, 12 mini, 11 Pro, XS, X | 1170×2532 · 1125×2436 · 1080×2340 | 2532×1170 · 2436×1125 · 2340×1080 | scaled from Dynamic Island medium | +| Home Button, large (5.5") | 8 Plus, 7 Plus, 6S Plus, 6 Plus | 1242×2208 | 2208×1242 | scaled from Face ID medium | +| Home Button, medium (4.7") | SE (3rd/2nd), 8, 7, 6S, 6 | 750×1334 | 1334×750 | scaled from Home Button large | +| Home Button, 4" | SE (1st), 5S, 5C, 5 | 640×1096 (no status bar) · 640×1136 | 1136×600 · 1136×640 | scaled from Home Button medium | +| Home Button, 3.5" | 4S, 4 | 640×920 (no status bar) · 640×960 | 960×600 · 960×640 | scaled from 4" | + +### iPad + +| Display | Devices | Portrait | Landscape | Requirement / fallback | +|---|---|---|---|---| +| 13" | iPad Pro (M5, M4, 6th/5th/4th/3rd/1st gen), iPad Air (M4, M3, M2) | **2064×2752** · 2048×2732 | 2752×2064 · 2732×2048 | **Required if app runs on iPad** | +| 12.9" | iPad Pro (2nd gen) | 2048×2732 | 2732×2048 | scaled from 13" | +| 11" | iPad Pro (M5, M4, 4th–1st gen), iPad Air (M4, M3, M2, 5th, 4th), iPad (A16, 10th), iPad mini (A17 Pro, 6th) | 1488×2266 · 1668×2420 · 1668×2388 · 1640×2360 | 2266×1488 · 2420×1668 · 2388×1668 · 2360×1640 | scaled from 13" | +| 10.5" | iPad Pro, iPad Air (3rd), iPad (9th/8th/7th) | 1668×2224 | 2224×1668 | scaled from 12.9" | +| 9.7" | older iPad / iPad Air / iPad mini 2–5 | 1536×2008 (no status bar) · 1536×2048 · 768×1004 · 768×1024 | 2048×1496 · 2048×1536 · 1024×748 · 1024×768 | scaled from 10.5" | + +### Other platforms + +| Platform | Accepted sizes | +|---|---| +| Mac (16:10) | 1280×800, 1440×900, 2560×1600, 2880×1800 | +| Apple TV | 1920×1080, 3840×2160 | +| Apple Vision Pro | 3840×2160 | +| Apple Watch Ultra 4 / Ultra 3 | 422×514 | +| Apple Watch Ultra 2 / Ultra | 410×502 | +| Apple Watch Series 12 / 11 / 10 | 416×496 | +| Apple Watch Series 9 / 8 / 7 | 396×484 | +| Apple Watch Series 6 / 5 / 4 / SE 3 / SE 2 / SE | 368×448 | +| Apple Watch Series 3 / 2 / 1 | 312×390 | + +Apple Watch: use the same screenshot size across all localizations. + +--- + +## 2. App previews + +**General** +- Optional. Up to **3 per device size and language** (iOS, macOS, tvOS, visionOS). Not available for iMessage apps. +- Always shown **before** screenshots on iPhone, iPad, Mac and Apple TV. If its aspect ratio differs from the screenshots, it appears in the "A Closer Look" section instead. +- Autoplays **muted** on the product page and in search results; poster frame shows when autoplay is off. Default poster frame = **5 s**. Poster frame **can't be edited after approval**. +- Orientation: iOS portrait or landscape; macOS/tvOS/visionOS landscape only. +- Processing after upload: up to 24 h. +- Missing localized preview → next best available language is used. + +**File requirements** + +| | H.264 | ProRes 422 (HQ only) | +|---|---|---| +| Containers | .mov, .m4v, .mp4 | .mov | +| Target bit rate | 10–12 Mbps | VBR ~220 Mbps | +| Video | Progressive, up to High Profile Level 4.0 | Progressive, no external references | +| Max frame rate | 30 fps | 30 fps | +| Audio | Stereo, 256 kbps AAC, 44.1 or 48 kHz | Stereo, PCM (16/24/32-bit) or 256 kbps AAC, 44.1 or 48 kHz | +| Stereo layout | 1 track × 2 channels (L, R) **or** 2 tracks × 1 channel (L, R); all tracks enabled | same | +| Length | **15–30 s** | 15–30 s | +| Max file size | **500 MB** | 500 MB | + +**Accepted resolutions** (use these, *not* the device's native resolution) + +| Display | Portrait | Landscape | Fallback | +|---|---|---|---| +| iPhone Duo | 886×1920 | 1920×886 | — | +| iPhone 6.9" Dynamic Island large | **886×1920** | 1920×886 | Face ID large | +| iPhone 6.5" Face ID large | 886×1920 | 1920×886 | Dynamic Island large | +| iPhone 6.1" Dynamic Island medium | 886×1920 | 1920×886 | Face ID large | +| iPhone 5.8" Face ID medium | 886×1920 | 1920×886 | Dynamic Island medium | +| iPhone 5.5" Home Button large | 1080×1920 | 1920×1080 | Face ID medium | +| iPhone 4.7" Home Button medium | 750×1334 | 1334×750 | Home Button large | +| iPhone 4" | 1080×1920 | 1920×1080 | Home Button medium | +| iPhone 3.5" | not supported | | | +| iPad 13" | **1200×1600** | 1600×1200 | — | +| iPad 12.9" (2nd gen) | 1200×1600 · 900×1200 | 1600×1200 · 1200×900 | 13" | +| iPad 11" | 1200×1600 | 1600×1200 | 13" | +| iPad 10.5" | 1200×1600 | 1600×1200 | 12.9" | +| iPad 9.7" | 900×1200 | 1200×900 | 10.5" | +| Mac | — | 1920×1080 | landscape only | +| Apple TV | — | 1920×1080 | landscape only | +| Apple Vision Pro | — | 3840×2160 | landscape only | + +--- + +## 3. Creative assets — iOS 27 / iPadOS 27 + +Rich images/videos for brand, seasonal offers, new content. Optional and **separate** from screenshots, previews, IAP images and editorial artwork. Shown on iOS 27 / iPadOS 27 and later. Can also be used in custom product pages, product page optimization tests and Apple Ads (Today tab, Search). + +**Images** (no alpha / transparency) + +| Placement | Aspect | Resolution | Formats | +|---|---|---|---| +| Product page header | 21:9 | 3840×1646 | .jpeg, .jpg, .png | +| Product page header | 16:9 | 5244×2950 | .png | +| Search results | 3:2 | min 1920×1280 – max 3840×2560 | .jpeg, .jpg, .png | +| Search results | 16:9 | 5244×2950 | .png | + +The 16:9 5244×2950 PNG row is listed under *both* placements without a label. App Store Connect separately describes a **universal creative asset** that can serve both header and search results (only universal assets offer "Use header asset in search results"), and Apple ships "Universal" templates for it — so the 16:9 PNG is almost certainly that universal format (inference; the spec page doesn't say it outright). Download the Universal template to get the exact safe areas, and keep the focal point centered so each placement's crop works. + +**Videos** + +| Placement | Aspect | Resolution | fps | Length | Formats | +|---|---|---|---|---|---| +| Product page header | 21:9 | 3840×1646 | 30 or 60 | 5–30 s | .mov, .m4v, .mp4 | +| Search results | 3:2 | 1920×1280 – 3840×2560 | 30 or 60 | 5–30 s | .mov, .m4v, .mp4 | + +- Videos **loop continuously** in both placements. +- Search results: muted, **can't be unmuted**. Product page: muted by default, people can unmute. + +**Templates** (official): https://developer.apple.com/app-store/asset-best-practices/ — Figma (Universal), Photoshop (Header, Search results, Universal), Pixelmator (Header, Search results, Universal), Sketch (Universal). + +If no search results creative asset is set, In-App Events, app previews and screenshots appear in search instead. + +--- + +## 4. In-App Event media + +| Media | Formats | Aspect | Min | Max | Max size | +|---|---|---|---|---|---| +| Event card image | .jpg, .jpeg, .png | 16:9 (landscape) | 1920×1080 | 3840×2160 | 500 MB | +| Event card video | .mov, .m4v, .mp4 | 16:9 | 1920×1080 @30/60 fps | 3840×2160 @30/60 fps | 500 MB | +| Event details image | .jpg, .jpeg, .png | 9:16 (portrait) | 1080×1920 | 2160×3840 | 500 MB | +| Event details video | .mov, .m4v, .mp4 | 9:16 | 1080×1920 @30/60 fps | 2160×3840 @30/60 fps | 500 MB | + +- Video length **15–30 s**; loops — make it seamless. +- Audio: stereo; card video 256 kbps AAC; details video PCM (16/24/32-bit) or 256 kbps AAC; 44.1 or 48 kHz; tracks without audio are supported. +- The App Store applies crops and gradients automatically → don't add borders or gradients. + +--- + +## 5. App icon + +| Platform | Layout | After masking | Size | Style | Appearances | +|---|---|---|---|---|---| +| iOS, iPadOS, macOS | Square | Rounded rectangle | **1024×1024** | Layered (Liquid Glass) | Default, dark, clear light, clear dark, tinted light, tinted dark | +| tvOS | Rectangle (landscape) | Rounded rectangle | 800×480 | Layered, parallax (2–5 layers) | — | +| visionOS | Square | Circle | 1024×1024 | Layered 3D (background + 1–2 layers) | — | +| watchOS | Square | Circle | 1088×1088 | Layered | — | + +- Color spaces: sRGB, Gray Gamma 2.2, Display P3 (not visionOS). +- Provide **unmasked square layers** — pre-rounded corners break specular highlights and look jagged. +- Built with **Icon Composer** (ships with Xcode) for iOS/iPadOS/macOS/watchOS; tvOS/visionOS use image stacks in the asset catalog. +- Changing the icon after release requires a **new app version**. + +Design guidance: `references/app-icon.md`. + +--- + +## 6. Promoted In-App Purchase image + +- **1024×1024**, JPG or PNG, 72 dpi, RGB, **flattened, no rounded corners**. +- Unique per IAP. Not a screenshot. Must not look like your app icon. **No text overlay.** +- Apple adds a frame; outside the product page your app icon sits in the **lower-left corner** — keep that area clear. +- Must stay readable at small sizes. Required to promote an IAP or to set up win-back offers; also shown when offer codes are redeemed. +- Up to **20** promoted IAPs on the product page. Consumables don't appear in search results. + +--- + +## 7. Text field limits (App Store Connect) + +| Field | Limit | Notes | +|---|---|---| +| App name | 2–30 characters | Localizable | +| Subtitle | 30 characters | Localizable | +| Promotional text | 170 characters | Editable anytime; above description; **doesn't affect search** | +| Description | 4000 characters | Plain text, no HTML; used for web search engines | +| Keywords | **100 bytes** | Comma-separated; each keyword > 2 characters; no other apps/companies | +| What's New | 4000 characters | Not available for v1.0; required for later versions | +| Copyright | "YEAR Owner" | The © symbol is added automatically — don't type it | +| IAP display name | 2–30 characters | Include duration for subscriptions | +| IAP description | 45 characters | Apple's "Creating your product page" article quotes 35/55; the App Store Connect field reference says 30/45 — **write to 30/45** so it fits both | +| In-App Event name | 30 characters | Title case | +| In-App Event short description | 50 characters | Sentence case (card) | +| In-App Event long description | 120 characters | Sentence case (details page) | +| PPO test reference name | 64 characters | Internal | +| App Review notes | 4000 bytes | Internal | + +--- + +## 8. Recommended minimal upload set + +Apple's fallback chains mean you don't need every size. A set that satisfies every requirement listed above with the least work: + +**iPhone-only app** +1. 6.9" set — **1320×2868** (iPhone 17/16 Pro Max Simulator), up to 10. +2. 6.1" set — **1206×2622** (iPhone 17 / 17 Pro Simulator). Upload it even though a fallback exists, because App Store Connect names this size as *the* required iPhone size. +3. Optional preview — **886×1920**, 15–30 s, covers all modern iPhones. + +**Universal (iPhone + iPad)** — the above plus: +4. iPad 13" set — **2064×2752** (iPad Pro 13-inch (M5/M4) Simulator). +5. Optional iPad preview — **1200×1600**. + +**Adopting iOS 27 creative assets** — one universal 16:9 **5244×2950 PNG**, or a dedicated 21:9 header (3840×1646) + 3:2 search asset (e.g. 2880×1920). + +**Coming soon** — iPhone Duo screenshots (1398×2034 outer + 2007×2853 inner) once you build with the iOS 27.1 SDK (from April 2027). diff --git a/.claude/skills/app-store-assets/references/visual-assets.md b/.claude/skills/app-store-assets/references/visual-assets.md new file mode 100644 index 0000000..f4b81e2 --- /dev/null +++ b/.claude/skills/app-store-assets/references/visual-assets.md @@ -0,0 +1,130 @@ +# Visual assets — screenshots, app previews, creative assets, event media + +Sources (fetched October 2026): +- Asset best practices (iOS 27): https://developer.apple.com/app-store/asset-best-practices/ +- Creating your product page: https://developer.apple.com/app-store/product-page/ +- Show more with app previews: https://developer.apple.com/app-store/app-previews/ +- Manage your App Store assets: https://developer.apple.com/help/app-store-connect/manage-app-information/manage-your-app-store-assets +- In-App Events: https://developer.apple.com/app-store/in-app-events/ +- WWDC26 "Enhance your presence on the App Store": https://developer.apple.com/videos/play/wwdc2026/205/ + +Specs are in `specs.md`; rules that cause rejection are in `review-rules.md`. This file is about making assets that work. + +## Contents +1. The asset family — one story, one style +2. Screenshots +3. App previews +4. Creative assets: product page header & search results (iOS 27) +5. In-App Event media +6. Promoted IAP image +7. Asset Library & submission workflow + +--- + +## 1. The asset family + +The icon, header, search asset, screenshots and previews are seen together. Apple asks that they **tell a cohesive story and look good together**: same color palette, iconography, fonts, message and tone. Decide these once (a mini style sheet) before producing anything: + +- 1 background treatment (solid brand color, gradient, or the app's own UI color) +- 1 headline font + weight, 1 size scale, max ~6 words per caption +- 1 device treatment (full-bleed UI, or Apple's official bezel — not both mixed) +- Light vs dark: if the app supports Dark Mode, include at least one dark screenshot + +Image principles from Apple: +- **Compelling visuals** — current-looking photography/illustration; use the latest UI; reflect the app's aesthetic; strong composition with **one clear focal point** per asset. +- **Intentional text** — a short phrase that *enhances* the visual rather than describes it. Localize it. +- **Legible everywhere** — assets render at many sizes; keep key elements and text inside a **centered safe area** to survive cropping across devices/orientations. Check in App Store Connect's **Preview** tool (iPhone, iPad, iPhone Duo, orientations, languages, Dark Mode). + +## 2. Screenshots + +Purpose: show what people can expect — the app **in use**, detailed and honest. + +**Sequence (up to 10):** +1. Lead with the **strongest feature or benefit** — the first 1–3 show in search results (portrait apps show 3; landscape show 1). +2. Order the rest like a real session of using the app — a story, not a feature dump. +3. One benefit per screenshot. Don't spend a whole screenshot on accolades or "Download now" (Apple says avoid). +4. Include IAP disclosure where premium features appear (2.3.2) — e.g. small "Premium" tag in the caption. + +**Caption formula (heuristic):** benefit verb + object, ≤ 6 words, sentence case, no trailing "!". +- Good: "Find kebabs open near you" · "Save the spots you love" +- Weak: "Map screen" (describes, doesn't enhance) · "THE BEST KEBAB APP!!" (claim + caps) + +**Layout options:** +- *Full-bleed UI* — the raw screenshot is the asset. Most honest, zero design work, works well for content-rich apps. +- *Caption + UI* — caption on top (~15–20% height), UI scaled below. Most common. Keep UI readable (don't shrink below ~75% width). +- *Device frame* — only Apple's official bezels, untouched, latest device; copy beside, not over the device. +- *Panoramic* — a background that continues across adjacent screenshots; each must still work alone in search. + +**Content hygiene:** status bar 9:41, full Wi-Fi/cellular/battery (`production.md`); fictional data that looks real ("Ana's Döner", not "Test Shop 1"); no debug UI, no placeholder text, no empty states unless that's the point; realistic locations you have rights to show. + +## 3. App previews + +Purpose: an interactive look at the real app in action. Up to 3 per size and language, 15–30 s each. + +**Story structure (Apple: beginning, middle, end):** +- **0–3 s hook** — the most compelling moment first; autoplay is muted and people scroll fast. +- **Middle** — 2–4 features in order of popularity, each held long enough to grasp (avoid quick cuts and fast motion — accessibility). +- **End** — a clean frame that loops back to the start seamlessly (it autoplays and repeats). +- Multiple previews: preview 1 = overview, 2–3 = deeper features. Each must show something new. + +**Rules of thumb:** +- Only screen capture of the app (2.3.4). No hands, no devices, no live action. +- Capture at native UI resolution; don't zoom into the view. Touch hotspots are OK to explain gestures. +- Transitions: dissolves and fades; never imply functionality the app lacks. +- **Works muted**: add short, legible text overlays that stay on screen long enough to read. No dates/seasons, no prices. +- **Audio** as brand texture: one music bed across the whole preview, UI sound effects OK, no sudden loud moments, loop cleanly. Voiceover only with pro-quality audio. +- Disclose IAP/subscription/login in footage or end frame. +- **Poster frame** (default 5 s): pick a frame that represents the app on its own — it's what people see when autoplay is off. Can't be changed after approval without resubmitting the preview. + +**AR apps:** uncluttered, well-lit room with nothing personal or third-party; texture for tracking; in the first seconds show the object being placed so people see what's virtual. Say "AR app/AR game" only for genuine AR. + +## 4. Creative assets — product page header & search results (iOS 27) + +Creative assets may show things **beyond the app in use** — brand, seasonal offerings, new content, lifestyle — but must still relate to the app's general functionality and follow all 4+/no-price/no-other-platform rules. + +**Product page header** — the first thing on the product page; sets the tone. +- One single, clear idea; dense or cluttered assets dilute impact. +- Design for a **first-time visitor** — what's most compelling to someone who's never heard of the app? +- Video: muted by default (user can unmute), loops. Make it silent-first. +- Ideas Apple gives: outdoor apps → aspirational imagery; travel → destinations; games → gameplay or characters. + +**Search results asset** — replaces the screenshots/previews in search. +- **State the obvious**: people are looking for something specific; the app's purpose must be clear at a glance. +- **Show the firsthand experience**: interface, content or gameplay so people can decide quickly. +- Video is always muted here. +- Without one, In-App Events, previews and screenshots appear instead. + +**Universal asset** — one asset for both header and search results strengthens recognition; start from Apple's Universal template so the critical area survives both crops. + +**Video rules** (Apple's guidance for these placements): +- Strong **poster frame** that represents the app on its own. +- **Start strong** with the value proposition; pace cuts so people can follow; avoid fast motion. +- **Loop seamlessly** — no visible jump. +- **Audio** is muted by default: silent-first; if adding sound, a short music loop over heavy effects, no sudden loud moments, loops cleanly. + +**Using them beyond the default page:** custom product pages can carry a tailored header per keyword set or campaign (and deep-link to the matching in-app content); PPO can A/B test headers; Apple Ads can reuse the same images/videos on the Today tab and in search. + +## 5. In-App Event media + +- Two formats: **card 16:9 landscape** and **details 9:16 portrait** — both must convey the event's essence; keep colors/illustration style continuous between them. +- Design to complement the **badge**, not duplicate it (don't write "Challenge" in the art if the badge says Challenge). +- Avoid text and logos in the media where possible (especially event or app name — already shown). +- No borders or gradients (Apple applies crops and gradients itself). Preview in App Store Connect to make sure key content isn't obscured. +- Video loops — seamless. + +## 6. Promoted IAP image + +Represents the item itself (a pack, a feature, a tier) — not a screenshot, not a copy of the app icon, no text. Must read at small sizes; keep the **lower-left corner clear** (the app icon is overlaid there outside the product page). Unique image per IAP. + +## 7. Asset Library & submission workflow (App Store Connect, 2026) + +- **Asset Library** (sidebar) holds screenshots, previews, event media and creative assets for all platforms/sizes. Upload via **+** → "Upload Creative Assets" or "Previews and Screenshots". Filter by Type/Status/Format. "Works With" shows which placements an asset fits. +- Assets can be **submitted standalone** (without a new version) once the app has an approved version; they're reviewed against the latest version of the platform you pick. For a brand-new app, include them in the first version submission. +- Assets attached to a version, CPP, PPO test or In-App Event are reviewed **with** that item and can't be submitted separately while it's in review. +- Creative assets: version page → Product Page Information → **Header and Search Results** tab → Header / Search Results → drag in or **Browse Assets**. Tick "Use header asset in search results" for universal assets. +- **Live version, new creative asset**: choose an already-approved asset → **Publish** — no new version needed. +- Screenshots/previews: version page → App Previews and Screenshots tab, or **Media Manager** ("View All Sizes") for per-size/per-language management and poster frames. Uploadable when the version is Prepare for Submission / Rejected / Metadata Rejected / Developer Rejected / Invalid Binary. +- Removing a screenshot also removes it from sizes that were using the scaled version. +- Only assets in **Prepared for Submission** can be deleted; others can be **archived** (archived assets can't be placed in new placements). +- Roles: Account Holder, Admin, App Manager or Marketing can manage assets; submitting needs Account Holder, Admin or App Manager. +- Automation: App Store Connect API (App Preview Sets, Screenshot Sets, Asset Library uploads/submissions); Apple Ads Platform API for ads. diff --git a/.claude/skills/app-store-assets/scripts/check_assets.py b/.claude/skills/app-store-assets/scripts/check_assets.py new file mode 100755 index 0000000..72ec070 --- /dev/null +++ b/.claude/skills/app-store-assets/scripts/check_assets.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +"""Validate App Store image/video assets against Apple's official specs (Oct 2026). + +Usage: + python3 check_assets.py PATH [PATH ...] [--ipad] [--json] + +PATH can be files or directories (searched recursively). For every image it +reports which App Store slot(s) the exact pixel size fits (screenshot sizes, +app icon, iOS 27 creative assets, In-App Event media, promoted IAP image) and +flags alpha channels / wrong formats. For every video it checks app preview, +creative asset and In-App Event video rules (needs `ffprobe` on PATH for +duration, fps, codec and audio checks; without it only the size is checked). + +At the end it summarises whether the required screenshot sizes are covered. +Pass --ipad if the app runs on iPad (then a 13" iPad set is required). + +Exit code: 1 if any BLOCKER was found, else 0. + +No third-party dependencies. Image headers (PNG, JPEG) are parsed directly. +Specs: https://developer.apple.com/help/app-store-connect/reference/app-information/ +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import struct +import subprocess +import sys +from collections import defaultdict +from fractions import Fraction + +IMAGE_EXT = {".png", ".jpg", ".jpeg"} +VIDEO_EXT = {".mov", ".m4v", ".mp4"} +OTHER_IMAGE_EXT = {".heic", ".webp", ".gif", ".tif", ".tiff", ".bmp"} + +# --------------------------------------------------------------------------- +# Screenshot slots: (group, label, [(w, h), ...] portrait sizes). Landscape is +# the same sizes swapped. Group ids feed the "required sizes" summary. +# --------------------------------------------------------------------------- +SCREENSHOTS = [ + ("iphone_duo", 'iPhone Duo (outer)', [(1398, 2034)]), + ("iphone_duo", 'iPhone Duo (inner)', [(2007, 2853)]), + ("iphone_di_large", 'iPhone 6.9" Dynamic Island large', [(1260, 2736), (1290, 2796), (1320, 2868)]), + ("iphone_faceid_large", 'iPhone 6.5" Face ID large', [(1284, 2778), (1242, 2688)]), + ("iphone_di_medium", 'iPhone 6.1" Dynamic Island medium', [(1179, 2556), (1206, 2622)]), + ("iphone_faceid_medium", 'iPhone 5.8" Face ID medium', [(1170, 2532), (1125, 2436), (1080, 2340)]), + ("iphone_55", 'iPhone 5.5" Home Button large', [(1242, 2208)]), + ("iphone_47", 'iPhone 4.7" Home Button medium', [(750, 1334)]), + ("iphone_40", 'iPhone 4" Home Button', [(640, 1096), (640, 1136)]), + ("iphone_35", 'iPhone 3.5" Home Button', [(640, 920), (640, 960)]), + ("ipad_13", 'iPad 13"', [(2064, 2752), (2048, 2732)]), + ("ipad_129", 'iPad 12.9" (2nd gen)', [(2048, 2732)]), + ("ipad_11", 'iPad 11"', [(1488, 2266), (1668, 2420), (1668, 2388), (1640, 2360)]), + ("ipad_105", 'iPad 10.5"', [(1668, 2224)]), + ("ipad_97", 'iPad 9.7"', [(1536, 2008), (1536, 2048), (768, 1004), (768, 1024)]), +] +# 4"/3.5"/9.7" landscape "without status bar" sizes aren't plain swaps. +EXTRA_LANDSCAPE = { + "iphone_40": [(1136, 600)], + "iphone_35": [(960, 600)], + "ipad_97": [(2048, 1496), (1024, 748)], +} +FIXED_SCREENSHOTS = [ + ("mac", "Mac (16:10)", [(1280, 800), (1440, 900), (2560, 1600), (2880, 1800)]), + ("appletv", "Apple TV", [(1920, 1080), (3840, 2160)]), + ("visionpro", "Apple Vision Pro", [(3840, 2160)]), + ("watch", "Apple Watch Ultra 4/3", [(422, 514)]), + ("watch", "Apple Watch Ultra 2/Ultra", [(410, 502)]), + ("watch", "Apple Watch Series 12/11/10", [(416, 496)]), + ("watch", "Apple Watch Series 9/8/7", [(396, 484)]), + ("watch", "Apple Watch Series 6/5/4/SE", [(368, 448)]), + ("watch", "Apple Watch Series 3/2/1", [(312, 390)]), +] + +PREVIEW_SIZES = { + (886, 1920): 'App preview — iPhone 6.9"/6.5"/6.1"/5.8"/Duo (portrait)', + (1920, 886): 'App preview — iPhone 6.9"/6.5"/6.1"/5.8"/Duo (landscape)', + (1080, 1920): 'App preview — iPhone 5.5"/4" (portrait)', + (1920, 1080): 'App preview — iPhone 5.5"/4" landscape, Mac, Apple TV', + (750, 1334): 'App preview — iPhone 4.7" (portrait)', + (1334, 750): 'App preview — iPhone 4.7" (landscape)', + (1200, 1600): 'App preview — iPad 13"/12.9"/11"/10.5" (portrait)', + (1600, 1200): 'App preview — iPad 13"/12.9"/11"/10.5" (landscape)', + (900, 1200): 'App preview — iPad 12.9"/9.7" (portrait)', + (1200, 900): 'App preview — iPad 12.9"/9.7" (landscape)', + (3840, 2160): "App preview — Apple Vision Pro (landscape)", +} + +MAX_BYTES = 500 * 1024 * 1024 + + +def screenshot_matches(w: int, h: int): + out = [] + for group, label, sizes in SCREENSHOTS: + for pw, ph in sizes: + if (w, h) == (pw, ph): + out.append((group, f"Screenshot — {label} (portrait)")) + elif (w, h) == (ph, pw): + out.append((group, f"Screenshot — {label} (landscape)")) + for lw, lh in EXTRA_LANDSCAPE.get(group, []): + if (w, h) == (lw, lh): + out.append((group, f"Screenshot — {label} (landscape, no status bar)")) + for group, label, sizes in FIXED_SCREENSHOTS: + if (w, h) in sizes: + out.append((group, f"Screenshot — {label}")) + # de-duplicate while keeping order + seen, uniq = set(), [] + for m in out: + if m not in seen: + seen.add(m) + uniq.append(m) + return uniq + + +def in_range(w, h, lo, hi, ratio: Fraction): + return Fraction(w, h) == ratio and lo[0] <= w <= hi[0] and lo[1] <= h <= hi[1] + + +def creative_image_matches(w: int, h: int, ext: str): + out = [] + if (w, h) == (3840, 1646): + out.append(("creative", "Creative asset — product page header 21:9")) + if (w, h) == (5244, 2950): + label = "Creative asset — 16:9 header/search results (universal)" + out.append(("creative", label if ext == ".png" else label + " — must be .png")) + if in_range(w, h, (1920, 1280), (3840, 2560), Fraction(3, 2)): + out.append(("creative", "Creative asset — search results 3:2")) + if in_range(w, h, (1920, 1080), (3840, 2160), Fraction(16, 9)): + out.append(("event", "In-App Event card 16:9")) + if in_range(w, h, (1080, 1920), (2160, 3840), Fraction(9, 16)): + out.append(("event", "In-App Event details page 9:16")) + if (w, h) == (1024, 1024): + out.append(("icon", "App icon layer 1024×1024 (iOS/iPadOS/macOS/visionOS) or promoted IAP image")) + if (w, h) == (1088, 1088): + out.append(("icon", "watchOS app icon 1088×1088")) + if (w, h) == (800, 480): + out.append(("icon", "tvOS app icon layer 800×480")) + return out + + +def creative_video_matches(w: int, h: int): + out = [] + if (w, h) == (3840, 1646): + out.append(("creative_header", "Creative video — product page header 21:9", 5, 30)) + if in_range(w, h, (1920, 1280), (3840, 2560), Fraction(3, 2)): + out.append(("creative_search", "Creative video — search results 3:2", 5, 30)) + if in_range(w, h, (1920, 1080), (3840, 2160), Fraction(16, 9)): + out.append(("event", "In-App Event card video 16:9", 15, 30)) + if in_range(w, h, (1080, 1920), (2160, 3840), Fraction(9, 16)): + out.append(("event", "In-App Event details video 9:16", 15, 30)) + return out + + +# --------------------------------------------------------------------------- +# Image header parsing (no Pillow needed) +# --------------------------------------------------------------------------- +def read_png(path): + with open(path, "rb") as f: + if f.read(8) != b"\x89PNG\r\n\x1a\n": + return None + has_trns = False + w = h = color_type = bit_depth = None + icc = None + while True: + hdr = f.read(8) + if len(hdr) < 8: + break + length, ctype = struct.unpack(">I4s", hdr) + data = f.read(length) if ctype in (b"IHDR", b"iCCP", b"sRGB") else None + if data is None: + f.seek(length, 1) + f.seek(4, 1) # CRC + if ctype == b"IHDR": + w, h, bit_depth, color_type = struct.unpack(">IIBB", data[:10]) + elif ctype == b"tRNS": + has_trns = True + elif ctype == b"iCCP": + icc = data.split(b"\x00", 1)[0].decode("latin-1", "replace") + elif ctype == b"sRGB": + icc = icc or "sRGB" + elif ctype in (b"IDAT", b"IEND"): + break + alpha = color_type in (4, 6) or has_trns + mode = {0: "Gray", 2: "RGB", 3: "Indexed", 4: "Gray+Alpha", 6: "RGBA"}.get(color_type, "?") + return {"format": "PNG", "width": w, "height": h, "alpha": alpha, "mode": mode, + "bit_depth": bit_depth, "profile": icc} + + +def read_jpeg(path): + with open(path, "rb") as f: + if f.read(2) != b"\xff\xd8": + return None + while True: + b = f.read(1) + while b and b != b"\xff": + b = f.read(1) + while b == b"\xff": + b = f.read(1) + if not b: + return None + marker = b[0] + if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7: + continue + seg_len = struct.unpack(">H", f.read(2))[0] + if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF): + _precision, h, w, comps = struct.unpack(">BHHB", f.read(6)) + mode = {1: "Gray", 3: "RGB/YCbCr", 4: "CMYK"}.get(comps, "?") + return {"format": "JPEG", "width": w, "height": h, "alpha": False, "mode": mode, + "bit_depth": 8, "profile": None} + f.seek(seg_len - 2, 1) + + +def sniff(path): + with open(path, "rb") as f: + head = f.read(12) + if head.startswith(b"\x89PNG"): + return "png" + if head.startswith(b"\xff\xd8"): + return "jpeg" + if head[4:8] == b"ftyp": + return "isobmff" + return None + + +# --------------------------------------------------------------------------- +# Video probing via ffprobe +# --------------------------------------------------------------------------- +def ffprobe(path): + exe = shutil.which("ffprobe") + if not exe: + return None + try: + out = subprocess.run( + [exe, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", path], + capture_output=True, text=True, timeout=60, check=True, + ).stdout + return json.loads(out) + except (subprocess.SubprocessError, json.JSONDecodeError): + return None + + +def parse_rate(r): + try: + n, d = r.split("/") + return float(n) / float(d) if float(d) else 0.0 + except (ValueError, AttributeError): + return 0.0 + + +# --------------------------------------------------------------------------- +# Checks +# --------------------------------------------------------------------------- +class Report: + def __init__(self): + self.items = [] # dicts: file, kind, info, matches, issues[(sev, msg)] + + def add(self, **kw): + kw.setdefault("issues", []) + kw.setdefault("matches", []) + self.items.append(kw) + return kw + + +def check_image(path, report): + ext = os.path.splitext(path)[1].lower() + kind = sniff(path) + item = report.add(file=path, kind="image") + if ext in OTHER_IMAGE_EXT: + item["issues"].append(("BLOCKER", f"{ext} isn't accepted — export as .png or .jpg")) + return + info = read_png(path) if kind == "png" else read_jpeg(path) if kind == "jpeg" else None + if not info or not info.get("width"): + item["issues"].append(("BLOCKER", "Couldn't read image header — not a valid PNG/JPEG")) + return + item["info"] = info + if (kind == "png" and ext not in (".png",)) or (kind == "jpeg" and ext not in (".jpg", ".jpeg")): + item["issues"].append(("WARNING", f"Extension {ext} doesn't match the real format ({info['format']}) — rename it")) + w, h = info["width"], info["height"] + shots = screenshot_matches(w, h) + creative = creative_image_matches(w, h, ext) + item["matches"] = [m[1] for m in shots] + [m[1] for m in creative] + item["groups"] = [m[0] for m in shots] + if not item["matches"]: + item["issues"].append(("BLOCKER", f"{w}×{h} doesn't match any App Store image size — {nearest_hint(w, h)}")) + if info["alpha"] and (w, h) in ((1024, 1024), (1088, 1088), (800, 480)): + item["issues"].append(("WARNING", f"Has an alpha channel ({info['mode']}) — fine for an Icon Composer " + "foreground layer, but a promoted IAP image or icon background must be " + "flattened, opaque RGB")) + elif info["alpha"]: + item["issues"].append(("BLOCKER", f"Has an alpha channel ({info['mode']}) — screenshots and creative assets " + "can't include transparency; re-export flattened RGB")) + if info["mode"] == "CMYK": + item["issues"].append(("BLOCKER", "CMYK JPEG — export as RGB (sRGB or Display P3)")) + if info["mode"] in ("Indexed",): + item["issues"].append(("WARNING", "Indexed-color PNG — export as 24-bit RGB to avoid banding")) + if any("must be .png" in m for m in item["matches"]): + item["issues"].append(("BLOCKER", "The 16:9 5244×2950 creative asset is only accepted as .png")) + + +def nearest_hint(w, h): + """Suggest the closest official screenshot size with the same orientation.""" + best = None + portrait = h >= w + for group, label, sizes in SCREENSHOTS: + for pw, ph in sizes: + cw, ch = (pw, ph) if portrait else (ph, pw) + d = abs(cw - w) + abs(ch - h) + if best is None or d < best[0]: + best = (d, cw, ch, label) + if best: + return f"closest screenshot size is {best[1]}×{best[2]} ({best[3]})" + return "see references/specs.md" + + +def check_video(path, report): + ext = os.path.splitext(path)[1].lower() + item = report.add(file=path, kind="video") + size = os.path.getsize(path) + probe = ffprobe(path) + if size > MAX_BYTES: + item["issues"].append(("BLOCKER", f"File is {size / 1024 / 1024:.0f} MB — max 500 MB")) + if probe is None: + item["issues"].append(("WARNING", "ffprobe not available/failed — install ffmpeg (brew install ffmpeg) " + "for duration/fps/codec/audio checks")) + return + vstreams = [s for s in probe.get("streams", []) if s.get("codec_type") == "video"] + astreams = [s for s in probe.get("streams", []) if s.get("codec_type") == "audio"] + if not vstreams: + item["issues"].append(("BLOCKER", "No video stream found")) + return + v = vstreams[0] + w, h = int(v.get("width", 0)), int(v.get("height", 0)) + rot = 0 + for sd in v.get("side_data_list", []) or []: + if "rotation" in sd: + rot = abs(int(sd["rotation"])) % 180 + if str((v.get("tags") or {}).get("rotate", "0")) in ("90", "270"): + rot = 90 + if rot == 90: + w, h = h, w + item["issues"].append(("WARNING", "Video relies on a rotation flag — re-encode with the pixels in the final " + "orientation to be safe")) + fps = parse_rate(v.get("avg_frame_rate")) or parse_rate(v.get("r_frame_rate")) + dur = float(probe.get("format", {}).get("duration") or v.get("duration") or 0) + codec = v.get("codec_name", "?") + profile = v.get("profile", "") + level = v.get("level") + bitrate = int(probe.get("format", {}).get("bit_rate") or 0) + item["info"] = {"width": w, "height": h, "fps": round(fps, 3), "duration": round(dur, 2), "codec": codec, + "profile": profile, "level": level, "bitrate_mbps": round(bitrate / 1e6, 2), + "audio": [f"{a.get('codec_name')} {a.get('channels')}ch {a.get('sample_rate')}Hz" + for a in astreams], "size_mb": round(size / 1024 / 1024, 1)} + + preview_label = PREVIEW_SIZES.get((w, h)) + creative = creative_video_matches(w, h) + item["matches"] = ([preview_label] if preview_label else []) + [c[1] for c in creative] + if not item["matches"]: + item["issues"].append(("BLOCKER", f"{w}×{h} isn't an accepted app preview, creative asset or event video " + "size — iPhone previews must be 886×1920 (or 1920×886)")) + return + + if preview_label: + p = [] + if ext not in VIDEO_EXT: + p.append(("BLOCKER", f"{ext} not accepted — use .mov, .m4v or .mp4")) + if codec not in ("h264", "prores"): + p.append(("BLOCKER", f"Codec {codec} — app previews must be H.264 or ProRes 422 (HQ)")) + if codec == "prores" and ext != ".mov": + p.append(("BLOCKER", "ProRes previews must be .mov")) + if codec == "h264": + if profile and profile.lower() not in ("high", "main", "baseline", "constrained baseline"): + p.append(("BLOCKER", f"H.264 profile '{profile}' — use High Profile, level ≤ 4.0")) + if level and int(level) > 40: + p.append(("BLOCKER", f"H.264 level {int(level) / 10:.1f} — max High Profile Level 4.0")) + if bitrate and not (8e6 <= bitrate <= 14e6): + p.append(("WARNING", f"Bit rate {bitrate / 1e6:.1f} Mbps — Apple targets 10–12 Mbps for H.264")) + if fps > 30.01: + p.append(("BLOCKER", f"{fps:.2f} fps — app previews max 30 fps")) + if dur < 15: + p.append(("BLOCKER", f"{dur:.2f} s — app previews must be at least 15 s")) + if dur > 30.0: + p.append(("BLOCKER", f"{dur:.2f} s — app previews must be at most 30 s (trim with -t 30)")) + if not astreams: + p.append(("WARNING", "No audio track — spec lists stereo AAC 256 kbps; add a silent stereo track to be safe " + "(see references/production.md)")) + for a in astreams: + if a.get("codec_name") not in ("aac", "pcm_s16le", "pcm_s24le", "pcm_s32le", "pcm_s16be", "pcm_s24be"): + p.append(("BLOCKER", f"Audio codec {a.get('codec_name')} — use AAC 256 kbps (or PCM for ProRes)")) + if a.get("sample_rate") not in ("44100", "48000"): + p.append(("BLOCKER", f"Audio sample rate {a.get('sample_rate')} Hz — use 44.1 or 48 kHz")) + if int(a.get("channels") or 0) not in (1, 2) or (len(astreams) == 1 and int(a.get("channels") or 0) != 2): + p.append(("BLOCKER", "Audio must be stereo (1 track × 2 ch, or 2 tracks × 1 ch)")) + if p: + item["issues"] += [(s, f"[preview] {m}") for s, m in p] + + for _group, label, dmin, dmax in creative: + c = [] + if ext not in VIDEO_EXT: + c.append(("BLOCKER", f"{ext} not accepted — use .mov, .m4v or .mp4")) + if not (abs(fps - 30) < 0.05 or abs(fps - 60) < 0.05): + c.append(("BLOCKER", f"{fps:.2f} fps — must be 30 or 60 fps")) + if dur < dmin or dur > dmax: + c.append(("BLOCKER", f"{dur:.2f} s — must be {dmin}–{dmax} s")) + if c: + short = label.split(" — ")[-1] if " — " in label else label + item["issues"] += [(s, f"[{short}] {m}") for s, m in c] + + +def coverage(report, ipad): + groups = defaultdict(int) + for it in report.items: + for g in it.get("groups", []) or []: + groups[g] += 1 + notes = [] + if not groups: + return notes, {} + has_iphone = any(g.startswith("iphone") for g in groups) + if has_iphone: + if not groups.get("iphone_di_medium"): + notes.append(("BLOCKER", + 'No iPhone 6.1" Dynamic Island (medium) screenshot (1206×2622 or 1179×2556) — App Store ' + 'Connect: "To submit your app, you must provide screenshots for … iPhone with Dynamic ' + 'Island (medium display)"')) + if not groups.get("iphone_di_large") and not groups.get("iphone_faceid_large"): + notes.append(("BLOCKER", + 'No 6.9" (1320×2868) or 6.5" (1284×2778 / 1242×2688) set — Face ID (large) is required ' + "when Dynamic Island (large) isn't provided")) + if not groups.get("iphone_di_large"): + notes.append(("TIP", 'Add a 6.9" set (1320×2868) — it\'s the largest iPhone size and the best master')) + if ipad and not groups.get("ipad_13"): + notes.append(("BLOCKER", 'App runs on iPad but no iPad 13" screenshot (2064×2752 or 2048×2732)')) + for g, n in groups.items(): + if n > 10: + notes.append(("WARNING", f"{n} screenshots match group '{g}' — max 10 per device size per language " + "(fine if they're for different localizations)")) + return notes, dict(groups) + + +def collect(paths): + files = [] + for p in paths: + if os.path.isdir(p): + for root, _dirs, names in os.walk(p): + for n in sorted(names): + if n.startswith("."): + continue + ext = os.path.splitext(n)[1].lower() + if ext in IMAGE_EXT | VIDEO_EXT | OTHER_IMAGE_EXT: + files.append(os.path.join(root, n)) + elif os.path.isfile(p): + files.append(p) + else: + print(f"warning: {p} not found", file=sys.stderr) + return files + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("paths", nargs="+") + ap.add_argument("--ipad", action="store_true", help="the app runs on iPad (13\" set required)") + ap.add_argument("--json", action="store_true", help="machine-readable output") + args = ap.parse_args() + + report = Report() + for f in collect(args.paths): + ext = os.path.splitext(f)[1].lower() + if ext in VIDEO_EXT: + check_video(f, report) + else: + check_image(f, report) + notes, groups = coverage(report, args.ipad) + + blockers = sum(1 for it in report.items for s, _ in it["issues"] if s == "BLOCKER") + \ + sum(1 for s, _ in notes if s == "BLOCKER") + + if args.json: + print(json.dumps({"files": report.items, "coverage": [{"severity": s, "message": m} for s, m in notes], + "groups": groups, "blockers": blockers}, indent=2, default=str)) + sys.exit(1 if blockers else 0) + + if not report.items: + print("No .png/.jpg/.jpeg/.mov/.m4v/.mp4 files found.") + sys.exit(0) + for it in report.items: + info = it.get("info") or {} + dims = f"{info.get('width')}×{info.get('height')}" if info.get("width") else "?" + extra = "" + if it["kind"] == "video" and info: + extra = f" · {info['duration']}s · {info['fps']}fps · {info['codec']} · {info['bitrate_mbps']}Mbps · " \ + f"audio: {', '.join(info['audio']) or 'none'} · {info['size_mb']}MB" + elif info: + extra = f" · {info['format']} {info['mode']}" + status = "✗" if any(s == "BLOCKER" for s, _ in it["issues"]) else ("!" if it["issues"] else "✓") + print(f"{status} {it['file']} [{dims}{extra}]") + for m in it["matches"]: + print(f" fits: {m}") + for s, m in it["issues"]: + print(f" {s}: {m}") + print("\nRequired screenshot coverage:") + if not groups: + print(" (no screenshots among these files — coverage not checked)") + elif not notes: + print(" ✓ all required sizes present") + for s, m in notes: + print(f" {s}: {m}") + print(f"\n{blockers} blocker(s).") + sys.exit(1 if blockers else 0) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/app-store-assets/scripts/check_metadata.py b/.claude/skills/app-store-assets/scripts/check_metadata.py new file mode 100755 index 0000000..5787b8c --- /dev/null +++ b/.claude/skills/app-store-assets/scripts/check_metadata.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +"""Lint App Store text metadata against Apple's limits and review rules (Oct 2026). + +Usage: + python3 check_metadata.py metadata.json [--json] [--banned "brand1,brand2"] + python3 check_metadata.py --template > metadata.json + +Input: one localization object, or {"localizations": [obj, obj, ...]}. +Every field is optional — only the ones present are checked. + + { + "locale": "en-US", + "company_name": "Lucas", # developer/seller name (already searchable) + "category": "Food & Drink", + "kids_category": false, + "has_iap": true, # app has IAP/subscriptions → disclosure check + "name": "...", # 2–30 chars + "subtitle": "...", # ≤ 30 + "promotional_text": "...", # ≤ 170 + "description": "...", # ≤ 4000, plain text + "keywords": "a,b,c", # ≤ 100 BYTES, comma-separated + "whats_new": "...", # ≤ 4000 + "copyright": "2026 Lucas", + "screenshot_captions": ["...", "..."], # text overlaid on screenshots / creative assets + "iap": [{"name": "...", "description": "..."}], # 2–30 / ≤ 45 + "events": [{"name": "...", "short_description": "...", "long_description": "..."}] # 30/50/120 + } + +Severities: BLOCKER (App Store Connect limit or explicit App Review rule), +WARNING (Apple best practice / likely flag), TIP (optimisation). Rules marked +"heuristic" are common practice, not written Apple policy. + +Exit code: 1 if any BLOCKER, else 0. No third-party dependencies. +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +import unicodedata + +LIMITS = { # field: (min, max, unit) + "name": (2, 30, "chars"), + "subtitle": (0, 30, "chars"), + "promotional_text": (0, 170, "chars"), + "description": (0, 4000, "chars"), + "keywords": (0, 100, "bytes"), + "whats_new": (0, 4000, "chars"), +} +IAP_LIMITS = {"name": (2, 30), "description": (0, 45)} +EVENT_LIMITS = {"name": 30, "short_description": 50, "long_description": 120} + +CATEGORIES = { + "books", "business", "developer tools", "education", "entertainment", "finance", "food & drink", "food", + "drink", "games", "graphics & design", "health & fitness", "health", "fitness", "lifestyle", "kids", + "magazines & newspapers", "medical", "music", "navigation", "news", "photo & video", "photo", "video", + "productivity", "reference", "shopping", "social networking", "social", "sports", "travel", "utilities", + "weather", "stickers", +} +OTHER_PLATFORMS = [ + r"android", r"google\s*play", r"play\s*store", r"galaxy\s*store", r"app\s*gallery", r"huawei", + r"windows\s*phone", r"\bapk\b", r"amazon\s*appstore", r"aptoide", r"f-?droid", +] +APPLE_AWARDS = [ + r"editor'?s'?\s*choice", r"app\s*of\s*the\s*day", r"game\s*of\s*the\s*day", r"apple\s*design\s*award", + r"featured\s*by\s*apple", r"approved\s*by\s*apple", r"apple[-\s]*(?:recommended|endorsed|certified)", +] +SUPERLATIVES = [ + r"#\s*1\b", r"\bno\.?\s*1\b", r"\bnumber\s*one\b", r"\bbest\b", r"\btop[-\s]*rated\b", r"\bworld'?s\b", + r"\bleading\b", r"\bmost\s+popular\b", r"\baward[-\s]*winning\b", r"\b(?:melhor|mejor|meilleur|beste?)\b", + r"\bn\.?º\s*1\b", +] +PRICE = [r"[€$£¥₹]\s*\d+(?:[.,]\d+)?", r"\d+(?:[.,]\d+)?\s*[€$£¥₹]", r"\b\d+(?:[.,]\d+)?\s*(?:usd|eur|gbp|euros?|dollars?)\b", + r"\d+\s*%\s*(?:off|desconto|discount)"] +DISCOUNT = [r"\b(?:discount|on sale|desconto|promo[cç][aã]o|half[-\s]price)\b"] +FREE = [r"\bfree\b", r"\bgr[aá]tis\b", r"\bgratuit[ao]?\b", r"\bkostenlos\b"] +KIDS = [r"\bfor\s+kids\b", r"\bfor\s+children\b", r"\bpara\s+crian[cç]as\b", r"\bpara\s+mi[uú]dos\b"] +URL = [r"https?://", r"\bwww\.", r"\b[\w-]+\.(?:com|net|org|io|app|pt|co|dev)\b"] +DEFAULT_BRANDS = [ + "uber eats", "ubereats", "glovo", "deliveroo", "just eat", "bolt food", "doordash", "grubhub", "tripadvisor", + "yelp", "google maps", "waze", "instagram", "tiktok", "facebook", "whatsapp", "snapchat", "youtube", "netflix", + "spotify", "chatgpt", "openai", "airbnb", "booking.com", "strava", "duolingo", "candy crush", "minecraft", + "fortnite", "pokemon", "mcdonalds", "mcdonald's", "starbucks", +] +IAP_WORDS = re.compile( + r"in-app purchase|purchase|subscription|subscribe|premium|\bpro\b|unlock|upgrade|paid|" + r"compra|subscri[cç][aã]o|assinatura|desbloque", re.I) +GENERIC_WHATS_NEW = re.compile( + r"^\W*(bug\s*fixes|improvements|performance|minor\s*fixes|stability|corre[cç][oõ]es|melhorias)", re.I) +EMOJI = re.compile("[\U0001F000-\U0001FAFF☀-➿️]") +ACRONYMS = {"GPS", "AR", "VR", "USA", "NBA", "NFL", "PDF", "API", "FAQ", "HD", "4K", "UEFA", "FIFA", "BBQ", "DIY"} +CTA_START = re.compile(r"^\s*(watch|join|play|download|get|tap|click|try|start|register|sign\s*up|vem|joga|" + r"descarrega|junta-te|participa)\b", re.I) +EVENT_BADGES = re.compile(r"\b(challenge|competition|live event|major update|new season|premiere|special event)\b", + re.I) + +TEMPLATE = { + "localizations": [ + { + "locale": "en-US", + "company_name": "Your Name", + "category": "Food & Drink", + "kids_category": False, + "has_iap": False, + "name": "", + "subtitle": "", + "promotional_text": "", + "description": "", + "keywords": "", + "whats_new": "", + "copyright": "2026 Your Name", + "screenshot_captions": [], + "iap": [], + "events": [], + } + ] +} + + +class Findings: + def __init__(self, locale): + self.locale = locale + self.rows = [] + + def add(self, sev, field, msg, rule=""): + self.rows.append({"locale": self.locale, "severity": sev, "field": field, "message": msg, "rule": rule}) + + +def length(text, unit): + return len(text.encode("utf-8")) if unit == "bytes" else len(text) + + +def find(patterns, text): + hits = [] + for p in patterns: + for m in re.finditer(p, text, re.I): + hits.append(m.group(0).strip()) + return sorted(set(hits), key=str.lower) + + +def words(text): + norm = unicodedata.normalize("NFKC", text.lower()) + return [w for w in re.split(r"[^\w'-]+", norm) if w] + + +def check_content(f: Findings, field, text, *, visual=False, kids_ok=False, banned=(), event=False): + """Rules that apply to any piece of customer-facing text.""" + strict = field in ("name", "subtitle", "keywords") or visual # 2.3.7: no prices/terms in these + prominent = strict or event or field == "promotional_text" + if hits := find(OTHER_PLATFORMS, text): + f.add("BLOCKER", field, f"Mentions another platform/marketplace: {', '.join(hits)}", "2.3.10") + if hits := find(APPLE_AWARDS, text): + f.add("BLOCKER", field, f"Apple award/endorsement wording: {', '.join(hits)}", "5.2.4 / asset best practices") + if strict and (hits := find(DISCOUNT, text)): + f.add("BLOCKER", field, f"Discount/sale term: {', '.join(hits)}", "2.3.7 / asset best practices") + if hits := find(PRICE, text): + if strict or event: + f.add("BLOCKER", field, f"Price/discount term: {', '.join(hits)}", "2.3.7") + elif field == "promotional_text": + f.add("WARNING", field, f"Specific price/discount: {', '.join(hits)} — mention a sale without an amount; " + "prices vary by storefront", "product page guidance") + else: + f.add("WARNING", field, f"Specific price/discount: {', '.join(hits)} — Apple asks you not to include " + "prices (they vary per region)", "product page guidance") + if strict and (hits := find(FREE, text)): + f.add("WARNING", field, f"Pricing term '{hits[0]}' in {field} — pricing information isn't allowed here", + "2.3.7") + if hits := find(SUPERLATIVES, text): + sev = "WARNING" if prominent else "TIP" + f.add(sev, field, f"Unverifiable claim: {', '.join(hits)} — only state what you can prove", + "2.3.7 / asset best practices") + if not kids_ok and (hits := find(KIDS, text)): + f.add("WARNING", field, f"'{hits[0]}' is reserved for the Kids category", "2.3.8 / 5.1.4") + if (strict or event) and (hits := find(URL, text)): + f.add("WARNING" if not visual else "BLOCKER", field, f"URL in {field}: {', '.join(hits)}", + "asset best practices" if visual else "2.3.7") + if visual and re.search(r"©|\(c\)", text, re.I): + f.add("WARNING", field, "Copyright symbol in a visual asset", "asset best practices") + low = text.lower() + for b in banned: + if re.search(r"(? hi: + f.add("BLOCKER", field, f"{n} {unit} — max {hi}", "App Store Connect limit") + elif lo and n < lo: + f.add("BLOCKER", field, f"{n} {unit} — min {lo}", "App Store Connect limit") + return n + + +def check_keywords(f: Findings, kw, loc, banned): + if not kw: + return + if re.search(r",\s", kw): + f.add("WARNING", "keywords", "Spaces after commas waste bytes — use 'a,b,c'", "product page guidance") + terms = [t.strip() for t in kw.split(",")] + if any(t == "" for t in terms): + f.add("WARNING", "keywords", "Empty term (double or trailing comma)", "product page guidance") + terms = [t for t in terms if t] + lower = [t.lower() for t in terms] + for t in terms: + if len(t) <= 2: + f.add("WARNING", "keywords", f"'{t}' — each keyword must be longer than two characters", + "App Store Connect") + if re.search(r"[#@]", t): + f.add("WARNING", "keywords", f"'{t}' — special characters carry no extra weight", "product page guidance") + dups = sorted({t for t in lower if lower.count(t) > 1}) + if dups: + f.add("WARNING", "keywords", f"Duplicate terms: {', '.join(dups)}", "product page guidance") + kw_words = [] + for t in lower: + kw_words += words(t) + seen = set() + rep = sorted({w for w in kw_words if w in seen or seen.add(w)}) + if rep: + f.add("TIP", "keywords", f"Words repeated across phrases: {', '.join(rep)} — single words combine with each " + "other and with the name/subtitle, so each word only needs to appear once " + "(heuristic)", "ASO practice") + indexed = set(words(loc.get("name", ""))) | set(words(loc.get("subtitle", ""))) | \ + set(words(loc.get("company_name", ""))) + wasted = sorted({w for w in kw_words if w in indexed and len(w) > 2}) + if wasted: + f.add("WARNING", "keywords", f"Already in name/subtitle/company name: {', '.join(wasted)} — already " + "searchable, remove to free bytes", "App Store Connect / product page guidance") + if "app" in kw_words or "apps" in kw_words: + f.add("WARNING", "keywords", "Don't use the word 'app'", "product page guidance") + cats = sorted({t for t in lower if t in CATEGORIES}) + if cats: + f.add("WARNING", "keywords", f"Category names: {', '.join(cats)} — don't use them", "product page guidance") + wset = set(kw_words) + plurals = sorted({w for w in wset if len(w) > 3 and w.endswith("s") and w[:-1] in wset}) + if plurals: + f.add("WARNING", "keywords", f"Plural of a word already present: {', '.join(plurals)}", + "product page guidance") + joined = " , ".join(lower) + for b in banned: + if re.search(r"(?]*>", desc, re.I): + f.add("BLOCKER", "description", "HTML tags — the description is plain text only", "App Store Connect") + first = re.split(r"(?<=[.!?])\s|\n", desc.strip(), maxsplit=1)[0] if desc.strip() else "" + if first and len(first) > 170: + f.add("TIP", "description", f"First sentence is {len(first)} chars — it's what people read before " + "'more'; make it short and specific", "product page guidance") + if loc.get("has_iap") and not IAP_WORDS.search(desc): + f.add("WARNING", "description", "App has IAP/subscriptions but the description never says which features " + "require a purchase", "2.3.2") + wn = loc.get("whats_new") + if wn is not None and GENERIC_WHATS_NEW.search(wn) and len(wn) < 80: + f.add("TIP", "whats_new", "Generic text is only OK for bug-fix/performance releases — list real " + "features if this version adds any", "2.3.12") + cr = loc.get("copyright") + if cr and re.search(r"©|\(c\)", cr, re.I): + f.add("WARNING", "copyright", "Remove '©' — App Store Connect adds it automatically", "App Store Connect") + + for i, cap in enumerate(loc.get("screenshot_captions") or [], 1): + field = f"caption {i}" + check_content(f, field, cap, visual=True, kids_ok=kids_ok, banned=banned) + if len(cap.split()) > 8: + f.add("TIP", field, f"{len(cap.split())} words — keep captions short (≤ ~6 words) so they read at " + "thumbnail size (heuristic)", "asset best practices") + if "!!" in cap or re.search(r"\b[A-Z]{5,}\b", cap): + f.add("TIP", field, "Shouting (caps/!!) reads as an ad, not as the app", "style") + + for i, iap in enumerate(loc.get("iap") or [], 1): + for key, (lo, hi) in IAP_LIMITS.items(): + text = iap.get(key) + if text is None: + continue + n = len(text) + if n > hi or n < lo: + f.add("BLOCKER", f"iap {i} {key}", f"{n} chars — must be {lo}–{hi}" if lo else f"{n} chars — max {hi}", + "App Store Connect limit") + check_content(f, f"iap {i} {key}", text, kids_ok=kids_ok, banned=banned) + name = iap.get("name") or "" + if re.fullmatch(r"\s*\d+\s+\w+\s*", name): + f.add("TIP", f"iap {i} name", f"'{name}' is generic — tie it to your app (e.g. 'Treasure Chest of 100 " + "Gems')", "Promoting IAPs") + + for i, ev in enumerate(loc.get("events") or [], 1): + for key, hi in EVENT_LIMITS.items(): + text = ev.get(key) + if text is None: + continue + if len(text) > hi: + f.add("BLOCKER", f"event {i} {key}", f"{len(text)} chars — max {hi}", "App Store Connect limit") + check_content(f, f"event {i} {key}", text, kids_ok=kids_ok, banned=banned, event=True) + if "!!" in text or re.search(r"\b[A-Z]{4,}\b", text): + f.add("WARNING", f"event {i} {key}", "No all-caps or repeated punctuation", "In-App Events guidance") + name = ev.get("name") or "" + if CTA_START.search(name): + f.add("WARNING", f"event {i} name", "Event name reads as a call to action — use the event's actual name", + "In-App Events guidance") + if EVENT_BADGES.search(name): + f.add("TIP", f"event {i} name", "Repeats the badge type — the badge already shows it", + "In-App Events guidance") + return f.rows, counts + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("file", nargs="?") + ap.add_argument("--json", action="store_true") + ap.add_argument("--template", action="store_true", help="print a starter metadata.json") + ap.add_argument("--banned", default="", help="extra comma-separated competitor/brand names to flag") + ap.add_argument("--no-default-brands", action="store_true", help="don't flag the built-in brand list") + args = ap.parse_args() + + if args.template: + print(json.dumps(TEMPLATE, indent=2, ensure_ascii=False)) + return + if not args.file: + ap.error("metadata.json required (or --template)") + with open(args.file, encoding="utf-8") as fh: + data = json.load(fh) + locs = data.get("localizations", [data]) if isinstance(data, dict) else data + banned = [] if args.no_default_brands else list(DEFAULT_BRANDS) + banned += [b.strip() for b in args.banned.split(",") if b.strip()] + + all_rows, all_counts = [], {} + for loc in locs: + rows, counts = lint(loc, banned) + all_rows += rows + all_counts[loc.get("locale", "default")] = counts + + order = {"BLOCKER": 0, "WARNING": 1, "TIP": 2} + all_rows.sort(key=lambda r: (r["locale"], order[r["severity"]])) + blockers = sum(r["severity"] == "BLOCKER" for r in all_rows) + + if args.json: + print(json.dumps({"counts": all_counts, "findings": all_rows, "blockers": blockers}, indent=2, + ensure_ascii=False)) + sys.exit(1 if blockers else 0) + + for locale, counts in all_counts.items(): + print(f"== {locale}") + for field, n in counts.items(): + lo, hi, unit = LIMITS[field] + mark = "✗" if n > hi or (lo and n < lo) else "✓" + print(f" {mark} {field:<17} {n:>4}/{hi} {unit}") + rows = [r for r in all_rows if r["locale"] == locale] + if not rows: + print(" No issues found.") + for r in rows: + rule = f" [{r['rule']}]" if r["rule"] else "" + print(f" {r['severity']:<7} {r['field']}: {r['message']}{rule}") + print() + print(f"{blockers} blocker(s), {sum(r['severity'] == 'WARNING' for r in all_rows)} warning(s), " + f"{sum(r['severity'] == 'TIP' for r in all_rows)} tip(s).") + sys.exit(1 if blockers else 0) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 21ae770..a172434 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -1,7 +1,4 @@ -# This workflow will build a Swift project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift - -name: Swift +name: CI on: push: @@ -10,13 +7,31 @@ on: branches: [ "main" ] jobs: - build: - + # ─── iOS build ────────────────────────────────────────────────────────────── + # `swift build` needs a Package.swift; this is an .xcodeproj, so build with + # xcodebuild (ported from the fix in PR #1, iOS job only). + ios-build: + name: iOS Build runs-on: macos-latest steps: - - uses: actions/checkout@v4 - - name: Build - run: swift build -v - - name: Run tests - run: swift test -v + - uses: actions/checkout@v4 + + - name: Resolve Swift Package dependencies + run: | + xcodebuild \ + -resolvePackageDependencies \ + -project KebabLocator.xcodeproj \ + -scheme KebabLocator + + - name: Build for iOS Simulator + run: | + xcodebuild \ + -project KebabLocator.xcodeproj \ + -scheme KebabLocator \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/docs/APP_STORE_CHECKLIST.md b/docs/APP_STORE_CHECKLIST.md index 318be68..29ff21f 100644 --- a/docs/APP_STORE_CHECKLIST.md +++ b/docs/APP_STORE_CHECKLIST.md @@ -40,7 +40,7 @@ Bundle ID: `com.lucas.KebabLocator2` | Version: 1.0 | Build: 1 | Keywords | ⬜ Preencher | Ver metadata | | URL de Suporte | ⬜ Precisas de URL | Ex: GitHub Issues ou email | | URL de Privacidade | ⬜ OBRIGATÓRIO | Criar página de privacidade (ver nota abaixo) | -| Screenshots iPhone 6.5" | ⬜ Fazer | 1242×2688px — min 1, ideal 3-5 | +| Screenshots iPhone 6.9" + 6.1" + iPad 13" | ⬜ Fazer | 1320×2868, 1206×2622, 2064×2752 — ver `APP_STORE_METADATA.md` | | Ícone 1024×1024 PNG | ⬜ Verificar | Sem transparência, sem arredondamento | | Copyright | ⬜ Preencher | `© 2026 Lucas` | diff --git a/docs/APP_STORE_METADATA.md b/docs/APP_STORE_METADATA.md index 5e65e38..ebef7b0 100644 --- a/docs/APP_STORE_METADATA.md +++ b/docs/APP_STORE_METADATA.md @@ -45,10 +45,17 @@ Business owners can claim and manage their listings directly in the app. Free to use. Supported by non-intrusive ads. ``` -### Palavras-chave (100 chars — separadas por vírgula, sem espaços após vírgula) +### Palavras-chave (100 bytes — separadas por vírgula, sem espaços após vírgula) ``` -kebab,kebab near me,kebab restaurant,food finder,shawarma,doner,halal food,food map,restaurant finder +doner,shawarma,falafel,durum,gyro,halal,takeaway,turkish,restaurant,map,nearby,wrap,pita,lebanese ``` +**97/100 bytes** (validado com `.claude/skills/app-store-assets/scripts/check_metadata.py`). + +Porquê assim: +- Não repete palavras do nome/subtítulo (`kebab`, `locator`, `find`, `shops`, `near`, `you`) nem o nome do developer — já são pesquisáveis. +- Sem `food`: faz parte da categoria (Food & Drink), e a Apple pede para não usar nomes de categorias. +- Palavras soltas em vez de frases: combinam com o nome e o subtítulo ("kebab" + "doner" → "doner kebab", "shawarma" + "near" → "shawarma near"). +- Limite em **bytes** UTF-8: evitar acentos (`döner` gasta 6 bytes, `doner` gasta 5). ### URL de Suporte ``` @@ -77,19 +84,59 @@ https://solderg.github.io/KebabLocator-iOS/privacy-policy.html --- -## Screenshots — iPhone 6.5" (1242×2688px) +## Screenshots + +A app corre em iPhone **e iPad** (`TARGETED_DEVICE_FAMILY = "1,2"`), por isso são precisos 3 conjuntos, todos em **portrait**: + +| Conjunto | Tamanho exato | Simulador | Obrigatório? | +|----------|---------------|-----------|--------------| +| iPhone 6.9" | **1320×2868** | iPhone 17 Pro Max | Sim (ou 6.5"); serve de base para os iPhones grandes | +| iPhone 6.1" | **1206×2622** | iPhone 17 | Sim; é o tamanho de iPhone que o App Store Connect exige | +| iPad 13" | **2064×2752** | iPad Pro 13-inch (M5) | Sim, porque a app corre em iPad | + +Regras: 1–10 por tamanho, `.png`/`.jpg`, **sem transparência**, tamanho em píxeis exato (o simulador já os gera certos; não redimensionar). Os 3 primeiros aparecem nos resultados de pesquisa, por isso são os que vendem a app. + +### Ordem e legendas (en-US) + +| # | Ecrã | Legenda (en-US) | O que mostra | +|---|------|-----------------|--------------| +| 1 | HomeView (lista "Nearest") | Find kebabs around you | Valor principal: kebabs mais perto, com distância | +| 2 | MapTabView | Every spot on one map | Mapa com vários pins na zona | +| 3 | ShopDetailView | Hours, ratings and directions | Horário, rating, "Get Directions" e "Call" | +| 4 | ExploreView | Search and filter by distance | Pesquisa e filtro de raio (km) | +| 5 | FavoritesView (Saved) | Save the spots you love | 3–4 lojas guardadas | +| 6 | AddKebabView | Add a spot that's missing | Contribuição da comunidade | + +Legendas validadas: sem preços, sem URLs, sem "best"/"#1", sem outras plataformas. -Tirar no simulador **iPhone 14 Plus** ou **iPhone 11 Pro Max**: +### Antes de capturar -| # | Ecrã | Descrição sugerida | -|---|------|--------------------| -| 1 | HomeView | "Kebabs perto de ti" com lista | -| 2 | MapTabView | Mapa com pins de kebabs | -| 3 | ExploreView | Pesquisa e filtros | -| 4 | ShopDetailView | Detalhe de uma loja | -| 5 | FavoritesView | Lista de favoritos | +- **Não usar o HeroView**: mostra "🥙 #1 Kebab Finder" e "Best Kebabs". São afirmações não verificáveis (Guideline 2.3.7) e é um ecrã de entrada, não a app em uso (2.3.3). +- **Esconder os banners AdMob**: anúncios reais mostram conteúdo de terceiros sobre o qual não tens direitos (2.3.9), que pode incluir logos de Google Play/Android (2.3.10). +- **Dados de lojas**: escolhe uma zona com várias lojas e nomes reais e limpos, sem fotos de terceiros que não possas usar. +- Dark mode está forçado na app, logo todos os screenshots ficam escuros e consistentes. + +### Como tirar + +```bash +# 1. Arrancar o simulador certo e pôr a barra de estado "limpa" (Wi-Fi e bateria cheios, 9:41) +xcrun simctl boot "iPhone 17 Pro Max" +xcrun simctl status_bar booted override --time "9:41" --dataNetwork wifi --wifiMode active --wifiBars 3 \ + --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100 + +# 2. Localização com várias lojas (ex.: Lisboa centro) +xcrun simctl location booted set 38.7223,-9.1393 + +# 3. Navegar até cada ecrã e capturar +xcrun simctl io booted screenshot --type=png "iphone69-01-home.png" + +# 4. Repetir para "iPhone 17" (6.1") e "iPad Pro 13-inch (M5)" + +# 5. Validar tudo antes de fazer upload +python3 .claude/skills/app-store-assets/scripts/check_assets.py AppStoreScreenshots/ --ipad +``` -**Como tirar:** Xcode → Simulator → File → Take Screenshot (⌘S) +Se adicionares legendas num editor (Figma, Pixelmator…), cria o canvas logo no tamanho final (1320×2868, 1206×2622, 2064×2752) e exporta sem transparência. ---