The colophon card: the shelf ends with the piper and the fine print

Eric's idea — the last card in the grid, always visible, carrying the
full piper, the Powered-by-BGG badge, and the legal text. A <div>
rather than an <a>, so the search filter (which selects a.game) can
never hide it: the compliance text is part of the collection, not a
skippable footer. The piper ships from the package's own static art,
credited to Juniper on the card itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-06 19:07:12 -04:00
co-authored by Claude Fable 5
parent c7fdc60f87
commit 47409d0487
2 changed files with 59 additions and 5 deletions
+41 -3
View File
@@ -186,7 +186,14 @@ a.game img { width:100%; aspect-ratio:1; object-fit:cover; display:block;
a.game .noart { width:100%; aspect-ratio:1; display:flex; align-items:center; a.game .noart { width:100%; aspect-ratio:1; display:flex; align-items:center;
justify-content:center; font-size:3rem; font-weight:700; color:var(--navy); justify-content:center; font-size:3rem; font-weight:700; color:var(--navy);
background:#e8ddc5; } background:#e8ddc5; }
a.game .info { padding:.55rem .7rem .7rem; } a.game .info, .colophon .info { padding:.55rem .7rem .7rem; }
.colophon { background:var(--board); border:1px solid var(--line);
border-radius:.8rem; overflow:hidden; display:flex; flex-direction:column;
box-shadow:0 2px 5px rgba(42,36,56,.15); }
.colophon > img { width:100%; aspect-ratio:1; object-fit:cover; display:block;
background:#cfe4f5; }
.colophon .meta { margin:.35rem 0 0; font-size:.72rem; line-height:1.45; }
.colophon a { color:var(--accent); }
.gname { font-weight:700; } .gname { font-weight:700; }
.meta { color:var(--ink-soft); font-size:.82rem; } .meta { color:var(--ink-soft); font-size:.82rem; }
main > .meta { color:var(--ink-soft-on-sky); } main > .meta { color:var(--ink-soft-on-sky); }
@@ -260,6 +267,35 @@ def _footer(title: str, rel: str, badge_present: bool) -> str:
</footer>""" </footer>"""
_PIPER_NAME = "bggpipe-piper.jpg"
_PIPER_SRC = Path(__file__).parent / "static" / "logo-full.jpg"
def _colophon_card(title: str, badge_present: bool) -> str:
"""The last card on the shelf: mascot, badge, and the fine print. A
<div>, not an <a> — the search filter selects a.game, so the colophon
stays visible whatever is typed."""
badge = (
f'<img src="art/{BADGE_NAME}" alt="Powered by BGG" height="34">'
if badge_present
else "Powered by BGG"
)
return f"""<div class="game colophon">
<img src="art/{_PIPER_NAME}"
alt="the bggpipe piper — a bagpiper whose bag is a board game box">
<div class="info">
<div class="gname">{html.escape(title)}</div>
<p class="meta">Cataloged from shelf photos by
<a href="https://git.kestrelsnest.social/eric/bggpipe" rel="noopener">bggpipe</a>.
Game data and covers from
<a href="https://boardgamegeek.com" rel="noopener">{badge}</a></p>
<p class="meta">BoardGameGeek and BGG are trademarks of BoardGameGeek,
LLC; this site is an independent project, not affiliated with or
endorsed by BoardGameGeek. Piper art by Juniper.</p>
</div>
</div>"""
_FAVICON = ( _FAVICON = (
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' " "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' "
"viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎲</text></svg>" "viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎲</text></svg>"
@@ -506,7 +542,7 @@ def run_export(
f'<p class="meta">{len(games)} games, cataloged from shelf photos.</p>' f'<p class="meta">{len(games)} games, cataloged from shelf photos.</p>'
'<div class="controls"><input type="search" id="q" ' '<div class="controls"><input type="search" id="q" '
'placeholder="search the shelves…" aria-label="search"></div>' 'placeholder="search the shelves…" aria-label="search"></div>'
f'<div class="grid">{cards}</div>' f'<div class="grid">{cards}\n{_colophon_card(title, badge_present)}</div>'
'<p class="meta" id="nomatch" hidden>No games match that search.</p>' '<p class="meta" id="nomatch" hidden>No games match that search.</p>'
f"{_INDEX_JS}" f"{_INDEX_JS}"
) )
@@ -524,8 +560,10 @@ def run_export(
if stale_dir.is_dir(): if stale_dir.is_dir():
shutil.rmtree(stale_dir) shutil.rmtree(stale_dir)
removed += 1 removed += 1
if _PIPER_SRC.exists():
atomic_write_bytes(out_dir / "art" / _PIPER_NAME, _PIPER_SRC.read_bytes())
wanted_art = {n for entry in art.values() for n in entry.values()} wanted_art = {n for entry in art.values() for n in entry.values()}
wanted_art.add(BADGE_NAME) wanted_art.update((BADGE_NAME, _PIPER_NAME))
for art_file in (out_dir / "art").iterdir(): for art_file in (out_dir / "art").iterdir():
if art_file.name not in wanted_art: if art_file.name not in wanted_art:
art_file.unlink() art_file.unlink()
+18 -2
View File
@@ -103,7 +103,8 @@ def test_export_writes_pages_art_and_no_shelf_photos(tmp_path):
assert "rpggeek.com/rpgitem/311654" in rpg assert "rpggeek.com/rpgitem/311654" in rpg
# covers landed locally and pages never hotlink the CDN # covers landed locally and pages never hotlink the CDN
assert len(list((out / "art").glob("*"))) == 4 # 2 full + 1 thumb + local art # 2 full + 1 thumb + local art + the colophon's piper
assert len(list((out / "art").glob("*"))) == 5
all_html = index + detail + rpg all_html = index + detail + rpg
assert "geekdo-images.com" not in all_html assert "geekdo-images.com" not in all_html
@@ -237,7 +238,7 @@ def test_garbage_200_is_a_failure_not_a_cached_cover(tmp_path):
assert summary["downloaded"] == 0 assert summary["downloaded"] == 0
# only the local-art copy survives; no CDN garbage was cached # only the local-art copy survives; no CDN garbage was cached
names = {f.name for f in (tmp_path / "site" / "art").iterdir()} names = {f.name for f in (tmp_path / "site" / "art").iterdir()}
assert names == {"abc123.jpg"} assert names == {"abc123.jpg", "bggpipe-piper.jpg"}
def test_missing_local_art_is_a_counted_failure(tmp_path): def test_missing_local_art_is_a_counted_failure(tmp_path):
@@ -330,3 +331,18 @@ def test_cli_export_wiring(tmp_path, monkeypatch):
monkeypatch.setattr("bggpipe.export.run_export", failing_run_export) monkeypatch.setattr("bggpipe.export.run_export", failing_run_export)
result = runner.invoke(app, ["export", "--out", str(tmp_path / "s")]) result = runner.invoke(app, ["export", "--out", str(tmp_path / "s")])
assert result.exit_code == 1 # scripted publishes must see failures assert result.exit_code == 1 # scripted publishes must see failures
def test_colophon_card_closes_the_shelf(tmp_path):
"""The last card: piper, badge, fine print — a div the search filter
(which selects a.game) can never hide."""
cfg = _cfg(tmp_path)
out = tmp_path / "site"
run_export(cfg, out, title="Test Shelves", fetch=_cdn([]), sleep=lambda s: None)
index = (out / "index.html").read_text()
assert 'class="game colophon"' in index
assert "bggpipe-piper.jpg" in index and "Piper art by Juniper" in index
assert "trademarks of BoardGameGeek" in index
assert (out / "art" / "bggpipe-piper.jpg").exists()
# the filter only ever hides <a class="game"> — the colophon is a div
assert '<div class="game colophon"' in index