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
151
store/screenshots/make.js
Normal 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();
|
||||
BIN
store/screenshots/out/ios/1.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
store/screenshots/out/ios/2.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
store/screenshots/out/ios/3.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
store/screenshots/out/ios/4.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
store/screenshots/out/ios/5.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
store/screenshots/out/play/1.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
store/screenshots/out/play/2.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
store/screenshots/out/play/3.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
store/screenshots/out/play/4.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
store/screenshots/out/play/5.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
store/screenshots/out/play/feature.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
store/screenshots/raw/home.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
store/screenshots/raw/session-hold.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
store/screenshots/raw/session-push.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
store/screenshots/raw/settings.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
store/screenshots/raw/streak.png
Normal file
|
After Width: | Height: | Size: 264 KiB |