Four directions per tile (neon box art / vector side art / amiga comic / 80s airbrush) generated on the farm, plus the picker page and the installer that ships chosen art and its risograph hover b-side. Recipe rules, all learned the hard way against the existing covers: tiles render 172-240px so silhouette and colour are all that read; the page draws every title in HTML so art carries no lettering (the old covers baked in garbled type); busy interiors smuggle text back via album sleeves and go muddy small, so it is one hero subject per card. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
191 lines
9.5 KiB
Python
191 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the cover-art picker page (self-contained, images inlined as webp data URIs).
|
|
|
|
Judged on the same dark ground the cards actually live on, and at the true
|
|
172px tile size, because that is the only size that matters on the real page.
|
|
"""
|
|
import base64, io, json, os, subprocess, sys, tempfile
|
|
|
|
CARDS = sys.argv[1]
|
|
CURRENT = sys.argv[2] # dir of existing covers for comparison
|
|
PROFILES = json.load(open(sys.argv[3]))
|
|
OUT = sys.argv[4]
|
|
VARIANTS = ['neon', 'vector', 'comic', 'paint']
|
|
LABELS = {'neon': 'neon box art', 'vector': 'vector side art',
|
|
'comic': 'amiga comic', 'paint': '80s airbrush'}
|
|
# tiles the arcade page shows for each game id
|
|
TITLES = {p['id']: p.get('title', p['id']) for p in PROFILES}
|
|
|
|
|
|
def webp(path, width=380, q=72):
|
|
"""Downscale to a data URI (macOS sips cannot WRITE webp — use cwebp)."""
|
|
if not os.path.exists(path):
|
|
return None
|
|
with tempfile.TemporaryDirectory() as td:
|
|
dst = os.path.join(td, 'o.webp')
|
|
r = subprocess.run(['cwebp', '-q', str(q), '-resize', str(width), '0', path, '-o', dst],
|
|
capture_output=True)
|
|
if r.returncode != 0 or not os.path.exists(dst):
|
|
return None
|
|
b = open(dst, 'rb').read()
|
|
return 'data:image/webp;base64,' + base64.b64encode(b).decode()
|
|
|
|
|
|
rows = []
|
|
for p in PROFILES:
|
|
gid = p['id']
|
|
cells = []
|
|
wide = p.get('aspect') == 'wide'
|
|
src_dir = os.path.join(os.path.dirname(CARDS), 'hero') if wide else CARDS
|
|
cur = webp(os.path.join(CURRENT, p.get('current_cover', gid) + '.jpg'),
|
|
620 if wide else 300, 70)
|
|
for v in VARIANTS:
|
|
d = webp(os.path.join(src_dir, f'{gid}__{v}.png'), 560 if wide else 380)
|
|
if d:
|
|
cells.append({'v': v, 'src': d})
|
|
if cells:
|
|
rows.append({'id': gid, 'title': TITLES.get(gid, gid), 'cur': cur,
|
|
'cells': cells, 'wide': wide})
|
|
|
|
total_kb = sum(len(c['src']) for r in rows for c in r['cells']) // 1024
|
|
print(f'{len(rows)} games, {sum(len(r["cells"]) for r in rows)} variants, ~{total_kb//1024}MB inlined')
|
|
|
|
html = ['''<title>Cover art — pick one per game</title>
|
|
<style>
|
|
:root{
|
|
--bg:#07080d; --panel:#0d1018; --line:#1b2233; --ink:#dbe6da; --dim:#7b8894;
|
|
--faint:#47535f; --phos:#3dff9a; --pink:#ff3ea5; --cyan:#38d6ff;
|
|
--mono:ui-monospace,"Cascadia Code","JetBrains Mono",Menlo,monospace;
|
|
--disp:"Arial Black","Helvetica Neue",Impact,sans-serif;
|
|
}
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{background:var(--bg);color:var(--ink);font-family:var(--mono);font-size:14px;line-height:1.5;
|
|
background-image:radial-gradient(circle at 12% 0%,rgba(255,62,165,.10),transparent 42%),
|
|
radial-gradient(circle at 88% 100%,rgba(56,214,255,.10),transparent 42%),
|
|
repeating-linear-gradient(0deg,rgba(255,255,255,.014) 0 1px,transparent 1px 3px)}
|
|
.wrap{max-width:1180px;margin:0 auto;padding:0 18px 100px}
|
|
header{padding:26px 0 8px}
|
|
h1{font-family:var(--disp);text-transform:uppercase;font-size:clamp(1.4rem,3.4vw,2.1rem);
|
|
letter-spacing:-.02em;color:#fff;line-height:1}
|
|
h1 .g{color:var(--phos)}
|
|
.sub{color:var(--dim);margin-top:.5rem;max-width:64ch}
|
|
.bar{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;align-items:center;gap:10px;
|
|
background:rgba(7,8,13,.93);border-bottom:1px solid var(--line);padding:11px 0;margin:14px 0 4px;
|
|
backdrop-filter:blur(8px)}
|
|
.count{color:var(--phos);letter-spacing:.1em;text-transform:uppercase;font-size:11px}
|
|
button{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
|
|
background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;
|
|
padding:7px 12px;cursor:pointer;transition:.15s}
|
|
button:hover{border-color:var(--pink);color:#fff}
|
|
button:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
|
|
button.on{border-color:var(--phos);color:var(--phos)}
|
|
/* 114 inlined covers is ~100MB of decoded bitmap if the browser paints them
|
|
all at once, which blanks the page on scroll. Only render rows near the
|
|
viewport; the intrinsic size keeps the scrollbar honest. */
|
|
.game{border-top:1px solid var(--line);padding:20px 0 24px;
|
|
content-visibility:auto;contain-intrinsic-size:auto 360px}
|
|
.gh{display:flex;align-items:baseline;gap:12px;margin-bottom:12px}
|
|
.gh .t{font-family:var(--disp);text-transform:uppercase;color:#fff;font-size:16px}
|
|
.gh .pick{color:var(--phos);font-size:11px;letter-spacing:.1em;text-transform:uppercase}
|
|
.gh .pick.none{color:var(--faint)}
|
|
.strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;align-items:start}
|
|
body.tiny .strip{grid-template-columns:repeat(auto-fill,172px)}
|
|
.opt{border:1px solid var(--line);border-radius:9px;overflow:hidden;background:var(--panel);
|
|
cursor:pointer;transition:transform .16s,border-color .16s,box-shadow .16s;padding:0;display:block;
|
|
text-align:left;width:100%}
|
|
.opt:hover{transform:translateY(-4px);border-color:var(--pink);box-shadow:0 12px 30px -14px rgba(255,62,165,.6)}
|
|
.opt.sel{border-color:var(--phos);box-shadow:0 0 0 1px var(--phos),0 12px 30px -14px rgba(61,255,154,.6)}
|
|
.opt .art{position:relative;aspect-ratio:896/1152;background:#000}
|
|
.game.wide .strip{grid-template-columns:repeat(auto-fit,minmax(300px,1fr))}
|
|
body.tiny .game.wide .strip{grid-template-columns:1fr}
|
|
.game.wide .opt .art{aspect-ratio:21/9}
|
|
.opt .art img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
|
|
.opt .lab{display:flex;align-items:center;gap:6px;padding:7px 9px;font-size:10px;
|
|
letter-spacing:.09em;text-transform:uppercase;color:var(--dim)}
|
|
.opt.sel .lab{color:var(--phos)}
|
|
.opt .lab .k{margin-left:auto;color:var(--faint)}
|
|
.opt.cur{opacity:.62}
|
|
.opt.cur .lab{color:var(--pink)}
|
|
.opt.cur:hover{transform:none;border-color:var(--line);box-shadow:none;cursor:default}
|
|
.out{position:fixed;left:0;right:0;bottom:0;z-index:30;background:rgba(7,8,13,.96);
|
|
border-top:1px solid var(--line);padding:10px 18px;display:flex;gap:12px;align-items:center;
|
|
backdrop-filter:blur(8px)}
|
|
.out code{flex:1;color:var(--cyan);font-size:12px;overflow-x:auto;white-space:nowrap;padding:4px 0}
|
|
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
|
|
</style>
|
|
<div class="wrap">
|
|
<header>
|
|
<h1>Cover art — <span class="g">pick one</span> per game</h1>
|
|
<p class="sub">Four directions per game, generated clean on the farm. The old covers had
|
|
garbled AI lettering baked in; these carry no type at all, because the site already
|
|
draws every title in HTML. Judge them small — that grey card is the cover you have now.</p>
|
|
</header>
|
|
<div class="bar">
|
|
<span class="count" id="count">0 picked</span>
|
|
<button id="tiny">actual tile size</button>
|
|
<button id="jump">next undecided</button>
|
|
<button id="copy">copy my picks</button>
|
|
</div>''']
|
|
|
|
for r in rows:
|
|
cls = 'game wide' if r['wide'] else 'game'
|
|
html.append(f''' <section class="{cls}" id="g-{r['id']}" data-game="{r['id']}">
|
|
<div class="gh"><span class="t">{r['title']}</span><span class="pick none">no pick</span></div>
|
|
<div class="strip">''')
|
|
if r['cur']:
|
|
html.append(f''' <div class="opt cur"><div class="art"><img decoding="async" src="{r['cur']}" alt=""></div>
|
|
<div class="lab">current</div></div>''')
|
|
for i, c in enumerate(r['cells'], 1):
|
|
html.append(f''' <button class="opt" data-v="{c['v']}"><div class="art"><img decoding="async" src="{c['src']}" alt=""></div>
|
|
<div class="lab">{LABELS[c['v']]}<span class="k">{i}</span></div></button>''')
|
|
html.append(' </div>\n </section>')
|
|
|
|
html.append('''</div>
|
|
<div class="out"><code id="picks">pick a cover in each row - your choices collect here</code>
|
|
<button id="copy2">copy</button></div>
|
|
<script>
|
|
const picks = {};
|
|
const games = [...document.querySelectorAll('.game')];
|
|
const fmt = () => games.filter(g => picks[g.dataset.game])
|
|
.map(g => g.dataset.game + '=' + picks[g.dataset.game]).join(' ');
|
|
function sync(){
|
|
document.getElementById('count').textContent =
|
|
Object.keys(picks).length + ' / ' + games.length + ' picked';
|
|
document.getElementById('picks').textContent =
|
|
Object.keys(picks).length ? fmt() : 'pick a cover in each row - your choices collect here';
|
|
}
|
|
games.forEach(g => {
|
|
const badge = g.querySelector('.pick');
|
|
g.querySelectorAll('.opt:not(.cur)').forEach(o => o.addEventListener('click', () => {
|
|
g.querySelectorAll('.opt').forEach(x => x.classList.remove('sel'));
|
|
o.classList.add('sel');
|
|
picks[g.dataset.game] = o.dataset.v;
|
|
badge.textContent = o.dataset.v;
|
|
badge.classList.remove('none');
|
|
sync();
|
|
}));
|
|
});
|
|
document.getElementById('tiny').addEventListener('click', e => {
|
|
document.body.classList.toggle('tiny');
|
|
e.currentTarget.classList.toggle('on');
|
|
});
|
|
document.getElementById('jump').addEventListener('click', () => {
|
|
const next = games.find(g => !picks[g.dataset.game]);
|
|
if (next) next.scrollIntoView({behavior:'smooth', block:'start'});
|
|
});
|
|
const copy = () => {
|
|
const t = fmt();
|
|
if (!t) return;
|
|
navigator.clipboard.writeText(t);
|
|
[...document.querySelectorAll('#copy,#copy2')].forEach(b => {
|
|
const o = b.textContent; b.textContent = 'copied'; setTimeout(()=>b.textContent=o, 1200);
|
|
});
|
|
};
|
|
document.getElementById('copy').addEventListener('click', copy);
|
|
document.getElementById('copy2').addEventListener('click', copy);
|
|
sync();
|
|
</script>''')
|
|
|
|
open(OUT, 'w').write('\n'.join(html))
|
|
print(f'→ {OUT} ({os.path.getsize(OUT)//1024}KB)')
|