Files
kegel/store/screenshots/make.js
marcuspaico 55999b7458
Some checks failed
Build & Release APK / build (push) Has been cancelled
feat: store release setup — EAS config, listing copy, screenshots, new icon
- 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
2026-09-07 16:10:27 +10:00

152 lines
6.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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();