Store listing
The Google Play listing: its text (title, short description and full description), its phone screenshots, its store icon and its feature graphic. All of them are made only in pull requests and reviewed there; a release ships what was reviewed and makes nothing of its own. The listing's category, tags, website and contact email are entered in Play Console by the owner, not compiled from this repository; they are recorded in Play Console listing details.
The phone screenshots are rendered from the app's own screens on the JVM (Robolectric, through Roborazzi), never captured by hand from a device, so they change only when the screens or the captions do, and every change is reviewed as an image diff in a pull request (STORE-SHOT-001 to STORE-SHOT-015). The store icon and the feature graphic are rendered the same way, the icon from the app's own launcher icon resources and the graphic from the same showcase screens as the shots, and reviewed the same way (STORE-ICON-001 to STORE-ICON-005, STORE-GRAPHIC-001 to STORE-GRAPHIC-007). The text is hand-written, one claim per line, and each claim names the requirements it rests on, so the listing can never promise what the specification does not say (STORE-LIST-001 to STORE-LIST-007). The listing's title is also the app's name on the phone (STORE-LIST-008).
The shots
Each shot shows a screen as it exists in the app, with the showcase data of STORE-SHOT-006.
- STORE-SHOT-001 Home: today's reminders and the goals card with goal progress (HOME-002, HOME-006). Light theme.
- STORE-SHOT-002 The stretch session opened from a reminder, with some of its stretches checked (SESS-001 to SESS-003). Dark theme.
- STORE-SHOT-003 Daily goals, with goals partway done (GOAL-001, GOAL-003). Light theme.
- STORE-SHOT-004 The schedule editor on a repeating schedule, showing its interval, minute, window and slot preview (SCHED-011, SCHED-012). Dark theme.
- STORE-SHOT-005 Weekly trends with the period summary and the current streak (TREND-004 to TREND-006). Light theme.
There is no shot of the skipped-days screen.
Inputs
- STORE-SHOT-006 Every shot is rendered from frozen inputs, so the same commit always gives the same pixels:
- the time is Friday 16 October 2026, 9:40 AM, in America/New_York, read from a fixed clock
(
Clock.fixed, SCHED-016) whose zone is also set as the JVM's default time zone; - the locale is en-US (Robolectric qualifier and the JVM default), with the 12-hour clock;
- the device is Robolectric's
w411dp-h891dp-xxhdpi(density 3) on a pinned SDK, with native graphics; - the colour schemes are the app's fixed light and dark schemes, dynamic colour off;
- animations are off: each screen is composed straight into its final state, the animator duration scale is 0, and the capture waits until composition is idle;
- the data comes from one
ShowcaseFixturethat builds each screen's state directly: the seeded stretch library (LIB-002), three goals partway done today, one set-times schedule and one repeating schedule, and 30 days of completion history.
Invariant — The showcase never touches a database or randomness. No Room, no
StretchApp, no Random, no system clock: a screen that only takes a ViewModel is rendered
through its stateless content composable with state the fixture built.
- STORE-SHOT-007 The images are PNGs in fastlane/metadata/android/en-US/images/phoneScreenshots/
(fastlane supply's layout), named in listing order: 1_home.png, 2_session.png,
3_goals.png, 4_schedule.png, 5_trends.png. Their caption text lives only in
docs/store/captions.md, one entry per shot ID (STORE-SHOT-001 to
STORE-SHOT-005), which the screenshot test reads. The file's only other entry is the feature
graphic's text (STORE-GRAPHIC-003).
Layout
All sizes are pixels of the final image.
- STORE-SHOT-010 Each shot is one 1080 x 2340 image: a caption at the top and a framed phone screen below it, cut off by the bottom edge.
- STORE-SHOT-011 The background is solid #1361A7 on every shot, light and dark: no gradient and nothing else drawn on it.
Invariant — A shot's light or dark theme changes only the screen inside the frame, never the background behind it. - STORE-SHOT-012 The caption is left-aligned, 108 px from the top, with 79 px side margins (922 px wide). Headline: white, bold, 90 px, 1.18 line height, at most 3 lines. Supporting line: #CFE1F5, regular, 50 px, 1.35 line height, 22 px below the headline, at most 2 lines. - STORE-SHOT-013 The phone frame's top edge is at y = 650 px on every shot. The frame is centred horizontally and 922 px wide. Its bezel is #0A0A0B, 18 px thick on the top and sides, with no bottom bezel and 79 px radius top corners. The real screen, laid out at the device's natural size (411 x 891 dp), is scaled down to fit inside the bezel. - STORE-SHOT-014 There is no status bar, navigation bar or other device chrome.
Invariant — A caption that is too long fails the test instead of shrinking the text or pushing the frame down. Lines are counted as rendered (measured text layout at the caption's width, font and size), not as characters.
Store icon and feature graphic
Two more images, beside the screenshots in fastlane supply's images folder. Both are rendered
on the JVM on the STORE-SHOT-006 device (Robolectric, native graphics, SDK and qualifiers
pinned). The store icon is drawn by StoreArtTest with no clock, locale or data involved. The
feature graphic is composed by FeatureGraphicTest with all of STORE-SHOT-006's frozen inputs,
because it shows two of the app's screens.
Store icon
- STORE-ICON-001 The store icon is
fastlane/metadata/android/en-US/images/icon.png: exactly 512 x 512 px, a 32-bit PNG with an alpha channel (8-bit RGBA, PNG colour type 6). It is a full square: no rounded corners, mask, shadow or margin of its own (Google Play applies its own mask). The file is at most 1024 KB (1,048,576 bytes), Google Play's limit for the store icon. - STORE-ICON-002 It is drawn from the app's real adaptive launcher icon: the test inflates
@mipmap/ic_launcherand draws its background layer (@color/ic_launcher_background, #FFFFFF) and then its foreground layer (@drawable/ic_launcher_foreground). The monochrome layer is not drawn.
Invariant — The store icon is always rendered from the launcher icon and never hand-edited. The test inflates the launcher icon's own resources rather than a copy of their geometry or colours, so any change to the launcher icon changes the rendered store icon and fails the verify (STORE-ICON-004) until the store icon is re-recorded. - STORE-ICON-003 The launcher icon's viewport, its central 72 x 72 dp (the most a launcher's mask can show; the smaller 66 dp safe zone is what no mask clips), fills the image. The layers' 108 x 108 dp canvas is scaled by 512 / 72 px per dp, to 768 x 768 px, and drawn offset by -128 px on both axes, so the outer 18 dp on each side falls outside the image.
Feature graphic
A headline on the left and two of the app's phone screens on the right, tilted towards each other and running off the bottom edge, drawn like the screenshots' framed phones.
All sizes are pixels of the final image. A rotation is in degrees, positive clockwise, about the frame's bottom centre (the middle of its bottom edge, before rotating).
- STORE-GRAPHIC-001 The feature graphic is
fastlane/metadata/android/en-US/images/featureGraphic.png: exactly 1024 x 500 px, a 24-bit PNG with no alpha channel (8-bit RGB, PNG colour type 2). - STORE-GRAPHIC-002 The background is solid #1361A7, the screenshots' colour (STORE-SHOT-011), and runs to every edge: no gradient. On it, in drawing order: the text (STORE-GRAPHIC-003, STORE-GRAPHIC-004), the back phone and then the front phone (STORE-GRAPHIC-005), so the front phone partly covers the back one. Nothing else is drawn: no app icon, app name, logo, badge or device chrome.
Invariant — The feature graphic never shows the app icon. Google Play shows the app icon
beside the feature graphic and advises against repeating it, so the test draws neither the
launcher icon nor the store icon, in any form, anywhere on the graphic.
- STORE-GRAPHIC-003 The text is a headline and a supporting line. Their wording lives only
in docs/store/captions.md, in its ## STORE-GRAPHIC-003 entry (a
Headline: line and a Supporting: line, like the shots' captions, STORE-SHOT-007), which
the test reads. Both are claims: each of the two lines ends with one tag,
<!-- ID, ID -->, naming the requirements it rests on, and the test fails unless every cited
ID is defined by a spec page and not retired (the rule of STORE-LIST-003). The tag is not
part of the drawn text.
- STORE-GRAPHIC-004 The text block is left-aligned, its left edge at x = 64 px, and 380 px
wide. Headline: white (#FFFFFF), Roboto Bold (weight 700), 46 px, 52 px line height, at most
3 lines. Supporting line: #CFE1F5, Roboto Regular (weight 400), 21 px, 29 px line height,
18 px below the headline, at most 2 lines. Each line is its line height, the text centred in
it, nothing trimmed (where the font is taller than the line height, as the headline's is, the
text engine may keep its full ascent on the first line and descent on the last). The block is
centred vertically: its top is at (500 − block height) / 2 px, rounded down, where the block
height is the headline's and the supporting line's heights as laid out (measured at the
block's width, font and size) plus the 18 px gap.
Invariant — Text that does not fit its lines fails the test instead of shrinking or moving. As for the shots' captions (STORE-SHOT-014), lines are counted as rendered (measured text layout at the block's width, font and size), not as characters; nothing shrinks the text, widens the block or moves the phones to make it fit. - STORE-GRAPHIC-005 Two phones, each drawn like a shot's frame (STORE-SHOT-013, STORE-SHOT-014) at a smaller size: a #0A0A0B bezel 9 px thick on the top and sides, no bottom bezel, 36 px radius top corners; the screen inside it has 27 px radius top corners (the frame's radius less the bezel), is laid out at the device's natural size (411 x 891 dp) and is scaled down to fit the bezel's inside width, top-anchored, and is cut off by the frame's bottom edge. There is no status bar, navigation bar or other device chrome. Before rotating:
| Phone | Screen | Theme | Left | Top | Width | Height | Rotation |
|---|---|---|---|---|---|---|---|
| Back | The stretch session (STORE-SHOT-002) | Dark | 720 | 96 | 250 | 447 | +6° |
| Front | Home (STORE-SHOT-001) | Light | 520 | 50 | 270 | 485 | −4° |
The heights are the bezel plus the screen's width × 1672 / 886, rounded, so each phone shows
the same part of its screen as its shot does (a shot's screen area is 886 x 1672 px). After rotating, each frame's bottom edge lies wholly below the image (y > 500) and its
top and sides inside it, so the phones run off the bottom edge only. Each frame casts a drop
shadow, drawn under it and rotated with it: black at 35 % opacity (#59000000), offset 18 px
down, blur radius 42 px (Android's Paint.setShadowLayer; a Gaussian sigma of about 25 px).
The background, text, bezels and shadows are drawn outside the app theme; only the screens
are themed.
- STORE-GRAPHIC-006 The fonts are Roboto Bold and Roboto Regular from Roboto 2
(Roboto-Bold.ttf and Roboto-Regular.ttf, version 2.001047, from the googlefonts/roboto
repository), under the Apache License 2.0. The two files and their licence text are committed
in app/src/test/resources/store/fonts/. The test loads those files, never a system font,
and fails if either cannot be loaded.
- STORE-GRAPHIC-007 The two screens are the app's real composables, rendered the way their
shots are: the same stateless content composables with the same ShowcaseFixture state as
STORE-SHOT-001 and STORE-SHOT-002, in the shot's fixed theme with dynamic colour off, and with
every frozen input of STORE-SHOT-006 (clock, zone, locale, 12-hour clock, device, density,
no animations). They change whenever those screens or the fixture change.
Invariant — The feature graphic's screens are rendered from the showcase fixture, never pasted in from an image. Nothing drawn on the graphic comes from an image file: the test does not read, crop or scale a committed screenshot or any other image to make it (a verify run reads only the committed feature graphic, to compare). It shares the shots' rendering code rather than a copy of it, so a screen cannot drift from its shot.
Writing and checking the two images
- STORE-ICON-004 Roborazzi writes every image as RGBA, which the feature graphic must not
be, so the two tests (
StoreArtTestfor the store icon,FeatureGraphicTestfor the feature graphic) write and compare the images themselves, through one shared recorder, following the mode the Roborazzi Gradle task sets: ./gradlew recordRoborazziDebugwrites both files in the formats of STORE-ICON-001 and STORE-GRAPHIC-001, then reads each back and checks its size and colour type;./gradlew verifyRoborazziDebug(what CI runs) compares each rendered image pixel for pixel with the committed file, with no tolerance, and fails on any difference in size or pixels or on a missing file. On a failure it writes the rendered image toapp/build/outputs/roborazzi/asicon_actual.pngorfeatureGraphic_actual.png, where CI uploads it with the screenshot diffs (STORE-SHOT-008), and leaves the committed file untouched;- plain
./gradlew testrenders both images and writes and compares nothing. - STORE-ICON-005 On every run except a recording, a test reads the committed
icon.pngandfeatureGraphic.pngand fails unless each exists with exactly the size and PNG colour type of STORE-ICON-001 and STORE-GRAPHIC-001, and unlessicon.pngis within STORE-ICON-001's 1024 KB limit. The feature graphic has no file size check.
Recording and verifying
Invariant — A store screenshot is recorded only when a person asks for it, and reaches the default branch only through a pull request whose diff shows the new image. This section applies to the store icon and the feature graphic exactly as to the screenshots; together they are the store images.
Invariant — CI only verifies the store images; it never re-records them. No check that runs on a pull request or a release writes a store image. A recording runs only when a person applies the label (STORE-SHOT-009), and its commit reaches the pull request only through that person's own push.
- STORE-SHOT-008 Every pull request's build (
pr.yml) only verifies the committed store images (./gradlew verifyRoborazziDebug, which fails on any changed or missing screenshot, store icon or feature graphic) and, when it fails, uploads the rendered and diff images as thestore-screenshot-diffsartifact. It never records. - STORE-SHOT-009 Recording happens in one of two ways, both started by a person:
- In CI: a person applies the
record-store-screenshotslabel to a pull request from a branch of this repository (never a fork).record-store-screenshots.ymlruns./gradlew recordRoborazziDebugon the pull request's head, commits only the store images (the files in thephoneScreenshotsfolder,icon.pngandfeatureGraphic.png) asgithub-actions[bot], and force-pushes that commit to the side branchstore-screenshots/pr-<number>, based on the pull request's head. If no image changed it pushes nothing. Its job summary says which commit to bring in, and it always removes the label afterwards, so the label can be applied again for another recording. - Locally:
./gradlew recordRoborazziDebug, on Linux with JDK 17 as CI runs. Robolectric draws with its own fonts and renderer, but the host's JDK formats dates and times, and anti-aliasing can differ between operating systems.
Invariant — The recording workflow never pushes to the pull request's branch. A push made
with the workflow's token starts no pr.yml run, so a person brings the side branch's commit
into the pull request with their own push (git fetch origin store-screenshots/pr-<number> &&
git merge --ff-only FETCH_HEAD, or a cherry-pick). That push runs pr.yml, which verifies the
new images, and the pull request's diff shows them for review.
- STORE-SHOT-015 When a pull request from a branch of this repository closes, merged or
not, delete-store-screenshots-branch.yml deletes its side branch
store-screenshots/pr-<number> (STORE-SHOT-009). The repository's automatic deletion of head
branches does not cover it, because the side branch is never a pull request's head. A pull
request that never recorded has no side branch, and its absence is not a failure: the run
succeeds when the branch does not exist. Any other failure to delete fails the run. The job's
token can write contents and nothing else, and it runs for no fork's pull request (a fork's
pull request never records, so it has no side branch). Reopening a pull request does not bring
its side branch back; applying the label again records again.
Listing text
Invariant — The listing is never generated at release time. The release run only verifies and packages text and images that were already reviewed in pull requests: nothing in it writes, rewrites or records listing text or screenshots.
- STORE-LIST-001 The listing text is written by hand in
docs/store/listing.md, in three sections, exactly these and in this
order:
## Title,## Short descriptionand## Full description. Text before## Titleexplains the page and is not part of the listing. The title and the short description are one line each. Every non-blank line of the full description is one claim: a paragraph line or a-list item, ending with one tag that lists the requirement IDs it rests on, for example- Set a daily goal for each stretch. <!-- GOAL-001 -->. The title and the short description may carry a tag as well..github/scripts/store_listing.pycompiles the page intofastlane/metadata/android/en-US/title.txt,short_description.txtandfull_description.txt(fastlane supply's layout), with the tags removed, the claims' text kept as written, and runs of blank lines collapsed into one paragraph break. The three files are committed with the listing change that produced them; they are never edited by hand. - STORE-LIST-002 Every page in
docs/spec/except this folder'sREADME.md(the index, which defines no requirements) starts with a store marker as its first line, followed by a blank line, in exactly one of two forms:<!-- store: headline -->or<!-- store: omit -->. The marker is an HTML comment, so MkDocs renders nothing for it. Aheadlinepage describes a feature the listing must mention; anomitpage need not be mentioned (its IDs may still be cited). The headline pages are scheduling, goals, sessions, trends and widgets; skipping, notifications, library, screens, privacy and this page areomit(privacy and the store listing are not app features). - STORE-LIST-003 A tag may cite only an ID that a spec page defines (a top-level list item
opening with the bold ID,
- **GOAL-001** …) and that is not retired (a definition whose text opens with_Retired._). An ID mentioned only in prose is not defined. - STORE-LIST-004 Every
headlinepage has at least one claim citing at least one of the IDs it defines. - STORE-LIST-005 The compiled files keep within Google Play's limits, counted in characters (not bytes), without the one trailing newline each file ends with: title 30, short description 80, full description 4,000.
- STORE-LIST-006
python3 .github/scripts/store_listing.py --checkwrites nothing and fails, with a workflow error naming each problem, when any of STORE-LIST-001 to STORE-LIST-005 is broken or a committed file is missing or differs from what the script compiles. Every pull request's build (pr.yml) runs it. Without--checkthe script writes the three files, and writes nothing when any of STORE-LIST-001 to STORE-LIST-005 is broken. - STORE-LIST-007 When release-please publishes a release,
build-and-attachinrelease-please.ymlruns the same check on the release tag's checkout (the tag's own script), before anything is built or attached, and attachesstore-listing-<version>.zipto the GitHub Release beside the APK and AAB. The zip holds the committedfastlane/metadata/androidfolder as it is at the tag (text, screenshots, store icon and feature graphic, under that path), and the job then reads back that the release carries it. The zip's images are the ones committed underfastlane/metadata/android; they are verified only in pull requests (verifyRoborazziDebug, STORE-SHOT-008), and the release run does not re-verify them (it re-checks only the listing text). It needs no secret beyond those the job already uses. A manual backfill of a tag older than this requirement (one withoutdocs/store/listing.md) skips the check and the zip with a notice; any other failure of the check fails the job. - STORE-LIST-008 The app's name on the phone is the listing title, exactly: "Simple
Stretch". It is the
app_namestring resource (app/src/main/res/values/strings.xml), which the manifest's<application android:label>names; the launcher activity declares no label of its own, so it inherits it. Android shows this label under the launcher icon, in the app's system settings and in its notifications' header.
Invariant — The app's name is written once, in app_name, and equals the compiled
title.txt. The manifest names the resource, never a literal, and a test fails when
app_name and fastlane/metadata/android/en-US/title.txt differ, so the store title and the
launcher label can only change together.
Build checklist
- [x] One screenshot test per shot, each naming its STORE-SHOT ID (STORE-SHOT-001 to
STORE-SHOT-005), all on one
ShowcaseFixturewith a fixed clock and zone, en-US,w411dp-h891dp-xxhdpi, native graphics, fixed colour schemes (STORE-SHOT-006). - [x] Screens that take a ViewModel are rendered through behaviour-neutral stateless content
composables (STORE-SHOT-006 invariant);
StretchThemetakes the dark flag and dynamic colour as parameters, defaulting to the system's. - [x] Roborazzi's output directory is the fastlane
phoneScreenshotsfolder, so verify compares against the committed images (STORE-SHOT-007). - [x] Captions are parsed from
docs/store/captions.md; a test checks it holds exactly the five shot IDs and then the feature graphic's entry (STORE-SHOT-007, STORE-GRAPHIC-003). - [x] The canvas is composed at density 3 (1 dp = 3 px) at exactly 1080 x 2340 px; background, caption and frame are drawn outside the app theme (STORE-SHOT-010 to STORE-SHOT-013, STORE-SHOT-011 invariant).
- [x] A unit test measures captions as rendered lines and fails a too-long one (STORE-SHOT-012, STORE-SHOT-014 invariant).
- [x]
pr.ymlrunsverifyRoborazziDebugand uploadsapp/build/outputs/roborazzi/(the actual and_compareimages) when it fails; it never records (STORE-SHOT-008). - [x]
record-store-screenshots.ymlruns only on therecord-store-screenshotslabel on a same-repository pull request, commits only the store images (thephoneScreenshotsfolder,icon.pngandfeatureGraphic.png), pushes tostore-screenshots/pr-<number>and never to the pull request's branch, and always removes the label; the label is defined in.github/labels.repo.yml(STORE-SHOT-009 and its invariant). - [x]
delete-store-screenshots-branch.ymlruns onpull_requestclosedfor a same-repository pull request, withcontents: writeonly, deletesstore-screenshots/pr-<number>, and succeeds when that branch does not exist (STORE-SHOT-015). - [x] The five images are recorded and committed in the
phoneScreenshotsfolder, andverifyRoborazziDebugpasses against them (STORE-SHOT-007). - [x]
StoreArtTestinflates@mipmap/ic_launcherand draws its background and foreground layers, with the 72 dp viewport scaled to 512 px (STORE-ICON-002, STORE-ICON-003 and the invariant). - [x] The feature graphic is composed on the shots' pinned device and frozen inputs, its
layout values in one place (
FeatureGraphicSpec), its text in the committed Roboto Bold and Roboto Regular files (STORE-GRAPHIC-002 to STORE-GRAPHIC-006). - [x] The graphic's headline and supporting line are read from the
STORE-GRAPHIC-003entry ofdocs/store/captions.md, each with a tag; a test fails on a missing tag or on a cited ID that is undefined or retired (STORE-GRAPHIC-003). - [x] A test measures the graphic's text as rendered lines and fails text that does not fit (STORE-GRAPHIC-004 and its invariant).
- [x] The two phones' screens are the shots' own composables and fixture state, through code
shared with
StoreScreenshotTest(the screen, the theme, the frame and the scaled screen), and no image file is read; nothing draws the launcher or store icon (STORE-GRAPHIC-002 and STORE-GRAPHIC-007 invariants). - [x] The recorder writes the icon as RGBA and the feature graphic as RGB, records only in a
recording run, compares exactly in a verify run and writes
<name>_actual.pngtoapp/build/outputs/roborazzi/on a failure (STORE-ICON-004). - [x] A test checks the committed images' size and PNG colour type, and the icon's 1024 KB limit, skipped only in a recording run (STORE-ICON-005).
- [x]
icon.pngis recorded and committed, andverifyRoborazziDebugpasses against it (STORE-ICON-001). - [x]
featureGraphic.pngis re-recorded for the two-phone design and committed, andverifyRoborazziDebugpasses against it (STORE-GRAPHIC-001). - [x] The owner chose the two-phone design from mockups and delegated the review of the rendered images in the pull request (STORE-GRAPHIC-002 to STORE-GRAPHIC-005).
- [x] Every spec page except
README.mdstarts with<!-- store: headline -->or<!-- store: omit -->and a blank line (STORE-LIST-002). - [x]
store_listing.pyis standard-library Python with unit tests citing the STORE-LIST IDs they cover, run bypr.ymlwith the other script tests (STORE-LIST-001 to STORE-LIST-006). - [x]
pr.ymlrunsstore_listing.py --check(STORE-LIST-006). - [x]
build-and-attachruns the check at the tag and attaches and reads backstore-listing-<version>.zip; no step in it writes listing text (STORE-LIST-007 and the invariant). - [x] A Robolectric test checks that the manifest's application label is
app_name, the launcher activity has no label of its own, andapp_nameequals the compiledtitle.txt(STORE-LIST-008 and its invariant). - [x] Store listing reviewed: the draft text in
docs/store/listing.mdclaims only what the cited requirements say (STORE-LIST-003, STORE-LIST-004).