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
86 lines
4.3 KiB
JavaScript
86 lines
4.3 KiB
JavaScript
#!/usr/bin/env node
|
||
// Generate the app icon set from one SVG mark, rendered through headless Chrome.
|
||
//
|
||
// node store/icons/make.js
|
||
//
|
||
// Writes into assets/images/:
|
||
// icon.png 1024×1024 opaque (iOS + fallback)
|
||
// android-icon-foreground.png 1024×1024 alpha (adaptive foreground, mark in the 66% safe zone)
|
||
// android-icon-monochrome.png 1024×1024 alpha (themed icon, white mark)
|
||
// splash-icon.png 512×512 alpha (splash, mark only)
|
||
//
|
||
// The mark is the session ring from the app: a thick accent ring with a filled
|
||
// core — "squeeze". Colours from src/lib/theme.ts. After running, regenerate the
|
||
// committed Android res with `npx expo prebuild -p android --no-install`.
|
||
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const { execFileSync } = require("child_process");
|
||
|
||
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
||
const OUT = path.join(__dirname, "..", "..", "assets", "images");
|
||
const TMP = path.join(__dirname, ".tmp");
|
||
|
||
const C = { bg: "#160B0B", bg2: "#2A1112", accent: "#E7402E", accentDeep: "#B8271A", text: "#F7ECEA" };
|
||
|
||
// size: canvas px; scale: fraction of canvas the mark spans; color overrides for monochrome.
|
||
function mark(size, scale, { ring = C.accent, core = C.accent, glow = true } = {}) {
|
||
const c = size / 2;
|
||
const R = (size * scale) / 2; // outer radius of the ring
|
||
const stroke = R * 0.3;
|
||
const ringR = R - stroke / 2;
|
||
const coreR = R * 0.32;
|
||
return `
|
||
<defs>
|
||
<radialGradient id="g" cx="50%" cy="50%" r="50%">
|
||
<stop offset="0%" stop-color="${ring}" stop-opacity="0.35"/>
|
||
<stop offset="100%" stop-color="${ring}" stop-opacity="0"/>
|
||
</radialGradient>
|
||
</defs>
|
||
${glow ? `<circle cx="${c}" cy="${c}" r="${R * 1.35}" fill="url(#g)"/>` : ""}
|
||
<!-- open arc, ~82% of the ring, like the session progress ring mid-hold; gap at the top-right, dash starts at 12 o'clock -->
|
||
<circle cx="${c}" cy="${c}" r="${ringR}" fill="none" stroke="${ring}" stroke-width="${stroke}" stroke-linecap="round"
|
||
stroke-dasharray="${2 * Math.PI * ringR * 0.82} ${2 * Math.PI * ringR}" transform="rotate(-90 ${c} ${c})"/>
|
||
<circle cx="${c}" cy="${c}" r="${coreR}" fill="${core}"/>`;
|
||
}
|
||
|
||
function svg(size, body, bg) {
|
||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||
${bg ? `<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0%" stop-color="${C.bg2}"/><stop offset="100%" stop-color="${C.bg}"/></linearGradient></defs>
|
||
<rect width="${size}" height="${size}" fill="url(#bg)"/>` : ""}
|
||
${body}
|
||
</svg>`;
|
||
}
|
||
|
||
function render(svgText, size, out, transparent) {
|
||
fs.mkdirSync(TMP, { recursive: true });
|
||
const file = path.join(TMP, path.basename(out, ".png") + ".html");
|
||
fs.writeFileSync(
|
||
file,
|
||
`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;width:${size}px;height:${size}px;overflow:hidden;background:${transparent ? "transparent" : C.bg}}svg{display:block}</style></head><body>${svgText}</body></html>`,
|
||
);
|
||
const args = [
|
||
"--headless=new", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=1",
|
||
`--window-size=${size},${size}`, `--screenshot=${out}`,
|
||
];
|
||
if (transparent) args.push("--default-background-color=00000000");
|
||
args.push("file://" + file);
|
||
execFileSync(CHROME, args, { stdio: "ignore" });
|
||
}
|
||
|
||
function main() {
|
||
// iOS: opaque, mark fills ~62% of the canvas (Apple masks the corners itself).
|
||
render(svg(1024, mark(1024, 0.62), true), 1024, path.join(OUT, "icon.png"), false);
|
||
// Android adaptive foreground: content must sit inside the central 66% safe zone.
|
||
render(svg(1024, mark(1024, 0.46, { glow: false })), 1024, path.join(OUT, "android-icon-foreground.png"), true);
|
||
// Android monochrome: single-colour silhouette, same safe zone.
|
||
render(svg(1024, mark(1024, 0.46, { ring: "#FFFFFF", core: "#FFFFFF", glow: false })), 1024, path.join(OUT, "android-icon-monochrome.png"), true);
|
||
// Splash: mark only, on transparent; app.json sets the background colour.
|
||
render(svg(512, mark(512, 0.8, { glow: false })), 512, path.join(OUT, "splash-icon.png"), true);
|
||
fs.rmSync(TMP, { recursive: true, force: true });
|
||
console.log("wrote icon.png, android-icon-foreground.png, android-icon-monochrome.png, splash-icon.png");
|
||
}
|
||
|
||
main();
|