Mascot: Juniper's bggpipe piper joins the header, favicon, and README

Original art (a bagpiper whose bag is a board game box) lives in
assets/; web-sized derivatives ship in the package: a face-crop avatar
in the header and favicon, and the framed full-length piper on the
review done screen. Served via an allowlisted /static route. README
leads with the full portrait, credited to Juniper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-01 17:35:53 -04:00
parent 8420a0a1ca
commit 824719edc7
9 changed files with 47 additions and 5 deletions
+4
View File
@@ -1,5 +1,7 @@
# bggpipe — Shelf-to-BoardGameGeek Collection Pipeline
<img src="assets/logo-full.jpeg" alt="the bggpipe piper — a bagpiper whose bag is a board game box" width="220" align="right">
Photograph your board game shelves. End up with your whole collection — including which *edition* of each game you own — cataloged on [BoardGameGeek](https://boardgamegeek.com).
```
@@ -42,3 +44,5 @@ This tool is **not affiliated with or supported by BoardGameGeek**. It uses only
## License
MIT — see [LICENSE](LICENSE).
Mascot art by Juniper, used with pride.
Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+17 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bggpipe review</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎲</text></svg>">
<link rel="icon" type="image/png" href="/static/favicon.png">
<style>
:root {
--felt: #2c4136;
@@ -35,10 +35,15 @@
position: sticky; top: 0; z-index: 5;
background: var(--felt-deep);
color: var(--paper);
padding: .6rem 1.2rem;
display: flex; align-items: baseline; gap: 1.2rem; flex-wrap: wrap;
padding: .45rem 1.2rem;
display: flex; align-items: center; gap: 1.2rem; flex-wrap: wrap;
border-bottom: 1px solid rgba(255,255,255,.12);
}
.brand { display: flex; align-items: center; gap: .6rem; }
.brand img {
width: 38px; height: 38px; border-radius: 50%;
border: 2px solid var(--brass); object-fit: cover; display: block;
}
.wordmark {
font-family: "Iowan Old Style", Palatino, Georgia, serif;
font-size: 1.25rem; letter-spacing: .02em;
@@ -158,6 +163,10 @@
background: var(--paper); border-radius: 8px; padding: 1.6rem;
text-align: center; box-shadow: 0 6px 18px rgba(0,0,0,.4);
}
.done .piper {
height: 165px; margin-bottom: .4rem;
border-radius: 8px; border: 3px solid var(--felt);
}
.done h2 { color: var(--ink); margin-top: 0; }
.done .nums { display: flex; justify-content: center; gap: 2rem; margin: 1rem 0; }
.done .nums div { font-size: 1.6rem; font-weight: 700; }
@@ -216,7 +225,10 @@
</head>
<body>
<header>
<span class="brand">
<img src="/static/logo.jpg" alt="the bggpipe piper — a bagpiper whose bag is a board game box">
<span class="wordmark">bggpipe <small>review</small></span>
</span>
<span id="tally"></span>
<span class="keyhelp">
<kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd><kbd>9</kbd> pick ·
@@ -362,6 +374,8 @@ function render() {
const waiting = s.summary.unresolved;
html += `
<div class="done">
<img class="piper" src="/static/logo-full.jpg"
alt="the bggpipe piper plays a celebratory tune">
<h2>${waiting
? "Resolved set fully reviewed"
: "All reviewed — this catalog is diff-ready"}</h2>
+15 -1
View File
@@ -20,7 +20,7 @@ from pathlib import Path
import typer
from defusedxml.ElementTree import fromstring as _safe_fromstring
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, Response
from pydantic import BaseModel
from rich.console import Console
@@ -286,6 +286,20 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI:
raise HTTPException(404, "no such photo")
return FileResponse(cfg.photos_dir / name)
_STATIC = {
"logo.jpg": "image/jpeg",
"logo-full.jpg": "image/jpeg",
"favicon.png": "image/png",
}
@app.get("/static/{name}")
def static_asset(name: str):
media_type = _STATIC.get(name) # allowlist: no traversal possible
if media_type is None:
raise HTTPException(404, "no such asset")
data = (resources.files("bggpipe") / "static" / name).read_bytes()
return Response(content=data, media_type=media_type)
return app
+10
View File
@@ -324,3 +324,13 @@ def test_merge_veto_roundtrip(tmp_path):
json={"title_raw": "Jokin Ha...", "source_photos": "shelf.jpg"},
)
assert bad.status_code == 400
def test_static_assets_served_with_allowlist(tmp_path):
web, _ = make_client(tmp_path)
logo = web.get("/static/logo.jpg")
assert logo.status_code == 200
assert logo.headers["content-type"] == "image/jpeg"
assert web.get("/static/favicon.png").status_code == 200
assert web.get("/static/nope.js").status_code == 404
assert web.get("/static/..%2Ftemplates%2Freview.html").status_code == 404