App Store / Play Store screenshot generator
A dev-only tool that renders the tracking app's marketing screenshots — framed,
branded, pixel-exact store tiles — straight from the real app UI, so the store
listings never drift from what ships. It lives at the route
/debug/screenshots and is
excluded from the production / native build (see
Build exclusion).
Each tile is a marketing composition: the enode PRO lockup, a category label + a headline, the enode dot-cloud texture, and one or more real device frames with the actual app showing through. Pick a screen, pick a theme, and download each tile as a pixel-exact JPEG at the exact store resolution.
Using it
Open /debug/screenshots in the
running dev app (it needs the debug area enabled — it is on by default in npm run dev). Then:
- Pick a screen from the tab row (Bar path tracking, Velocity-based training, Performance insights, Video review, Multi-station, One platform).
- Pick a theme with the Dark / Light toggle — the whole tile (background, chrome, and the app itself) renders in that theme.
- Download JPEG per tile, or Download all devices for the current screen.
Files are named
<screen>-<device>-<theme>-<W>x<H>.jpg.
The on-screen previews are shrunk; the capture always shoots the tile at its full export resolution, so the downloaded file is pixel-exact for the store.
The screens
Every screen renders the real app components driven by a fixture session (no mock-up markup where a real component exists — see Real components).
| Screen | Category | App view |
|---|---|---|
technique | Bar path tracking | Insights → Technique (Sagittal/Frontal bar path) |
set-tracking | Velocity-based training | Active tracking (set list + coaching cards) |
insights | Performance insights | Insights → Overview (stat cards, positive trends) |
video-review | Video review | Full-screen set review (video + timeline + charts) |
stations | Multi-station | The station shell (tablet-only) |
ecosystem | One platform | Composite hero — three devices at once |
stations is tablet-only (padOnly) — the tile grid hides the phone
targets for it. ecosystem is a hero (hero) — a single composite tile
that draws its own device frames (see Hero composition).
Device targets
Store resolutions and the frame each target uses live in
devices.ts:
| Target | Export px | Frame |
|---|---|---|
| iPhone 6.9" | 1320 × 2868 | iPhone |
| iPad 13" (landscape) | 2732 × 2048 | iPad |
| Android phone | 1080 × 1920 | iPhone (reused) |
| Android tablet (landscape) | 1920 × 1200 | iPad (reused) |
| Mac (Studio Display) | 2880 × 1800 | Mac |
| Ecosystem (landscape) | 2880 × 1920 | — (hero draws its own) |
| Ecosystem (iPhone) | 1320 × 2868 | — (hero draws its own) |
Android reuses the Apple frames (there is no Android frame). Each target's
screenW × screenH (the app's logical render size) is sized to match its frame
cutout's aspect so the app fills the cutout without stretch.
Architecture
The pieces, under apps/tracking/src/app/debug/screenshots/:
page.screenshot.tsx— the route. Owns the screen tabs, the Dark/Light toggle, the tile grid, and the per-tile + "download all" export flows. The.screenshot.tsxextension is what gates the whole subtree out of prod (see Build exclusion).devices.ts— the export targets (resolution, logical size, frame) + the frameless hero targets.screens.tsx— theSCREENSregistry and every screen's render. Each screen composes real app components for the target's device class.marketing-tile.tsx— the tile shell: dot-cloud background, branding + headline, theme, and the device region (fits a single frame to the space, or hands off to a hero).device-mock.tsx— one framed device: the app screen behind a real device-frame PNG, aligned to the frame's transparent cutout. Reads the theme from context.frames.ts— the device-frame descriptors (image + cutout rect + aspect).fixture-session.ts— the deterministic clean-pullWorkoutSessionthat drives the real screens.app-chrome.tsx— the small header/nav chrome the app doesn't export as standalone components, reproduced (top bar, "Insights" header, bottom-nav pills).sample-data.ts— the clean bar-path curve, the set-timeline reps, and the set-list rows.export.ts— the pixel-exact JPEG capture (dynamic-importsmodern-screenshot).assets/— the dot-cloud SVG and the video-mock placeholder.frames/— the three device-frame PNGs.
Real components, fixture data
The screens mount the actual app route components — TechniqueTab,
PerformanceTab, SessionStats, the coaching cards, the station shell, etc. —
not look-alikes. They are driven by one fixture WorkoutSession from
fixture-session.ts,
so the charts are exactly what the app renders. No app code was changed to
support this.
Only where the app has no reusable component (route-level headers baked into
InsightsSheet / ExerciseDetailView, the workouts list's store-coupled
exercise names, the SetReviewView layout that relies on viewport media queries)
is the chrome reproduced in app-chrome.tsx
/ screens.tsx.
Three fixture facts worth knowing (all in fixture-session.ts):
- The load metric is selected by
dimension === "mass"— the other loading metric is flywheel inertia (g·m²), which would render "60000 g·m²". - Power / force are stored per-kg —
powerPeak/forcePeakareloadingFactormetrics, someasurementDisplayValuemultiplies the stored value by the set load. Storing absolute W/N shows load× too high. - The bar path is derived from raw
strengthsamples ({ t, s, a, v, q }) by the real chart service — the fixture authors the samples, not a 2-D path.
To use a real captured session instead of the synthetic one, replace
cleanSessionFixture()'s return with a real WorkoutSession; nothing else
changes.
Device frames
The frames are Apple design-mockup PNGs in frames/: a transparent screen
cutout + transparent exterior, with the opaque bezel/notch/stand between.
device-mock.tsx
places the app screen behind the frame, aligned to the cutout, and paints the
frame <img> on top; the frame's rounded bezel masks the app's square corners.
Each frame's cutout rectangle (in
frames.ts) was measured
from the PNG's alpha channel — flood-fill the transparent screen region, take its
bounding box, express it as fractions of the image.
Dot-cloud background
The faint enode dot-cloud texture behind every tile is
assets/dot-cloud.svg
— the brand dot field, recolored to white dots on a transparent background.
marketing-tile.tsx
lays it full-bleed behind the content at low opacity. In light mode it is
filter: invert(1) (dark dots on white).
Themes
The Dark / Light toggle swaps the whole tile. MarketingTile provides the theme
through ScreenshotThemeContext; every DeviceMock (including the hero's nested
ones) reads it and pins .theme-dark / .theme-light on the app subtree, so the
app renders in that exact theme regardless of the viewer's prefers-color-scheme
— exports are deterministic. The tile background, branding text, and dot-cloud
recolor with the theme; the export background is forced white in light mode.
Hero composition
The ecosystem screen is a frameless hero (hero: true): the tile skips the
single-device wrapper and the screen (HeroEcosystem) draws its own device
frames — an iMac (Studio Display frame) showing Insights, an iPhone showing
velocity-based training, and an iPad showing technique. It renders two tiles
(landscape + iPhone portrait); the portrait branch is a layered scene that bleeds
off the tile edges. To bleed past the tile's content padding, the hero drops the
device region's overflow-hidden (the tile still clips) and the branding sits
above the composition (z-20).
The "iMac" is currently the Studio Display frame (a monitor on a stand); there is no dedicated iMac frame. Drop one into
frames/and wire it inframes.ts+devices.tsto swap it.
Build exclusion
Nothing here ships in the app. The route file is page.screenshot.tsx, and
next.config.ts only adds the
screenshot.tsx page extension when the debug area is enabled. In a normal
prod / native next build the flag is unset, so page.screenshot.tsx is not a
page — nothing imports it or its subtree, so the whole thing (including
modern-screenshot, the frame PNGs, the dot-cloud SVG, and the video mock) is
tree-shaken out of out/ and the Capacitor apps.
This is why every asset lives under the screenshots subtree (imported as an
ES module, never in public/). public/ is copied verbatim into out/; an
imported asset is only emitted if reachable code imports it.
To verify after adding assets:
npm run build tracking
# 0 matches each = excluded:
find apps/tracking/out -iname '*frame-*' -o -iname '*dot-cloud*' -o -iname '*video-mock*'
Extending
- Add a screen: add an entry to
SCREENSinscreens.tsxwith arender(target)that composes the app fortarget.deviceClass. SetpadOnlyto hide phone tiles, orherofor a frameless composite tile. - Replace the video still: overwrite
assets/video-mock.png(any aspect — it'sobject-covercropped). If the filename/type changes, update the oneimport videoMock from …line inscreens.tsx. - Add / replace a device frame: drop the PNG into
frames/, measure its cutout (flood-fill the alpha), add a descriptor inframes.ts, and reference it from a target indevices.ts(sizescreenW/Hto the cutout aspect). - Change a store resolution: edit the target's
exportW/exportHindevices.ts— the tile fits itself, nothing else changes.