storebound/tools/fixture-art.ts
CRT ae2ffd8dbf Phosphor icons and the real store marks
Desktop icons move from lucide to Phosphor (@phosphor-icons/react, per-icon imports, regular weight; fill for the
active nav item, notices and rating stars). The site's icons and the fixture art use Phosphor's own files from
@phosphor-icons/core. App Store and Google Play are shown with their marks from simple-icons (CC0 data), vendored
unchanged by tools/site-icons.ts and rendered as a mask in the neutral text colour next to the store's name; the
trademark note is in docs/desktop.md and the site footer. lucide-react and lucide-static removed. The
no-hand-written-SVG guard allows only the vendored files (byte-equal to their packages) and per-icon Phosphor
imports. Site stylesheet ?v= bumped.
2026-10-11 09:00:46 +08:00

70 lines
4.9 KiB
TypeScript

#!/usr/bin/env bun
// Renders the desktop dev fixtures' art (apps/desktop/src/dev/art/*.png): app icons and store screenshot stand-ins,
// each a Phosphor icon (@phosphor-icons/core, MIT, "fill" weight) on a CSS gradient, photographed by Playwright's
// Chromium. No drawn SVG: the icons are Phosphor's own files. Dev only (the fixtures never ship in the release bundle).
// bun tools/fixture-art.ts (Playwright's Chromium, or an installed Google Chrome; same launcher as storebound shots)
import { mkdirSync, readFileSync, renameSync } from 'node:fs';
import { join } from 'node:path';
import { launch } from '../cli/shots/browser';
const root = new URL('..', import.meta.url).pathname;
const out = join(root, 'apps/desktop/src/dev/art');
const phosphor = (name: string) => readFileSync(join(root, 'node_modules/@phosphor-icons/core/assets/fill', `${name}-fill.svg`), 'utf8');
/** [file, Phosphor icon, gradient from, gradient to] */
const ICONS: [string, string, string, string][] = [
['icon-prism', 'drop', '#7c5cff', '#ff5fa2'], ['icon-tidy', 'list-checks', '#2fd18b', '#0f9d74'],
['icon-orbit', 'planet', '#0ea5e9', '#6366f1'], ['icon-block', 'cube', '#22c55e', '#15803d'],
['icon-lantern', 'lamp', '#f59e0b', '#b45309'], ['icon-tycoon', 'coins', '#a855f7', '#6d28d9'],
['icon-racer', 'car', '#ef4444', '#991b1b'], ['icon-habit', 'egg', '#14b8a6', '#0f766e'],
['icon-focus', 'timer', '#f43f5e', '#be123c'], ['icon-sketch', 'star', '#fb923c', '#ea580c'],
['icon-night', 'moon', '#1e293b', '#4338ca'], ['icon-mood', 'grid-four', '#ec4899', '#8b5cf6'],
];
/** Four scenes of the demo game: [Phosphor icon, from, to, accent] */
const SCENES: [string, string, string, string][] = [
['drop', '#7c5cff', '#ff5fa2', '#ffd36e'], ['lightning', '#ff7a59', '#ff3d7f', '#ffe08a'],
['calendar-dots', '#2fb5ff', '#5b6cff', '#b8f3ff'], ['trophy', '#ffb03b', '#ff7a1a', '#fff1b8'],
];
/** Screenshot shapes at a small preview size, in the stores' aspect ratios. */
const SHAPES: [string, number, number][] = [['phone', 240, 521], ['play', 240, 427], ['ipad', 300, 400], ['feature', 512, 250]];
const page = (w: number, h: number, body: string, css: string) =>
`<!doctype html><html><head><style>*{margin:0;box-sizing:border-box}html,body{width:${w}px;height:${h}px;overflow:hidden}${css}</style></head><body>${body}</body></html>`;
mkdirSync(out, { recursive: true });
const browser = await launch(root, {});
const tab = await browser.newPage({ deviceScaleFactor: 1 });
async function snap(file: string, w: number, h: number, html: string) {
await tab.setViewportSize({ width: w, height: h });
await tab.setContent(html);
const png = join(out, `${file}.png`);
await tab.screenshot({ path: png, clip: { x: 0, y: 0, width: w, height: h } });
// 8-bit palette PNG (ffmpeg, already needed for storebound icons): about a quarter of the bytes; kept as is without it
if (Bun.which('ffmpeg')) {
const tmp = `${png}.q.png`;
const r = Bun.spawnSync(['ffmpeg', '-v', 'error', '-y', '-i', png, '-vf', 'split[a][b];[a]palettegen=max_colors=96:stats_mode=full[p];[b][p]paletteuse=dither=bayer:bayer_scale=3', tmp]);
if (r.exitCode === 0) renameSync(tmp, png);
}
console.log(`fixture-art: ${file}.png ${w}x${h}`);
}
for (const [file, icon, a, b] of ICONS) {
await snap(file, 128, 128, page(128, 128, `<div class="t">${phosphor(icon)}</div>`,
`.t{width:128px;height:128px;display:grid;place-items:center;background:linear-gradient(135deg,${a},${b});color:#fff}.t svg{width:72px;height:72px}`));
}
for (const [i, [icon, a, b, c]] of SCENES.entries()) {
for (const [shape, w, h] of SHAPES) {
const land = w > h;
const phone = land ? { w: h * 0.42, h: h * 0.86 } : { w: w * 0.62, h: Math.min(h * 0.72, w * 0.62 * 2.05) };
await snap(`scene${i + 1}-${shape}`, w, h, page(w, h,
`<div class="bg"><div class="glow"></div><div class="big">${phosphor(icon)}</div><div class="ph"><div class="scr">${phosphor(icon)}</div></div></div>`,
`.bg{position:relative;width:${w}px;height:${h}px;background:linear-gradient(135deg,${a},${b});color:#fff;overflow:hidden}
.glow{position:absolute;inset:0;background:radial-gradient(circle at 50% 30%,rgba(255,255,255,.35),transparent 70%)}
.big{position:absolute;${land ? `left:${w * 0.1}px;top:${h * 0.22}px` : `left:50%;top:${h * 0.05}px;transform:translateX(-50%)`};color:${c}}
.big svg{width:${land ? h * 0.5 : w * 0.26}px;height:${land ? h * 0.5 : w * 0.26}px}
.ph{position:absolute;width:${phone.w}px;height:${phone.h}px;${land ? `right:${w * 0.1}px;top:${h * 0.07}px` : `left:50%;bottom:-${phone.w * 0.12}px;transform:translateX(-50%)`};
border-radius:${phone.w * 0.14}px;background:#10142a;border:${Math.max(2, phone.w * 0.03)}px solid rgba(255,255,255,.85);padding:${phone.w * 0.06}px}
.scr{width:100%;height:100%;border-radius:${phone.w * 0.08}px;background:#1d2350;display:grid;place-items:center;color:${c}}
.scr svg{width:45%;height:45%}`));
}
}
await browser.close();