Imported from vagabond911/aso-auditor (
SKILL.md). Install upstream withnpx skills add vagabond911/aso-auditor. Copyright stays with the author.
ASO Scoring Checklist Auditor
A structured, production-grade ASO audit skill built on a 7-pillar, 155-point scoring framework. Outputs an interactive MDX report, an opportunities table, a 90-day roadmap, and a keyword testing lab for every audit session.
1. SKILL METADATA
| Field | Value |
|---|---|
| Name | aso-auditor |
| Version | 1.0.0 |
| Trigger | Fires on any App Store or Google Play URL paste, or on phrases: "ASO audit", "app store score", "optimize app listing", "screenshot review", "keyword field", "app metadata", "Play Store ranking", "app conversion rate" |
| Platforms covered | iOS App Store + Google Play Store |
| Max score | 155 pts โ normalized to 0โ100% Optimization Grade |
| Output format | Interactive MDX file deployable in Next.js 14 + @next/mdx |
2. INPUT SCHEMA
Required inputs
| Field | Format | Example |
|---|---|---|
ios_url |
Full App Store URL | https://apps.apple.com/us/app/example/id123456789 |
play_url |
Full Play Store URL | https://play.google.com/store/apps/details?id=com.example.app |
app_category |
Text string | Finance, Productivity, Health & Fitness |
Optional inputs
| Field | Format | Notes |
|---|---|---|
asc_export |
CSV or JSON from App Store Connect | Keyword ranking data, impressions, CVR |
play_export |
CSV or JSON from Google Play Console | Search terms report, install funnel |
datai_export |
CSV or JSON from Data.ai / App Annie | Market share, category ranking |
competitor_urls |
Comma-separated URLs | Up to 3 competitor listings for gap table |
Validation rules
- Only one platform URL provided: Score available platform only. Flag missing platform. Output message: "โ ๏ธ Only [iOS / Android] URL provided. Android / iOS score will be marked N/A. Provide the second URL to enable platform delta analysis."
- URL inaccessible / 404: Attempt
web_fetchtwice. If both fail, prompt user to paste raw listing text (title, subtitle, description, screenshot count, rating). - Notion knowledge base JS-blocked: Fall back to embedded ruleset in this file. Do not halt the audit.
- No competitor URLs: Skip Section 11 competitor table. Insert placeholder row: "No competitor URLs provided โ run competitor comparison to unlock gap analysis."
3. PRE-AUDIT RESEARCH PROTOCOL
Execute in this exact sequence at the start of every audit session.
Step 3.0 โ URL normalisation (do this before any fetch)
Google Play and the App Store both serve JS-heavy pages that block desktop crawlers. Always rewrite URLs to their mobile-friendly forms before fetching.
Android โ append locale params:
Original: https://play.google.com/store/apps/details?id=com.example.app
Rewritten: https://play.google.com/store/apps/details?id=com.example.app&hl=en&gl=us
iOS โ append country and language params:
Original: https://apps.apple.com/us/app/example/id123456789
Rewritten: https://apps.apple.com/us/app/example/id123456789?l=en
Use the rewritten URLs for all web_fetch calls in Steps 3.1 and 3.2.
Step 3.1 โ Fetch live listing: iOS
web_fetch: {ios_url_rewritten}
Extract and log in the Metadata Extraction Table (see below):
- App title (count chars)
- Subtitle (count chars)
- Promotional text (count chars, note: not indexed)
- Description first 170 chars (above-fold preview)
- Full description (char count)
- Keyword field (not visible; note "not public-facing")
- Screenshot count
- Screenshot orientations (portrait / landscape)
- App preview video present? โ see critical note below
- Overall rating (X.X / 5)
- Total review count
- Developer response rate (sample last 10 reviews)
โ CRITICAL โ iOS preview video is NEVER detectable from a crawler fetch.
The App Store serves preview videos as dynamically generated blob: URLs
(src="blob:https://apps.apple.com/..."). These are resolved in the browser session
and are invisible to web_fetch. The crawler will always return no video element,
regardless of whether a video exists on the listing.
Rule: Never mark iOS criterion 6.1 as fail based on a crawler result alone. Always ask the user to confirm before scoring Pillar 6.
The correct protocol is:
- After fetching, note "iOS preview video โ cannot verify via crawler"
- Ask the user directly: "Does your iOS App Store listing have an app preview video? You can check by visiting your listing on a device, or in App Store Connect under the relevant version's media assets."
- Score criterion 6.1 based on the user's answer only.
- If the user cannot confirm, mark as
[UNVERIFIED โ ask user before scoring]and do not auto-fail.
If the fetch returns a JS-block error or yields < 3 auditable fields: do not halt. Proceed immediately to Step 3.1a.
Step 3.1a โ iOS manual input fallback
If the automated fetch fails or any of the following fields are missing, prompt the user for each missing field individually in this exact order. Do not ask for all fields at once.
"I couldn't fully read your App Store listing automatically. I'll ask for the missing
fields one at a time โ you can copy-paste from App Store Connect or from the live listing.
1. What is your app title? (paste exactly as it appears on the listing)
2. What is your subtitle? (paste exactly, or type "none" if not set)
3. What is your promotional text? (paste exactly, or "none" if empty)
4. What is your keyword field? (paste from App Store Connect > App Information > Keywords)
5. Paste your full description text below.
6. How many screenshots are uploaded? Which device sizes?
7. Does your listing have an app preview video? (yes / no โ check on your device or
in App Store Connect; the crawler cannot detect this)
8. What is your current overall rating and total review count?"
Log each answer into the Metadata Extraction Table before proceeding to scoring.
Step 3.2 โ Fetch live listing: Android
web_fetch: {play_url_rewritten}
Extract and log:
- App title (count chars)
- Short description (count chars)
- Long description (char count; estimate keyword density for top 3 terms)
- Feature graphic present? (Y/N; describe if logo-only or lifestyle)
- Screenshot count
- Preview video present? (YouTube link if visible)
- Icon dimensions (verify 512ร512 standard)
- Overall rating (X.X / 5)
- Total review count
- Developer response rate (sample last 10 reviews)
Known crawling limitation: Google Play's short description field is frequently omitted from desktop crawler responses even when present on the live listing. If short description is absent from the fetch result, do not assume it is missing from the listing. Proceed to Step 3.2a before marking it as a gap.
If the fetch returns a JS-block error or yields < 3 auditable fields: proceed to Step 3.2a immediately.
Step 3.2a โ Android manual input fallback
If the automated fetch fails or any of the following fields are missing, prompt for each individually in this order:
"I couldn't fully read your Play Store listing automatically. I'll ask for the missing
fields one at a time โ you can copy-paste from Play Console or from the live listing.
1. What is your app title? (paste exactly)
2. What is your short description? (the 80-char line shown above the fold โ paste exactly,
or type "none" if you haven't set one)
3. Paste your full long description below.
4. How many screenshots are uploaded?
5. Is there a preview video? (Y/N โ if yes, paste the YouTube URL)
6. Does your listing have a feature graphic? (Y/N โ if yes, describe: lifestyle image,
product mockup, or logo on solid background?)
7. What is your current overall rating and total review count?"
Log each answer into the Metadata Extraction Table before proceeding to scoring.
Step 3.2b โ Screenshot and icon image analysis protocol
Pillar 2 (App Icon) and Pillar 5 (Screenshots) cannot be scored from text alone. The following fields require visual inspection:
| Field | Required for | When to request |
|---|---|---|
| App icon image | Pillar 2.1โ2.4 | Always โ icon is never extractable from fetch text |
| Screenshot images (all uploaded slots) | Pillar 5.2โ5.5 | When fetch returns no visible screenshot content |
| Feature graphic image (Android) | Pattern 3 assessment | When fetch does not describe the feature graphic |
Protocol:
- After completing Steps 3.1/3.2 and any manual fallbacks, check whether screenshot and icon images were returned in the fetch.
- If images are absent or descriptions are insufficient to score visual criteria, output this exact prompt before scoring Pillars 2 and 5:
"To score your icon and screenshots accurately, I need to see the actual images.
Please upload:
- Your app icon (the square image shown in search results)
- All screenshots currently live on your listing (drag them in or upload as a zip)
If uploading isn't convenient, describe each screenshot in one sentence:
Screenshot 1: [what it shows, whether it has a caption, approximate caption text]
Screenshot 2: [same]
...and so on."
-
Once images or descriptions are received, score Pillars 2 and 5 using the visual criteria in Section 4. If neither images nor descriptions are provided after prompting, mark Pillars 2 and 5 as "Unscored โ visual data required" and exclude them from the Optimization Grade calculation, noting the exclusion in the report header.
-
When images ARE provided: Claude must reference specific visual observations in every Pillar 2 and Pillar 5 finding. Example: "Screenshot 1 shows a white background with the app logo centred and no caption text โ this fails criterion 5.3 (readable captions) and 5.4 (core value communication)." Generic descriptions like "icon appears simple" are not acceptable when the actual image is available.
Step 3.3 โ Metadata Extraction Table
Render this table before scoring begins. Every auditable field, both platforms.
| Field | iOS Value | iOS Limit | iOS Utilization | Android Value | Android Limit | Android Utilization |
|---|---|---|---|---|---|---|
| App title | [extracted] | 30 chars | [N/30 = X%] | [extracted] | 30 chars | [N/30 = X%] |
| Subtitle / Short desc | [extracted] | 30 chars (iOS) / 80 chars (Android) | [X%] | [extracted] | 80 chars | [X%] |
| Keyword field | [not public] | 100 chars | [unknown โ flag] | N/A โ embedded in desc | โ | โ |
| Promotional text | [extracted] | 170 chars | [X%] | N/A | โ | โ |
| Full description | [char count] | No hard limit | โ | [char count] | 4,000 chars | [X%] |
| Screenshots | [count] | 10 max | [N/10] | [count] | 8 max | [N/8] |
| App preview video | [UNVERIFIABLE via crawler โ always ask user] iOS videos are blob URLs invisible to web_fetch | 3 max (iOS) | โ | [Y/N / YouTube URL โ visible in fetch] | 1 video | โ |
| Feature graphic | N/A | โ | โ | [Y/N; lifestyle or logo?] | 1024ร500px | โ |
| Overall rating | [X.X] | โ | โ | [X.X] | โ | โ |
| Review count | [N] | โ | โ | [N] | โ | โ |
| Developer responses | [Y/N in last 10?] | โ | โ | [Y/N in last 10?] | โ | โ |
Step 3.4 โ Platform rules lock-in (embedded; no external fetch required)
iOS (App Store) โ verified limits:
- Title: 30 chars max (Apple App Store Connect)
- Subtitle: 30 chars max (Apple App Store Connect)
- Keyword field: 100 chars max; single words separated by commas; no spaces after commas (Apple App Store Connect). Not visible to users.
- Promotional text: 170 chars max; updatable without a new app version; NOT indexed by the algorithm; conversion-impacting only.
- Screenshots: 1โ10 per localization; 2025 mandatory primary sizes:
- iPhone: 6.9" (1320ร2868 px or 2868ร1320 px) โ required primary as of 2025
- iPad: 13" (2064ร2752 px or 2752ร2064 px) โ required primary as of 2025
- Older 6.7" / 6.5" / 5.5" sizes still accepted but no longer primary
- App preview video: up to 3 per device size; 15โ30 seconds; autoplay is MUTED; must be self-explanatory without audio.
- Keyword hierarchy (ranking weight): Title > Subtitle > Keyword field
- Prohibited keyword categories (Guideline 2.3.7): competitor app names, Apple product names, irrelevant third-party trademarks, category names used deceptively.
- Metadata accuracy (Guideline 2.3.8): metadata must accurately reflect app content and functionality; misleading descriptions are grounds for rejection.
- Localization: each additional locale expands keyword indexing (keywords from secondary locales are indexed in primary market searches).
Android (Google Play) โ verified limits:
- Title: 30 chars max (Google Play Console policy)
- Short description: 80 chars max; shown above the fold; primary conversion copy.
- Long description: 4,000 chars max; primary keyword indexing field; no dedicated keyword field exists.
- Icon: 512ร512 px, 32-bit PNG, max 1 MB.
- Feature graphic: 1024ร500 px, JPEG or 24-bit PNG, max 1 MB; occupies ~60% of above-fold space on store listing; must be lifestyle or product image, not logo-only.
- Screenshots: minimum 2 required; up to 8 recommended.
- Preview video: via YouTube link; 30โ120 seconds; public or unlisted; autoplay is MUTED.
- Keyword indexing: title + short description + long description (no separate keyword field).
- Prohibited title words (Google Play Metadata policy): "free", "sale", "best", "top", "#1", "download now", "update", "App of the Year", performance claims, ranking claims, ALL CAPS (unless brand name), emojis/emoticons, repeated special characters.
- Keyword density in long description: target ~1% for primary keyword; avoid >2% for any single term (keyword stuffing flag risk).
- Google Play indexes only the active interface language locale.
4. SCORING RUBRIC
Total maximum: 155 pts. Normalization formula: Optimization Grade = (Raw Score / 155) ร 100
Score iOS and Android separately. Flag platform divergence > 15 percentage points.
PILLAR 1 โ App Title + Subtitle (iOS) / App Title + Short Description (Android)
Max: 25 pts
| # | Sub-criterion | Max pts | Pass definition | Fail definition | Source |
|---|---|---|---|---|---|
| 1.1 | No duplicate keywords across title and subtitle/short desc | 5 | Zero repeated root words across both fields | Any keyword appears in both fields | Expert benchmark; Apple/Google both penalize keyword duplication across indexed fields |
| 1.2 | Character utilization โฅ 80% of limit | 5 | Title โฅ 24/30 chars; subtitle โฅ 24/30 (iOS) or short desc โฅ 64/80 (Android) | Any field below 80% utilization | Apple/Google field limits; unused chars = wasted indexing surface |
| 1.3 | Most important keyword placed first | 5 | Primary conversion keyword appears in first 15 chars of title | Title leads with brand name only or a weak generic word | Expert benchmark: algorithm weights first tokens more heavily [uncertain โ exact weight distribution not public] |
| 1.4 | Keywords combine to form additional search terms | 5 | Title + subtitle word combination unlocks โฅ 2 additional compound search terms | Title and subtitle are standalone fragments with no combinatorial value | Expert ASO benchmark from production audits |
| 1.5 | Short description in full sentences, no keyword stuffing โ Android only | 5 | Full grammatical sentence; reads naturally; primary keyword appears once | Reads as a keyword list; fragments; brand name leads the sentence | Google Play Metadata policy; conversion copy best practice |
Pillar 1 total: /25
PILLAR 2 โ App Icon
Max: 20 pts
| # | Sub-criterion | Max pts | Pass definition | Fail definition | Source |
|---|---|---|---|---|---|
| 2.1 | Simple, scalable design legible at small sizes | 5 | Recognizable at 29ร29px (notification size); no text-heavy or cluttered design | Small icon is indistinct; text unreadable at notification sizes | Apple HIG; Google Play icon specification |
| 2.2 | Strong brand association / recognizability | 5 | Icon uses brand color or logo element consistently with other brand touchpoints | Icon looks unrelated to brand identity; could belong to any app | Expert benchmark from production audits |
| 2.3 | Unique and differentiated from competitors | 5 | Color, shape, and concept clearly distinct from top 5 category competitors | Icon is generic or nearly identical to a competitor | Expert benchmark |
| 2.4 | Eye-catching, draws attention in search results | 5 | Bold contrast, clear focal point, strong color; performs above category CVR average | Muted, low-contrast, or visually crowded; blends into results grid | Expert benchmark; A/B test data from AppRadar / SplitMetrics [uncertain โ category-specific] |
Pillar 2 total: /20
PILLAR 3 โ Keywords & Metadata
Max: 25 pts
iOS sub-criteria:
| # | Sub-criterion | Max pts | Pass definition | Fail definition | Source |
|---|---|---|---|---|---|
| 3.1 | Keyword field uses simple single-word units, comma-separated | 5 | All tokens are single words; no spaces wasted; no phrases that duplicate title/subtitle | Phrases used instead of single words; duplicates title words; spaces after commas | Apple App Store Connect keyword field best practice |
| 3.2 | 100-char keyword field fully utilized (โฅ 95 chars) | 5 | Field is 95โ100 chars | Field < 80 chars | Apple App Store Connect field limit |
| 3.3 | High-volume keywords targeted (verified via ASA or third-party tool) | 5 | At least 3 of 5 targeted keywords have popularity score โฅ 40/100 on a tool like AppFollow / App Radar | All keywords are low-volume or unmeasured | Expert benchmark; AppFollow keyword popularity scoring [uncertain โ exact threshold varies by category] |
| 3.4 | Top organic ranking achieved for primary keyword | 5 | Ranks in top 10 results for stated primary keyword | Not ranking in top 50 for any targeted keyword | Expert benchmark |
| 3.5 | Ranks above key competitors for โฅ 1 shared keyword | 5 | Outranks at least 1 named competitor on a shared high-volume term | Consistently below all competitors on shared terms | Expert benchmark |
Android sub-criteria:
| # | Sub-criterion | Max pts | Pass definition | Fail definition | Source |
|---|---|---|---|---|---|
| 3.1 | 5โ7 focus keywords identified and embedded naturally in long description | 5 | 5โ7 distinct keyword themes visible; each appears naturally in context | No identifiable keyword strategy; fewer than 3 themes; or pure stuffing | Google Play indexing mechanism; long desc is sole keyword source |
| 3.2 | Keyword density for primary term ~1% (not exceeding 2%) | 5 | Primary keyword appears once per 100 words on average | Primary keyword density >2% (keyword stuffing risk) | Google Play Metadata policy; expert benchmark |
| 3.3 | High-impact terms targeted | 5 | Primary and secondary keywords have measurable search volume | All chosen terms are niche or unmeasured | Expert benchmark |
| 3.4 | Top organic ranking for primary keyword | 5 | Ranks in top 10 for stated primary keyword | Not ranking in top 50 | Expert benchmark |
| 3.5 | Ranks above key competitors for โฅ 1 shared keyword | 5 | Outranks โฅ 1 named competitor on a shared term | Below all competitors on all shared terms | Expert benchmark |
Pillar 3 total: /25
PILLAR 4 โ Description (iOS Long Desc / Android Long Desc)
Max: 25 pts
| # | Sub-criterion | Max pts | Pass definition | Fail definition | Source |
|---|---|---|---|---|---|
| 4.1 | Formatted with clear structure (bullets, line breaks, headers) | 5 | Scannable in 10 seconds; uses line breaks, emoji bullets, or bold headers | Wall of text with no visual breaks | UX best practice; Google Play metadata policy (avoid excessive formatting but structure is required) |
| 4.2 | Covers all identified target keywords | 5 | Every keyword from the keyword field (iOS) or keyword strategy (Android) appears at least once in description | Multiple target keywords absent from description | ASO expert benchmark; description feeds secondary indexing on iOS |
| 4.3 | Builds on title and subtitle โ does not repeat them verbatim | 5 | First paragraph introduces new information not in title/subtitle | First sentence restates title; description is a verbatim expansion of header fields | Keyword duplication wastes indexing surface; expert benchmark |
| 4.4 | Clear CTA present | 5 | At least one action phrase (e.g., "Download free", "Start your trial today", "Join X million users") | No CTA anywhere in description | Conversion optimization best practice |
| 4.5 | Branded voice consistent with update notes and listing identity | 5 | Tone, reading level, and brand voice match across description, update notes, and developer reply voice | Inconsistent voice (corporate vs casual); update notes are generic "Bug fixes" | Trust and brand coherence; expert benchmark |
Pillar 4 total: /25
PILLAR 5 โ Screenshots
Max: 25 pts
| # | Sub-criterion | Max pts | Pass definition | Fail definition | Source |
|---|---|---|---|---|---|
| 5.1 | 7 or more screenshots uploaded (iOS: up to 10; Android: up to 8) | 5 | iOS: 7โ10 screenshots; Android: 6โ8 screenshots | iOS: fewer than 7; Android: fewer than 6 | Apple App Store Connect (1โ10 allowed); Google Play (minimum 2, up to 8) |
| 5.2 | Sufficient contrast and visual hierarchy in each frame | 5 | Background-to-text contrast ratio โฅ 4.5:1 (WCAG AA); key element is immediately visible | Low contrast; text washes into background; crowded layout | WCAG 2.1 AA contrast guideline; expert benchmark |
| 5.3 | โฅ 80% of screenshots have readable captions | 5 | At least 6/7 (iOS) or 5/6 (Android) screenshots have caption text โฅ 18pt equivalent | Most screenshots have no captions, or captions are too small to read on a 6" phone | Expert benchmark; caption text is primary keyword surface on visual assets |
| 5.4 | First 2โ3 screenshots clearly communicate core value; captions readable at a glance | 5 | Slot 1: main benefit; Slot 2: USP; Slot 3: social proof or key use case; each caption legible without zooming | First screenshot shows a generic feature or app splash screen; no clear value statement | SplitMetrics / StoreMaven A/B test data: first 2 screenshots drive majority of conversion decision [uncertain โ exact % varies by category] |
| 5.5 | CTA or download prompt visible in final screenshot | 5 | Last screenshot includes "Download", "Try free", "Start now" or similar prompt | Final screenshot is an arbitrary feature shot with no closing hook | Conversion optimization best practice; expert benchmark |
Screenshot specifications to verify:
- iOS 2025 primary required sizes: 6.9" iPhone (1320ร2868 px portrait) and 13" iPad (2064ร2752 px portrait)
- Android: screenshots must be at least 320px on shortest side; JPEG or PNG; max 8 MB each
Pillar 5 total: /25
PILLAR 6 โ App Preview Video
Max: 20 pts
| # | Sub-criterion | Max pts | Pass definition | Fail definition | Source |
|---|---|---|---|---|---|
| 6.1 | Preview video present | 5 | At least 1 app preview video uploaded and visible on listing | No video; screenshots-only listing | Apple App Store Connect (up to 3 per device size); Google Play (YouTube link) |
| 6.2 | Clearly explains app value within first 5 seconds | 5 | Value proposition visible in first 5 seconds without audio; hero moment not buried | Video opens with generic loading screen, logo animation, or lengthy intro | Best practice: autoplay is MUTED on both platforms; first 5 seconds must convert visually |
| 6.3 | Reinforces title and subtitle keywords visually | 5 | On-screen text in video includes โฅ 2 keywords from title/subtitle or keyword field | Video has no text overlays; value is purely audio-dependent | Expert benchmark; video is a visual keyword surface for conversion |
| 6.4 | Includes a clear CTA or closing hook | 5 | Final frame shows download prompt, rating, or strong closing benefit statement | Video ends on a loading screen or generic end card | Conversion optimization best practice |
Note: Autoplay is muted on both App Store and Google Play. Content MUST be visually self-explanatory without audio.
Pillar 6 total: /20
PILLAR 7 โ Ratings & Reviews
Max: 15 pts
| # | Sub-criterion | Max pts | Pass definition | Fail definition | Source |
|---|---|---|---|---|---|
| 7.1 | Overall rating โฅ 4.0 (4.5+ = full 5 pts) | 5 | Rating โฅ 4.5 = 5 pts; 4.0โ4.49 = 3 pts; 3.5โ3.99 = 1 pt; < 3.5 = 0 pts | Rating < 4.0 causes measurable conversion drag; < 3.5 triggers store demotion risk | AppFollow / AppRadar best practice: 4.0+ is minimum viable; 4.5+ signals category leader |
| 7.2 | โฅ 50% of reviews are 5-star | 5 | 5-star reviews comprise majority of visible review mix | Majority are 1โ3 star; or 5-star rate < 40% | Expert benchmark |
| 7.3 | Developer responds personally to reviews | 5 | Developer response visible on โฅ 50% of reviews in last 30 days; responses are personalized (not templated) | No developer responses; or all responses are identical templates | Google Play and Apple App Store both surface developer responses; personalized replies increase review quality [uncertain โ exact algorithm weight not public] |
Pillar 7 total: /15
Scoring summary table (fill per audit)
| Pillar | iOS Raw | iOS Max | Android Raw | Android Max |
|---|---|---|---|---|
| 1. Title + Subtitle / Short Desc | 25 | 25 | ||
| 2. App Icon | 20 | 20 | ||
| 3. Keywords & Metadata | 25 | 25 | ||
| 4. Description | 25 | 25 | ||
| 5. Screenshots | 25 | 25 | ||
| 6. App Preview Video | 20 | 20 | ||
| 7. Ratings & Reviews | 15 | 15 | ||
| TOTAL | 155 | 155 | ||
| Optimization Grade | (iOS / 155) ร 100 = X% | (Android / 155) ร 100 = X% |
Platform delta: If |iOS% - Android%| > 15, flag: "โ ๏ธ Platform divergence of [X]pp detected. See Section 5 for field-by-field comparison."
5. PLATFORM DELTA TABLE
Side-by-side reference for every auditable field. Render this table in every audit to anchor scoring decisions.
| Field | iOS App Store | Android Google Play |
|---|---|---|
| Title char limit | 30 | 30 |
| Subtitle / Short desc limit | 30 (subtitle) | 80 (short description) |
| Keyword field | 100 chars; comma-separated single words; NOT shown to users | None โ keywords embedded in title + short desc + long desc |
| Promotional text | 170 chars; updatable without app update; NOT indexed | None |
| Long description char limit | No hard limit (recommended < 4,000) | 4,000 chars |
| Keyword hierarchy | Title > Subtitle > Keyword field | Title > Short desc > Long desc |
| Icon spec | No separate upload spec (auto-generated from app binary) | 512ร512 px, 32-bit PNG, max 1 MB |
| Feature graphic | None | 1024ร500 px, JPEG or 24-bit PNG, max 1 MB |
| Screenshots: min / max | 1 / 10 | 2 / 8 |
| 2025 primary screenshot sizes | 6.9" iPhone (1320ร2868 px); 13" iPad (2064ร2752 px) | No fixed primary size; minimum 320px shortest side |
| Preview video | Up to 3 per device size; 15โ30 sec; autoplay muted; self-hosted | 1 YouTube link; 30โ120 sec; public or unlisted; autoplay muted |
| Prohibited title words | Competitor names (2.3.7); misleading claims (2.3.8); Apple trademarks | "free", "sale", "best", "top", "#1", "download now", "update", "App of the Year", ALL CAPS, emojis, ranking claims |
| Localization & keyword indexing | Additional locales expand keyword indexing in primary market | Only active interface language locale is indexed |
| Rating threshold for conversion | 4.0+ minimum; 4.5+ = category leader signal | 4.0+ minimum; 4.5+ = category leader signal |
| Developer reply surface | Visible on product page; public | Visible on product page; public |
| A/B testing native tool | Product Page Optimization (App Store Connect) | Store Listing Experiments (Play Console) |
| Minimum A/B test window | 7 days (recommended) | 14 days (recommended) |
6. INTERACTIVE HTML REPORT โ DELIVERY AND ARCHITECTURE
How to deliver
Use Claude's visualize:show_widget tool to render the audit report inline in the
conversation. This requires no build step, no file download, no Next.js, and no MDX
setup. The report renders immediately inside Claude.ai.
Do not output the code as a JSX artifact block or a downloadable file. The
show_widget tool is the correct delivery mechanism โ it streams the HTML directly
into the chat and makes it immediately interactive.
Technical rules for the HTML widget:
- Pure HTML + vanilla JS only. No React. No external libraries.
- Inline
styleattributes throughout โ no<style>blocks except for short shared rules (< 15 lines). Never reference Tailwind classes. - Use CSS variables from Claude's design system:
--color-text-primary,--color-background-secondary,--color-border-tertiary,--font-sans,--border-radius-md,--border-radius-lg. These auto-adapt to light/dark mode. - No
position: fixed. No nested scrolling. Nodisplay: noneduring initial render. - All audit data (pillar scores, findings, fixes, opportunity rows, keyword variants,
roadmap) is hardcoded as
constarrays at the top of the<script>block. - The widget is fully self-contained. All state lives in plain JS variables, updated
via
innerHTMLre-render on user interaction.
โ ๏ธ CRITICAL SCORING ANTI-PATTERNS โ never do these
Two bugs have been confirmed in production. Both produce wrong scores. Both are preventable. Read before writing any score-related code.
Anti-pattern 1 โ Getter double-counting:
// WRONG โ do not do this
const IOS_AUDIT = {
p1: 25, p2: 15, p3: 20,
get total() { return this.p1 + this.p2 + this.p3; } // โ DANGER
};
Object.values(IOS_AUDIT).reduce((s, v) => s + v, 0);
// Object.values() INVOKES the getter and includes its return value in the array.
// Result: 25+15+20+60 = 120 โ the total is counted TWICE.
// Displayed grade: (120/155)ร100 = 77% instead of correct 39%.
Anti-pattern 2 โ Dual source of truth drift:
// WRONG โ do not do this
const IOS_AUDIT = { p1: 25, p2: 18, p3: 20 }; // manually set
const pillars = [ /* p2 initial passes only sum to 15, not 18 */ ];
// Frozen score card shows 73%. Pillar bar for P2 shows 15/20.
// User sees inconsistent numbers before touching any checkbox.
The correct pattern โ single source of truth:
// RIGHT โ always do this
const INIT_PILLARS = [ /* all criteria with pass: true/false per platform */ ];
// Frozen audit scores: computed ONCE from INIT_PILLARS, never recomputed.
function computeScore(pillars, plat) {
return pillars.reduce((s, p) =>
s + p.criteria.reduce((cs, c) => cs + (c[plat].pass ? c.pts : 0), 0), 0);
}
const IOS_AUDIT_SCORE = computeScore(INIT_PILLARS, 'ios'); // plain const, never changes
const AND_AUDIT_SCORE = computeScore(INIT_PILLARS, 'and'); // plain const, never changes
// Live scores: recomputed from mutable state on every toggle.
// In the render loop, always call computeScore(currentPillars, plat).
The frozen audit score and the initial live score MUST be equal when the page first loads. If they differ, there is a source-of-truth mismatch โ stop and debug before delivering.
Binary scoring rule
Every sub-criterion scores either its full pts value or 0. Never assign partial
points within a criterion (e.g., 3/5 or 4/5). The binary constraint is what makes the
live simulation meaningful โ toggling a checkbox always produces a clean +5, +5, or
+5 change that the user can map to a specific action.
When visual data is unavailable (screenshots not uploaded, icon not visible):
- Still score binary: mark as
pass: falseand add[ESTIMATED โ upload screenshots to verify]to the finding text. - Do not give partial credit to compensate for uncertainty.
- Do not skip the criterion โ mark it false and flag it.
Criterion data structure
Each criterion must follow this exact shape. The nested ios / and objects are the
proven working pattern. The old flat passed / android_passed pattern caused
scoring bugs and is deprecated.
{
id: '2.3',
label: 'Unique and differentiated from competitors',
pts: 5,
ios: {
pass: false, // boolean โ full pts or 0, never fractional
finding: 'Dark navy blends with Xoom, Western Union, MoneyGram in search grids...',
fixes: [
{ t: 'A', x: 'Test icon variant with teal background โ copy-only, no designer needed...' },
{ t: 'B', x: 'Run Product Page Optimization: current navy vs teal vs white. 7+ days...' },
{ t: 'C', x: 'Screenshot the "money transfer" search grid at 48px scale. If Remitly...' },
]
},
and: {
pass: false,
finding: 'Same differentiation gap on Android. Finance category is dense with dark blue.',
fixes: [
{ t: 'A', x: 'Same teal background test as iOS via Store Listing Experiment...' },
{ t: 'B', x: 'Compare tap-through rate of current icon vs lighter-palette variant...' },
{ t: 'C', x: 'If test shows no CTR lift, icon is already optimal โ document and move on.' },
]
}
}
Rules:
passis alwaystrueorfalse. Nevernull, never a number.fixesis only populated whenpass: false. Empty array[]when passing.findingis always present, for both pass and fail โ the passing finding explains WHY it passes.- Every fix must cite actual listing text or data (see Section 7 mandatory rule).
Score display โ three-card hero layout
The hero scoreboard has exactly three metric cards:
| Card | Label | Source | Behaviour |
|---|---|---|---|
| 1 | iOS Audit Score | IOS_AUDIT_SCORE (const) |
Never changes โ frozen baseline |
| 2 | Android Audit Score | AND_AUDIT_SCORE (const) |
Never changes โ frozen baseline |
| 3 | Live Simulation | computeScore(pillars, plat) |
Updates on every checkbox toggle |
Card 3 has a dashed border to visually distinguish it from the frozen cards.
Toggle placement โ inside the Live card, not below the hero.
The platform toggle buttons (๐ iOS / ๐ค Android) must live inside the Live
simulation card header โ not in a separate row below the three cards. The toggle
controls what the Live card displays, so they must be co-located with it.
โโโ iOS frozen โโโ โโโ Android frozen โโโ โโโ Live (dashed) โโโโโโโโโโโ
โ 71% โ โ 74% โ โ [๐ iOS] [๐ค Android] โ
โ 100/155 ยท ... โ โ 115/155 ยท ... โ โ 78% โ
โโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโ โ 120/155 ยท toggle below โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The toggle switches:
- Which platform's pass states the checklist displays
- Which platform the Live score reflects
- The pillar bar percentages within the checklist
The frozen iOS and Android cards are always both visible regardless of which platform the toggle is set to. They are the permanent audit record and must never update when checkboxes are toggled.
The footnote Audit: 71% ยท Live: 78% may be shown inline within the Live card or
below it โ do not render a separate toggle row below the three cards.
Single-platform hero layout
When only one platform URL is provided (iOS-only or Android-only audit):
The unavailable platform card shows "N/A" with muted grey styling:
// iOS-only example โ Android card is N/A
{ lbl:'Android audit score', na:true, sub:'Android URL not provided' }
Render the N/A card with color: var(--color-text-secondary), no progress bar fill,
and the sub-label explaining why. Do not show a score or pts count.
The Live card shows a static platform badge inside the card header instead of toggle buttons โ since there is only one platform, no switching is needed:
โโโ Live (dashed) โโโโโโโโโโโโโโโ
โ LIVE [iOS only] โ โ badge replaces toggle buttons
โ 48% โ
โ 74/155 ยท Toggle to simulate โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The [iOS only] / [Android only] badge is a small dark pill: background: var(--color-text-primary); color: var(--color-background-primary); font-size: 11px; padding: 2px 8px; border-radius: 99px;
No platform toggle is rendered anywhere in a single-platform audit โ not in the Live card, not below the hero. The checklist label ("iOS only ยท toggle to simulate fixes") communicates the platform context without buttons.
Scoring for the unavailable platform: all criteria for the missing platform
auto-pass (pass: true) with finding: 'N/A โ [platform] not provided.' and
fixes: []. This keeps the data structure consistent but those criteria never
contribute to a visible score. The computeScore() call only uses the available
platform's data.
Complete HTML scaffold
<style>
/* Shared rules only โ keep under 15 lines */
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font-sans);font-size:14px;color:var(--color-text-primary)}
.card{background:var(--color-background-primary);border:0.5px solid var(--color-border-tertiary);border-radius:var(--border-radius-lg);padding:1.25rem;margin-bottom:1rem}
.lbl{font-size:11px;color:var(--color-text-secondary);text-transform:uppercase;letter-spacing:.03em;margin-bottom:4px}
.pill{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:500}
.pr{background:#FCEBEB;color:#A32D2D}.pa{background:#FAEEDA;color:#854F0B}.pg{background:#EAF3DE;color:#3B6D11}.pb{background:#E6F1FB;color:#185FA5}
.fix-box{background:var(--color-background-secondary);border-left:2px solid #E24B4A;padding:.65rem .9rem;margin-top:.5rem;font-size:12px;line-height:1.6}
.ft{font-size:10px;font-weight:600;padding:1px 5px;border-radius:3px;display:inline-block;margin-right:4px}
.fa{background:#EAF3DE;color:#3B6D11}.fb{background:#E6F1FB;color:#185FA5}.fc{background:#EEEDFE;color:#534AB7}
.fdg{font-size:12px;color:var(--color-text-secondary);line-height:1.5;margin-top:3px}
table{width:100%;border-collapse:collapse;font-size:12px}
th{background:var(--color-background-secondary);padding:6px 10px;text-align:left;font-weight:500;font-size:11px;color:var(--color-text-secondary);border-bottom:0.5px solid var(--color-border-tertiary)}
td{padding:6px 10px;border-bottom:0.5px solid var(--color-border-tertiary);vertical-align:top}
</style>
<div id="root"></div>
<script>
const MAX = 155;
// โโ STEP 1: PILLAR DATA โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// All pass states, findings, and fixes live here.
// This is the SINGLE SOURCE OF TRUTH for all score computations.
// Binary scoring: pass is true or false. Never fractional.
const INIT_PILLARS = [
{ id:1, name:'Title + Subtitle / Short description', max:25, criteria:[
{ id:'1.1', label:'No duplicate root keywords across fields', pts:5,
ios:{ pass:{IOS_1_1}, finding:'{IOS_1_1_FINDING}', fixes:[] },
and:{ pass:{AND_1_1}, finding:'{AND_1_1_FINDING}', fixes:[
{ t:'A', x:'{AND_1_1_FIX_A}' },
{ t:'B', x:'{AND_1_1_FIX_B}' },
{ t:'C', x:'{AND_1_1_FIX_C}' },
]}
},
{ id:'1.2', label:'Character utilization โฅ 80% of limit', pts:5,
ios:{ pass:{IOS_1_2}, finding:'{IOS_1_2_FINDING}', fixes:[] },
and:{ pass:{AND_1_2}, finding:'{AND_1_2_FINDING}', fixes:[] }
},
{ id:'1.3', label:'Most important keyword placed first', pts:5,
ios:{ pass:{IOS_1_3}, finding:'{IOS_1_3_FINDING}', fixes:[] },
and:{ pass:{AND_1_3}, finding:'{AND_1_3_FINDING}', fixes:[] }
},
{ id:'1.4', label:'Keywords combine to form additional search terms', pts:5,
ios:{ pass:{IOS_1_4}, finding:'{IOS_1_4_FINDING}', fixes:[] },
and:{ pass:{AND_1_4}, finding:'{AND_1_4_FINDING}', fixes:[] }
},
{ id:'1.5', label:'Short description in full sentences, no stuffing (Android only)', pts:5,
ios:{ pass:true, finding:'N/A for iOS โ auto-pass.', fixes:[] },
and:{ pass:{AND_1_5}, finding:'{AND_1_5_FINDING}', fixes:[] }
},
]},
{ id:2, name:'App icon', max:20, criteria:[
{ id:'2.1', label:'Simple, scalable design legible at small sizes', pts:5,
ios:{ pass:{IOS_2_1}, finding:'{IOS_2_1_FINDING}', fixes:[] },
and:{ pass:{AND_2_1}, finding:'{AND_2_1_FINDING}', fixes:[] }
},
{ id:'2.2', label:'Strong brand association / recognizability', pts:5,
ios:{ pass:{IOS_2_2}, finding:'{IOS_2_2_FINDING}', fixes:[] },
and:{ pass:{AND_2_2}, finding:'{AND_2_2_FINDING}', fixes:[] }
},
{ id:'2.3', label:'Unique and differentiated from competitors', pts:5,
ios:{ pass:{IOS_2_3}, finding:'{IOS_2_3_FINDING}', fixes:[] },
and:{ pass:{AND_2_3}, finding:'{AND_2_3_FINDING}', fixes:[] }
},
{ id:'2.4', label:'Eye-catching, draws attention in search results', pts:5,
ios:{ pass:{IOS_2_4}, finding:'{IOS_2_4_FINDING}', fixes:[] },
and:{ pass:{AND_2_4}, finding:'{AND_2_4_FINDING}', fixes:[] }
},
]},
{ id:3, name:'Keywords & metadata', max:25, criteria:[
{ id:'3.1', label:'Focus keywords naturally embedded (5โ7 themes)', pts:5,
ios:{ pass:{IOS_3_1}, finding:'{IOS_3_1_FINDING}', fixes:[] },
and:{ pass:{AND_3_1}, finding:'{AND_3_1_FINDING}', fixes:[] }
},
{ id:'3.2', label:'Keyword field โฅ95% utilized (iOS) / density ~1% (Android)', pts:5,
ios:{ pass:{IOS_3_2}, finding:'{IOS_3_2_FINDING}', fixes:[] },
and:{ pass:{AND_3_2}, finding:'{AND_3_2_FINDING}', fixes:[] }
},
{ id:'3.3', label:'High-volume terms targeted', pts:5,
ios:{ pass:{IOS_3_3}, finding:'{IOS_3_3_FINDING}', fixes:[] },
and:{ pass:{AND_3_3}, finding:'{AND_3_3_FINDING}', fixes:[] }
},
{ id:'3.4', label:'Top organic ranking for primary keyword', pts:5,
ios:{ pass:{IOS_3_4}, finding:'{IOS_3_4_FINDING}', fixes:[] },
and:{ pass:{AND_3_4}, finding:'{AND_3_4_FINDING}', fixes:[] }
},
{ id:'3.5', label:'Ranks above key competitors for โฅ1 shared keyword', pts:5,
ios:{ pass:{IOS_3_5}, finding:'{IOS_3_5_FINDING}', fixes:[] },
and:{ pass:{AND_3_5}, finding:'{AND_3_5_FINDING}', fixes:[] }
},
]},
{ id:4, name:'Description', max:25, criteria:[
{ id:'4.1', label:'Formatted with clear structure', pts:5,
ios:{ pass:{IOS_4_1}, finding:'{IOS_4_1_FINDING}', fixes:[] },
and:{ pass:{AND_4_1}, finding:'{AND_4_1_FINDING}', fixes:[] }
},
{ id:'4.2', label:'Covers all identified target keywords', pts:5,
ios:{ pass:{IOS_4_2}, finding:'{IOS_4_2_FINDING}', fixes:[] },
and:{ pass:{AND_4_2}, finding:'{AND_4_2_FINDING}', fixes:[] }
},
{ id:'4.3', label:'Builds on title โ does not repeat it verbatim', pts:5,
ios:{ pass:{IOS_4_3}, finding:'{IOS_4_3_FINDING}', fixes:[] },
and:{ pass:{AND_4_3}, finding:'{AND_4_3_FINDING}', fixes:[] }
},
{ id:'4.4', label:'Clear CTA present', pts:5,
ios:{ pass:{IOS_4_4}, finding:'{IOS_4_4_FINDING}', fixes:[] },
and:{ pass:{AND_4_4}, finding:'{AND_4_4_FINDING}', fixes:[] }
},
{ id:'4.5', label:'Branded voice consistent with update notes', pts:5,
ios:{ pass:{IOS_4_5}, finding:'{IOS_4_5_FINDING}', fixes:[] },
and:{ pass:{AND_4_5}, finding:'{AND_4_5_FINDING}', fixes:[] }
},
]},
{ id:5, name:'Screenshots', max:25, criteria:[
{ id:'5.1', label:'7+ screenshots (iOS: up to 10, Android: up to 8)', pts:5,
ios:{ pass:{IOS_5_1}, finding:'{IOS_5_1_FINDING}', fixes:[] },
and:{ pass:{AND_5_1}, finding:'{AND_5_1_FINDING}', fixes:[] }
},
{ id:'5.2', label:'Sufficient contrast and visual hierarchy', pts:5,
ios:{ pass:{IOS_5_2}, finding:'{IOS_5_2_FINDING}', fixes:[] },
and:{ pass:{AND_5_2}, finding:'{AND_5_2_FINDING}', fixes:[] }
},
{ id:'5.3', label:'โฅ 80% of screenshots have readable captions', pts:5,
ios:{ pass:{IOS_5_3}, finding:'{IOS_5_3_FINDING}', fixes:[] },
and:{ pass:{AND_5_3}, finding:'{AND_5_3_FINDING}', fixes:[] }
},
{ id:'5.4', label:'First 2โ3 screenshots communicate core value', pts:5,
ios:{ pass:{IOS_5_4}, finding:'{IOS_5_4_FINDING}', fixes:[] },
and:{ pass:{AND_5_4}, finding:'{AND_5_4_FINDING}', fixes:[] }
},
{ id:'5.5', label:'CTA or download prompt in final screenshot', pts:5,
ios:{ pass:{IOS_5_5}, finding:'{IOS_5_5_FINDING}', fixes:[] },
and:{ pass:{AND_5_5}, finding:'{AND_5_5_FINDING}', fixes:[] }
},
]},
{ id:6, name:'App preview video', max:20, criteria:[
{ id:'6.1', label:'Preview video present', pts:5,
ios:{ pass:{IOS_6_1}, finding:'{IOS_6_1_FINDING}', fixes:[] },
and:{ pass:{AND_6_1}, finding:'{AND_6_1_FINDING}', fixes:[] }
},
{ id:'6.2', label:'Explains value within first 5 seconds (muted)', pts:5,
ios:{ pass:{IOS_6_2}, finding:'{IOS_6_2_FINDING}', fixes:[] },
and:{ pass:{AND_6_2}, finding:'{AND_6_2_FINDING}', fixes:[] }
},
{ id:'6.3', label:'Reinforces title keywords visually', pts:5,
ios:{ pass:{IOS_6_3}, finding:'{IOS_6_3_FINDING}', fixes:[] },
and:{ pass:{AND_6_3}, finding:'{AND_6_3_FINDING}', fixes:[] }
},
{ id:'6.4', label:'Includes a clear CTA or closing hook', pts:5,
ios:{ pass:{IOS_6_4}, finding:'{IOS_6_4_FINDING}', fixes:[] },
and:{ pass:{AND_6_4}, finding:'{AND_6_4_FINDING}', fixes:[] }
},
]},
{ id:7, name:'Ratings & reviews', max:15, criteria:[
{ id:'7.1', label:'Overall rating โฅ 4.0 (4.5+ = full 5 pts)', pts:5,
ios:{ pass:{IOS_7_1}, finding:'{IOS_7_1_FINDING}', fixes:[] },
and:{ pass:{AND_7_1}, finding:'{AND_7_1_FINDING}', fixes:[] }
},
{ id:'7.2', label:'โฅ 50% of reviews are 5-star', pts:5,
ios:{ pass:{IOS_7_2}, finding:'{IOS_7_2_FINDING}', fixes:[] },
and:{ pass:{AND_7_2}, finding:'{AND_7_2_FINDING}', fixes:[] }
},
{ id:'7.3', label:'Developer responds personally to reviews', pts:5,
ios:{ pass:{IOS_7_3}, finding:'{IOS_7_3_FINDING}', fixes:[] },
and:{ pass:{AND_7_3}, finding:'{AND_7_3_FINDING}', fixes:[] }
},
]},
];
// โโ STEP 2: FROZEN AUDIT SCORES โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Single source of truth: computed from INIT_PILLARS, stored in plain consts.
// NEVER use a separate object with manually set pillar totals.
// NEVER use Object.values() on an object that has a getter โ getters are invoked
// and their return value is included in the array, causing double-counting.
function computeScore(pillars, plat) {
return pillars.reduce((s, p) =>
s + p.criteria.reduce((cs, c) => cs + (c[plat].pass ? c.pts : 0), 0), 0);
}
const IOS_AUDIT_SCORE = computeScore(INIT_PILLARS, 'ios');
const AND_AUDIT_SCORE = computeScore(INIT_PILLARS, 'and');
const IOS_AUDIT_GRADE = Math.round((IOS_AUDIT_SCORE / MAX) * 100);
const AND_AUDIT_GRADE = Math.round((AND_AUDIT_SCORE / MAX) * 100);
// Sanity check: IOS_AUDIT_GRADE + AND_AUDIT_GRADE should both be plausible
// percentages (0โ100). If either exceeds 100, there is a data error in INIT_PILLARS.
// โโ STEP 3: OPPORTUNITY ROWS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// One row per failed sub-criterion (score = 0). Sorted P0 โ P1 โ P2.
const OPPS = [
// { p:'P0', plt:'Both', opp:'...', why:'...', action:'...', effort:'High', design:'Yes', time:'...' },
// CLAUDE: Fill with real audit findings. Every failed criterion generates โฅ1 row.
];
// โโ STEP 4: KEYWORD VARIANTS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const KWVARS = [
// { f:'iOS Title โ current', c:'App Name', n:8, l:30, h:'Baseline' },
// { f:'iOS Title โ variant A', c:'App Name: Keyword', n:18, l:30, h:'Tests keyword expansion' },
// CLAUDE: 2โ3 candidates per field, with real char counts.
];
// โโ STEP 5: ROADMAP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const ROADMAP = [
{ d:'Day 1', a:'P0 fixes โ tailored to this app\'s specific gaps.' },
{ d:'Day 5', a:'Scorecard + tracking setup.' },
{ d:'Day 10', a:'Begin implementation of Update 1.' },
{ d:'Day 17', a:'Update 1 launch. Begin Update 2.' },
{ d:'Day 25', a:'Update 2 ready.' },
{ d:'Day 30โ32',a:'Update 2 launch. Close Update 1 data window.' },
{ d:'Day 35', a:'30-day check-in vs baseline and competitors.' },
{ d:'Day 45โ47',a:'Update 3 launch.' },
{ d:'Day 60', a:'Update 4 launch. 60-day progress report.' },
{ d:'Day 67', a:'60-day check-in.' },
{ d:'Day 75', a:'Update 5 launch. 90-day report.' },
{ d:'Day 90', a:'Final scorecard. Continue or restructure decision.' },
];
// โโ RENDER LOOP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
(function() {
const root = document.getElementById('root');
let pillars = JSON.parse(JSON.stringify(INIT_PILLARS));
let openMap = {};
let plat = 'ios';
function liveScore() {
return computeScore(pillars, plat);
}
function gc(g) { return g>=75?'#639922':g>=50?'#BA7517':'#A32D2D'; }
function gl(g) { return g>=75?'Well optimized':g>=50?'Needs work':'Critical gaps'; }
function pp(p) { return {P0:'pr',P1:'pa',P2:'pb'}[p]||''; }
function render() {
const livS = liveScore();
const livG = Math.round((livS / MAX) * 100);
const audG = plat === 'ios' ? IOS_AUDIT_GRADE : AND_AUDIT_GRADE;
const audS = plat === 'ios' ? IOS_AUDIT_SCORE : AND_AUDIT_SCORE;
const delta = Math.abs(IOS_AUDIT_GRADE - AND_AUDIT_GRADE);
root.innerHTML = `<div style="padding:1rem 0">
<!-- HERO: 3-card layout โ iOS frozen | Android frozen | Live simulation -->
<div class="card">
<div class="lbl">ASO audit</div>
<div style="font-size:18px;font-weight:500;margin-bottom:2px">{APP_NAME}</div>
<div style="font-size:12px;color:var(--color-text-secondary);margin-bottom:1.25rem">
{APP_CATEGORY} ยท {APP_ID} ยท Audited: {AUDIT_DATE}
</div>
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-bottom:1rem">
${[
{ lbl:'iOS audit score', g:IOS_AUDIT_GRADE, raw:IOS_AUDIT_SCORE, sub:'Frozen โ checkboxes don\'t affect this' },
{ lbl:'Android audit score',g:AND_AUDIT_GRADE, raw:AND_AUDIT_SCORE, sub:'Frozen โ checkboxes don\'t affect this' },
].map(s => `
<div style="background:var(--color-background-secondary);border-radius:var(--border-radius-md);padding:.9rem">
<div class="lbl">${s.lbl}</div>
<div style="font-size:30px;font-weight:500;color:${gc(s.g)};line-height:1.1">${s.g}%</div>
<div style="height:5px;background:var(--color-border-tertiary);border-radius:99px;margin:6px 0 4px;overflow:hidden">
<div style="width:${Math.min(s.g,100)}%;height:100%;background:${gc(s.g)};border-radius:99px;transition:width .2s"></div>
</div>
<div style="font-size:11px;color:var(--color-text-secondary)">${s.raw}/${MAX} pts ยท ${gl(s.g)}</div>
<div style="font-size:10px;color:var(--color-text-secondary);margin-top:2px">${s.sub}</div>
</div>`).join('')}
<!-- LIVE CARD: toggle buttons live HERE, not below the hero -->
<div style="background:var(--color-background-secondary);border-radius:var(--border-radius-md);padding:.9rem;border:1.5px dashed var(--color-border-secondary)">
<!-- Platform toggle buttons sit at the top of the Live card -->
<div style="display:flex;gap:6px;margin-bottom:8px;align-items:center">
${['ios','and'].map(p => `<button onclick="setP('${p}')"
style="padding:3px 10px;border-radius:99px;border:0.5px solid var(--color-border-${plat===p?'primary':'tertiary'});
background:${plat===p?'var(--color-text-primary)':'transparent'};
color:${plat===p?'var(--color-background-primary)':'var(--color-text-secondary)'};
font-size:11px;cursor:pointer">
${p==='ios'?'๐ iOS':'๐ค Android'}
</button>`).join('')}
</div>
<div class="lbl">Live: ${plat==='ios'?'iOS':'Android'}</div>
<div style="font-size:30px;font-weight:500;color:${gc(livG)};line-height:1.1">${livG}%</div>
<div style="height:5px;background:var(--color-border-tertiary);border-radius:99px;margin:6px 0 4px;overflow:hidden">
<div style="width:${Math.min(livG,100)}%;height:100%;background:${gc(livG)};border-radius:99px;transition:width .2s"></div>
</div>
<div style="font-size:11px;color:var(--color-text-secondary)">${livS}/${MAX} pts ยท ${gl(livG)}</div>
<div style="font-size:10px;color:var(--color-text-secondary);margin-top:2px">Toggle checkboxes below to simulate fixes</div>
</div>
</div>
${delta > 15 ? `<div style="background:#FAEEDA;border-radius:var(--border-radius-md);padding:.6rem 1rem;font-size:12px;color:#854F0B;margin-bottom:.75rem">
โ Platform divergence: ${delta}pp. iOS and Android listings are significantly misaligned. See pillar breakdown below.</div>` : ''}
</div>
<!-- 7-PILLAR CHECKLIST -->
<div style="font-size:15px;font-weight:500;margin:1.5rem 0 .75rem">
7-pillar checklist
<span style="font-size:12px;font-weight:400;color:var(--color-text-secondary)">
โ ${plat==='ios'?'iOS':'Android'} ยท toggle to simulate fixes
</span>
</div>
${pillars.map(p => {
const ps = p.criteria.reduce((s,c) => s+(c[plat].pass?c.pts:0), 0);
const pct = Math.round((ps/p.max)*100);
const isOpen = !!openMap[p.id];
return `<div class="card" style="padding:0;margin-bottom:8px">
<div onclick="togP(${p.id})" style="padding:.85rem 1.25rem;cursor:pointer;display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:11px;color:var(--color-text-secondary);font-weight:500">P${p.id}</span>
<span style="font-size:13px;font-weight:500">${p.name}</span>
</div>
<div style="display:flex;align-items:center;gap:10px">
<div style="width:70px;height:5px;background:var(--color-border-tertiary);border-radius:99px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:${gc(pct)};border-radius:99px;transition:width .2s"></div>
</div>
<span style="font-size:12px;color:var(--color-text-secondary);min-width:36px;text-align:right">${ps}/${p.max}</span>
<span style="font-size:10px;color:var(--color-text-secondary)">${isOpen?'โฒ':'โผ'}</span>
</div>
</div>
${isOpen ? `<div style="border-top:0.5px solid var(--color-border-tertiary)">
${p.criteria.map(c => {
const d = c[plat];
return `<div style="padding:.6rem 1.25rem;border-bottom:0.5px solid var(--color-border-tertiary);background:${d.pass?'var(--color-background-primary)':'#fffbfb'}">
<label style="display:flex;gap:10px;cursor:pointer;align-items:flex-start">
<input type="checkbox" ${d.pass?'checked':''} onchange="togC(${p.id},'${c.id}')"
style="margin-top:2px;flex-shrink:0;accent-color:var(--color-text-primary)">
<div style="flex:1">
<div style="font-size:13px;font-weight:500;color:${d.pass?'var(--color-text-primary)':'#A32D2D'}">
[${c.id}] ${c.label}
<span style="font-size:11px;font-weight:400;color:var(--color-text-secondary)">(${c.pts} pts)</span>
</div>
<div class="fdg">${d.finding}</div>
${!d.pass && d.fixes && d.fixes.length ? `<div class="fix-box">
${d.fixes.map(f => `<div style="margin-bottom:5px"><span class="ft f${f.t.toLowerCase()}">${f.t}</span>${f.x}</div>`).join('')}
</div>` : ''}
</div>
</label>
</div>`;
}).join('')}
</div>` : ''}
</div>`;
}).join('')}
<!-- OPPORTUNITIES TABLE -->
<div style="font-size:15px;font-weight:500;margin:1.5rem 0 .75rem">Opportunities</div>
<div class="card" style="padding:0;overflow-x:auto">
<table>
<thead><tr>
<th>#</th><th>Priority</th><th>Platform</th><th>Opportunity</th>
<th>Why it matters</th><th>Action</th><th>Effort</th><th>Design?</th><th>Timeline</th>
</tr></thead>
<tbody>${OPPS.map((r,i) => `<tr style="background:${i%2===0?'var(--color-background-primary)':'var(--color-background-secondary)'}">
<td style="color:var(--color-text-secondary)">${i+1}</td>
<td><span class="pill ${pp(r.p)}">${r.p}</span></td>
<td style="white-space:nowrap">${r.plt}</td>
<td style="font-weight:500;max-width:140px">${r.opp}</td>
<td style="max-width:170px;color:var(--color-text-secondary)">${r.why}</td>
<td style="max-width:210px">${r.action}</td>
<td style="white-space:nowrap">${r.effort}</td>
<td>${r.design}</td>
<td style="color:var(--color-text-secondary);white-space:nowrap">${r.time}</td>
</tr>`).join('')}</tbody>
</table>
</div>
<!-- KEYWORD TESTING LAB -->
<div style="font-size:15px;font-weight:500;margin:1.5rem 0 .75rem">Keyword testing lab</div>
<div class="card" style="padding:0;overflow-x:auto">
<table>
<thead><tr><th>Field</th><th>Candidate</th><th>Chars</th><th>Limit</th><th>Util</th><th>Hypothesis</th></tr></thead>
<tbody>${KWVARS.map((r,i) => {
const pct = Math.round((r.n/r.l)*100);
const over = r.n > r.l;
const ok = pct >= 80 && !over;
return `<tr style="background:${i%2===0?'var(--color-background-primary)':'var(--color-background-secondary)'}">
<td style="font-size:11px;color:${r.f.includes('current')?'var(--color-text-secondary)':'var(--color-text-primary)'};white-space:nowrap">${r.f}</td>
<td style="font-family:var(--font-mono);font-size:11px">${r.c}</td>
<td style="text-align:center;font-family:var(--font-mono);color:${over?'#A32D2D':'inherit'}">${r.n}${over?' โ ':''}</td>
<td style="text-align:center;font-family:var(--font-mono);color:var(--color-text-secondary)">${r.l}</td>
<td style="text-align:center"><span class="pill" style="background:${ok?'#EAF3DE':'#FCEBEB'};color:${ok?'#3B6D11':'#A32D2D'}">${pct}%</span></td>
<td style="font-size:11px;color:var(--color-text-secondary);max-width:210px">${r.h}</td>
</tr>`;
}).join('')}</tbody>
</table>
</div>
<!-- 90-DAY ROADMAP -->
<div style="font-size:15px;font-weight:500;margin:1.5rem 0 .75rem">What happens next โ 90-day roadmap</div>
<div class="card">${ROADMAP.map(r => `
<div style="display:flex;gap:1rem;align-items:flex-start;margin-bottom:8px">
<div style="min-width:58px;font-size:11px;font-family:var(--font-mono);color:var(--color-text-secondary);padding-top:2px;flex-shrink:0">${r.d}</div>
<div style="flex:1;font-size:13px;border-left:2px solid var(--color-border-tertiary);padding-left:1rem;padding-bottom:8px">${r.a}</div>
</div>`).join('')}
</div>
<!-- COMPETITOR GAP (conditional) -->
<!-- CLAUDE: Render this section only if competitor URLs were provided.
Otherwise omit it entirely or render a one-line placeholder. -->
<!-- FOOTER CTA โ must appear verbatim per Section 13 -->
<div style="background:var(--color-background-secondary);border-radius:var(--border-radius-lg);padding:1.5rem;text-align:center">
<div style="font-size:14px;font-weight:500;margin-bottom:6px">Ready to go deeper?</div>
<div style="font-size:13px;color:var(--color-text-secondary)">
Would you like to run a competitor comparison or export these recommendations as a Notion-ready task list?
</div>
</div>
*Truncated - read the full file at https://github.com/vagabond911/aso-auditor/blob/4a36ff1ea921744392642af433e66b8ba550c08c/SKILL.