// Renders the Chrome Web Store listing images: the promo tiles from the brand // SVGs, and one 1280x800 listing screenshot per `store` entry in // scripts/shots.mjs. The // compositions are assembled in scripts/compose.mjs terms by nesting // brand/mark.svg, brand/sticker.svg, and the generated brand/wordmark.svg // into a paper-colored canvas, so the store art can never drift from the // canonical logo files. Listing copy lives in the store listing itself, not // in art; the only text here is the wordmark. // // Run npm run wordmark first when brand sources change (the prek hooks // order this correctly): the marquee nests the generated wordmark file. // // Usage: node scripts/render-cws.mjs (also: npm run cws) // Set CHROME to point at a specific Chrome/Chromium binary. import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { compose, nest, nestPng, nestWordmark } from './compose.mjs' import { findChrome, pngSize } from './render-icons.mjs' import { rasterize } from './rasterize.mjs' import { capturePath, storeShots, storeTilePath } from './shots.mjs' /** * One listing screenshot: the real popup capture from the status docs * (docs/img/, re-captured by scripts/capture-status-docs.mjs) beside the brand * lockup, framed like the site's cards — ink border, solid offset shadow, a * slight tilt. The frame follows the capture's own aspect ratio, so a * re-capture of a taller or wider popup is never squashed to fit. * * The captures come from the same list the frontpage gallery renders * (scripts/shots.mjs), so the store and substandard.blog cannot advertise two * different popups. */ function screenshotTile(shot) { const capture = capturePath(shot) return { width: 1280, height: 800, dest: storeTilePath(shot), body: () => { const size = pngSize(readFileSync(resolve(capture))) const [x, w] = [570, 680] const h = Math.round((w * size.height) / size.width) const y = Math.round((800 - h) / 2) return ( nest('brand/sticker.svg', 135, 190, 300) + nestWordmark(65, 580, 440) + `` + `` + nestPng(capture, x, y, w, h) + `` + '' ) }, } } const TILES = { 'promo-small': { width: 440, height: 280, dest: 'brand/cws/promo-small.png', // Sticker over wordmark, the stack optically centred: at 440x280 there is // no room to set the wordmark beside the sticker without the name coming // out smaller than the mark, which is backwards for a tile whose whole job // is to say what the thing is called. The marquee, at 2.5:1, has the width // for the horizontal lockup and keeps it. body: () => nest('brand/sticker.svg', 160, 25, 120) + nestWordmark(70, 212, 300), }, marquee: { width: 1400, height: 560, dest: 'brand/cws/marquee.png', body: () => nest('brand/sticker.svg', 150, 110, 340) + nestWordmark(560, 280, 760), }, // A screenshot tile per store shot, keyed by capture name so a shot that // leaves the list takes its tile's name with it. ...Object.fromEntries(storeShots().map((shot) => [shot.name, screenshotTile(shot)])), } function main() { const chrome = findChrome() const work = mkdtempSync(join(tmpdir(), 'substandard-cws-')) try { for (const [name, tile] of Object.entries(TILES)) { const { opaque, png } = rasterize( chrome, work, name, compose(tile), tile.width, tile.height, ) const dim = pngSize(png) if (dim.width !== tile.width || dim.height !== tile.height) { throw new Error(`${name}: rendered ${dim.width}x${dim.height}`) } // The paper ground makes a healthy tile fully opaque. const fraction = opaque / (tile.width * tile.height) if (fraction < 0.99) { throw new Error(`${name}: only ${(fraction * 100).toFixed(1)}% opaque — misrender`) } writeFileSync(resolve(tile.dest), png) console.log(`${name}: ${png.length} bytes -> ${tile.dest}`) } } finally { rmSync(work, { recursive: true, force: true }) } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main() }