feat: store release setup — EAS config, listing copy, screenshots, new icon
Some checks failed
Build & Release APK / build (push) Has been cancelled

- eas.json production/simulator profiles; iOS plist flags; EAS project linked
- literal versionCode/versionName in build.gradle so EAS can parse it; Gitea CI
  stamps the run number with sed instead of env-driven gradle expressions
- store/: listing copy + keyword rationale, screenshot composer (headless
  Chrome), raw simulator captures, rendered App Store / Play slides, feature
  graphic, privacy policy source, icon generator
- replace Expo placeholder icon with the session-ring mark; regenerate Android
  launcher/splash res and theme colours

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WontRuPqKcod9XK4ZN9MXv
This commit is contained in:
marcuspaico
2026-09-07 16:10:27 +10:00
parent 6a75db0aae
commit 55999b7458
55 changed files with 421 additions and 12 deletions

151
store/screenshots/make.js Normal file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env node
// Compose store screenshots from raw simulator captures.
//
// node store/screenshots/make.js
//
// Inputs: store/screenshots/raw/<name>.png (iPhone 6.9" captures, 1320×2868)
// Outputs: store/screenshots/out/ios/<n>.png 1320×2868 (App Store, 6.9")
// store/screenshots/out/play/<n>.png 1080×1920 (Google Play phone)
// store/screenshots/out/play/feature.png 1024×500 (Play feature graphic)
//
// Renders HTML through headless Chrome so we can use real typography. Colours
// come from src/lib/theme.ts.
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const ROOT = __dirname;
const RAW = path.join(ROOT, "raw");
const OUT = path.join(ROOT, "out");
const TMP = path.join(ROOT, ".tmp");
const C = {
bg: "#160B0B",
surface: "#241213",
border: "#42201F",
text: "#F7ECEA",
muted: "#A98A86",
accent: "#E7402E",
push: "#FF9D3B",
};
const slides = [
{
raw: "home.png",
h: "Kegels that get<br>harder as you do",
sub: "Holds grow automatically every 3 sessions",
},
{
raw: "session-hold.png",
h: "Guided by timer<br>and haptics",
sub: "Train with the screen off or eyes closed",
},
{
raw: "session-push.png",
h: "Squeezes and<br>reverse kegels",
sub: "Both sides of the pelvic floor, every session",
},
{
raw: "streak.png",
h: "Build the habit",
sub: "Day streak, best streak, two-week history",
},
{
raw: "settings.png",
h: "Free. Offline.<br>No account.",
sub: "Nothing leaves your phone. No ads, ever.",
},
];
const dataUri = (file) => "data:image/png;base64," + fs.readFileSync(file).toString("base64");
function slideHtml({ w, h, cap, sub, img, phoneW, capSize, subSize, top, gap }) {
return `<!doctype html><html><head><meta charset="utf-8"><style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-font-smoothing:antialiased; }
html, body { width:${w}px; height:${h}px; overflow:hidden; background:${C.bg}; }
body { font-family:-apple-system,"SF Pro Display","Helvetica Neue",Helvetica,Arial,sans-serif; }
.slide { width:${w}px; height:${h}px; position:relative; display:flex; flex-direction:column; align-items:center; overflow:hidden; }
.glow { position:absolute; left:50%; top:${top + capSize * 2.6}px; width:${w * 1.2}px; height:${w * 1.2}px;
transform:translateX(-50%); border-radius:50%;
background:radial-gradient(circle, ${C.accent}33 0%, ${C.accent}14 30%, transparent 62%); }
.cap { position:relative; margin-top:${top}px; width:${w * 0.86}px; text-align:center; }
.h { font-size:${capSize}px; font-weight:800; color:${C.text}; line-height:1.04; letter-spacing:-0.02em; }
.sub { font-size:${subSize}px; color:${C.muted}; margin-top:${subSize * 0.7}px; font-weight:500; line-height:1.3; }
.ph { position:relative; margin-top:${gap}px; width:${phoneW}px; border-radius:${phoneW * 0.11}px;
border:${Math.round(phoneW * 0.012)}px solid ${C.border}; overflow:hidden;
box-shadow:0 ${phoneW * 0.06}px ${phoneW * 0.16}px rgba(0,0,0,.6); background:${C.bg}; }
.ph img { display:block; width:100%; }
</style></head><body><div class="slide">
<div class="glow"></div>
<div class="cap"><div class="h">${cap}</div><div class="sub">${sub}</div></div>
<div class="ph"><img src="${img}"></div>
</div></body></html>`;
}
function featureHtml(icon) {
return `<!doctype html><html><head><meta charset="utf-8"><style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-font-smoothing:antialiased; }
html, body { width:1024px; height:500px; overflow:hidden; background:${C.bg}; }
body { font-family:-apple-system,"SF Pro Display","Helvetica Neue",Helvetica,Arial,sans-serif; }
.wrap { width:1024px; height:500px; display:flex; align-items:center; justify-content:center; gap:56px; position:relative; overflow:hidden; }
.glow { position:absolute; left:50%; top:50%; width:900px; height:900px; transform:translate(-50%,-50%); border-radius:50%;
background:radial-gradient(circle, ${C.accent}33 0%, ${C.accent}10 35%, transparent 65%); }
img { position:relative; width:220px; height:220px; border-radius:50px; box-shadow:0 20px 60px rgba(0,0,0,.6); }
.t { position:relative; }
.brand { font-size:88px; font-weight:800; color:${C.text}; letter-spacing:-0.03em; line-height:1; }
.brand span { color:${C.accent}; }
.tag { font-size:34px; color:${C.muted}; margin-top:18px; font-weight:500; }
</style></head><body><div class="wrap">
<div class="glow"></div>
<img src="${icon}">
<div class="t"><div class="brand">Him<span>.</span></div>
<div class="tag">Kegel exercises for men.<br>Free, offline, no account.</div></div>
</div></body></html>`;
}
function render(html, w, h, out) {
fs.mkdirSync(TMP, { recursive: true });
const file = path.join(TMP, path.basename(out, ".png") + ".html");
fs.writeFileSync(file, html);
execFileSync(CHROME, [
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
"--force-device-scale-factor=1",
`--window-size=${w},${h}`,
`--screenshot=${out}`,
"file://" + file,
], { stdio: "ignore" });
}
function main() {
for (const s of slides) {
const f = path.join(RAW, s.raw);
if (!fs.existsSync(f)) throw new Error(`missing raw capture: ${f}`);
}
fs.mkdirSync(path.join(OUT, "ios"), { recursive: true });
fs.mkdirSync(path.join(OUT, "play"), { recursive: true });
slides.forEach((s, i) => {
const img = dataUri(path.join(RAW, s.raw));
// App Store 6.9": caption then phone; phone overflows the bottom edge on purpose.
render(
slideHtml({ w: 1320, h: 2868, cap: s.h, sub: s.sub, img, phoneW: 1100, capSize: 104, subSize: 46, top: 150, gap: 96 }),
1320, 2868, path.join(OUT, "ios", `${i + 1}.png`),
);
// Play phone (16:9): same layout, smaller phone.
render(
slideHtml({ w: 1080, h: 1920, cap: s.h, sub: s.sub, img, phoneW: 720, capSize: 78, subSize: 36, top: 110, gap: 70 }),
1080, 1920, path.join(OUT, "play", `${i + 1}.png`),
);
});
const icon = dataUri(path.join(ROOT, "..", "..", "assets", "images", "icon.png"));
render(featureHtml(icon), 1024, 500, path.join(OUT, "play", "feature.png"));
fs.rmSync(TMP, { recursive: true, force: true });
console.log(`wrote ${slides.length} iOS + ${slides.length} Play slides and feature.png to ${OUT}`);
}
main();

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB