chore(send-to-readest): add Chrome Web Store screenshot generator (#4664)

Adds store/ with a reproducible generator for the Web Store listing
screenshots: composites the popup capture onto an on-brand (readest.com)
background with a headline, renders via headless Chromium, and writes 24-bit
no-alpha PNGs at 1280x800 and 640x400 into store/out/ (gitignored).

- store/generate.mjs - config + compositing + render (Playwright + ImageMagick)
- store/popup.png     - raw popup capture (700x508); its status strip is blanked
                        and re-lettered from CONFIG so it stays in sync with code
- store/README.md     - usage + requirements
- package.json        - adds `pnpm store:screenshots`
- .gitignore          - ignores store/out/

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-19 18:58:24 +08:00
committed by GitHub
parent 6caa376f82
commit b7585ac46d
5 changed files with 188 additions and 0 deletions
@@ -6,6 +6,9 @@ dist/
# Versioned upload archives produced by `pnpm zip`.
*.zip
# Generated store screenshots (`pnpm store:screenshots`).
store/out/
# Per-package node_modules (the workspace hoists most of it to the root,
# but pnpm still drops a `.pnpm` symlink tree here).
node_modules/
@@ -8,6 +8,7 @@
"dev": "webpack --watch --mode development",
"clean": "rimraf dist",
"zip": "pnpm build && node scripts/zip.mjs",
"store:screenshots": "node store/generate.mjs",
"i18n:extract": "node scripts/extract-i18n.mjs",
"i18n:check": "node scripts/extract-i18n.mjs --check"
},
@@ -0,0 +1,41 @@
# Store screenshots
Generates the Chrome Web Store listing screenshots for the **Send to Readest**
extension — the popup composited onto an on-brand background (matching
readest.com) with a headline.
## Run
```bash
pnpm store:screenshots # from extensions/send-to-readest/
```
Outputs to `store/out/` (gitignored):
- `send-to-readest-1280x800.png`
- `send-to-readest-640x400.png`
Both are 24-bit PNG, sRGB, **no alpha** — what the Web Store requires for the
"Global screenshots" slot.
## Requirements
- **@playwright/test** — already a monorepo dev dependency (provides headless
Chromium).
- **ImageMagick** `magick` on PATH — `brew install imagemagick`.
## Customising
Edit `CONFIG` at the top of `generate.mjs`:
- `headline` — the left-hand headline, one array entry per line.
- `status` — the popup success line. **Keep `status.text` in sync with the
success message in `src/popup/popup.ts`.**
- `popupDisplayW`, `bg` — popup size and brand background.
## The popup asset
`popup.png` is a raw capture of the extension popup, cropped to its dark card
(700×508). The generator blanks its status strip and overlays
`CONFIG.status.text`, so re-capturing the popup only means replacing `popup.png`
with a new crop of the same size/layout — no image editing needed.
@@ -0,0 +1,143 @@
#!/usr/bin/env node
/**
* Generate Chrome Web Store screenshots for the Send to Readest extension.
*
* Composites the popup capture (store/popup.png) onto an on-brand background
* (matching readest.com) with a headline, renders it with headless Chromium,
* and writes 24-bit PNGs (no alpha) at the two Web Store sizes into store/out/.
*
* pnpm store:screenshots # from extensions/send-to-readest/
*
* Requires:
* - @playwright/test (already a monorepo dev dependency)
* - ImageMagick `magick` on PATH (brew install imagemagick)
*
* To tweak the result, edit CONFIG below. Keep CONFIG.status.text in sync with
* the success message in src/popup/popup.ts.
*/
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from '@playwright/test';
const HERE = dirname(fileURLToPath(import.meta.url));
const OUT = join(HERE, 'out');
const POPUP = join(HERE, 'popup.png');
const CONFIG = {
bg: '#fdf9f3',
// Left-hand headline, one array entry per rendered line.
headline: ['Send any page', 'to your Readest', 'library.'],
// Popup success status (keep in sync with src/popup/popup.ts).
status: { text: 'Saved to your library.', color: '#78db88' },
// popup.png is a 700x508 capture; these source coords place the status line
// and define the dark strip that is blanked, then re-lettered as an overlay.
popupSrc: { w: 700, h: 508, statusLeft: 33, statusTop: 420, statusFontPx: 27, coverTop: 400 },
popupDisplayW: 560, // how wide the popup renders inside the 1280x800 canvas
};
const SIZES = [
{ w: 1280, h: 800 },
{ w: 640, h: 400 },
];
function magick(args) {
execFileSync('magick', args, { stdio: ['ignore', 'ignore', 'inherit'] });
}
function ensureMagick() {
try {
execFileSync('magick', ['-version'], { stdio: 'ignore' });
} catch {
console.error('ImageMagick `magick` not found on PATH. Install it: brew install imagemagick');
process.exit(1);
}
}
function buildHtml(blankedPopupPath) {
const s = CONFIG.popupDisplayW / CONFIG.popupSrc.w;
const left = Math.round(CONFIG.popupSrc.statusLeft * s);
const top = Math.round(CONFIG.popupSrc.statusTop * s);
const font = (CONFIG.popupSrc.statusFontPx * s).toFixed(1);
return `<!doctype html><html><head><meta charset="utf-8" /><style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 1280px; height: 800px; }
.canvas {
position: relative; width: 1280px; height: 800px; overflow: hidden;
background:
radial-gradient(135% 120% at -5% -12%, rgba(249,190,116,0.55) 0%, rgba(249,190,116,0) 42%),
radial-gradient(130% 120% at 106% 112%, rgba(244,188,198,0.62) 0%, rgba(244,188,198,0) 46%),
${CONFIG.bg};
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased;
}
.headline {
position: absolute; left: 90px; top: 50%; transform: translateY(-50%); width: 560px;
color: #1c1c1e; font-weight: 800; font-size: 74px; line-height: 1.07; letter-spacing: -2px;
}
.popup-wrap { position: absolute; right: 40px; top: 50%; transform: translateY(-50%); width: ${CONFIG.popupDisplayW}px; }
.pointer {
position: absolute; top: -13px; right: 104px; width: 0; height: 0;
border-left: 15px solid transparent; border-right: 15px solid transparent; border-bottom: 14px solid #1a1a1c;
}
.popup-frame { position: relative; }
.popup {
display: block; width: ${CONFIG.popupDisplayW}px; border-radius: 14px;
box-shadow: 0 34px 64px -16px rgba(60,42,18,0.40), 0 14px 26px -10px rgba(60,42,18,0.26);
}
.status { position: absolute; left: ${left}px; top: ${top}px; line-height: 1; font-weight: 400;
color: ${CONFIG.status.color}; font-size: ${font}px; }
</style></head><body>
<div class="canvas">
<div class="headline">${CONFIG.headline.join('<br />')}</div>
<div class="popup-wrap">
<div class="pointer"></div>
<div class="popup-frame">
<img class="popup" src="file://${blankedPopupPath}" />
<div class="status">${CONFIG.status.text}</div>
</div>
</div>
</div>
</body></html>`;
}
async function main() {
ensureMagick();
if (!existsSync(POPUP)) {
console.error(`missing popup asset: ${POPUP}`);
process.exit(1);
}
rmSync(OUT, { recursive: true, force: true });
mkdirSync(OUT, { recursive: true });
// 1. Blank the popup's status strip so the configured status can be overlaid.
const blanked = join(OUT, '_popup-blanked.png');
const { w, h, coverTop } = CONFIG.popupSrc;
magick([POPUP, '-fill', '#1a1a1a', '-draw', `rectangle 0,${coverTop} ${w},${h}`, blanked]);
// 2. Render the composite at 2x for crisp downscaling.
const htmlPath = join(OUT, '_composite.html');
writeFileSync(htmlPath, buildHtml(blanked));
const raw = join(OUT, '_render@2x.png');
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 });
await page.goto('file://' + htmlPath);
await page.waitForTimeout(300);
await page.screenshot({ path: raw });
await browser.close();
// 3. Downscale + flatten to each Web Store size (24-bit, no alpha).
for (const { w: ow, h: oh } of SIZES) {
const out = join(OUT, `send-to-readest-${ow}x${oh}.png`);
magick([raw, '-resize', `${ow}x${oh}`, '-background', CONFIG.bg, '-flatten',
'-alpha', 'remove', '-alpha', 'off', '-strip', out]);
console.log(`wrote ${out}`);
}
for (const f of [blanked, htmlPath, raw]) rmSync(f, { force: true });
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB