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 # 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). 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 ## License
MIT — see [LICENSE](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 charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>bggpipe review</title> <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> <style>
:root { :root {
--felt: #2c4136; --felt: #2c4136;
@@ -35,10 +35,15 @@
position: sticky; top: 0; z-index: 5; position: sticky; top: 0; z-index: 5;
background: var(--felt-deep); background: var(--felt-deep);
color: var(--paper); color: var(--paper);
padding: .6rem 1.2rem; padding: .45rem 1.2rem;
display: flex; align-items: baseline; gap: 1.2rem; flex-wrap: wrap; display: flex; align-items: center; gap: 1.2rem; flex-wrap: wrap;
border-bottom: 1px solid rgba(255,255,255,.12); 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 { .wordmark {
font-family: "Iowan Old Style", Palatino, Georgia, serif; font-family: "Iowan Old Style", Palatino, Georgia, serif;
font-size: 1.25rem; letter-spacing: .02em; font-size: 1.25rem; letter-spacing: .02em;
@@ -158,6 +163,10 @@
background: var(--paper); border-radius: 8px; padding: 1.6rem; background: var(--paper); border-radius: 8px; padding: 1.6rem;
text-align: center; box-shadow: 0 6px 18px rgba(0,0,0,.4); 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 h2 { color: var(--ink); margin-top: 0; }
.done .nums { display: flex; justify-content: center; gap: 2rem; margin: 1rem 0; } .done .nums { display: flex; justify-content: center; gap: 2rem; margin: 1rem 0; }
.done .nums div { font-size: 1.6rem; font-weight: 700; } .done .nums div { font-size: 1.6rem; font-weight: 700; }
@@ -216,7 +225,10 @@
</head> </head>
<body> <body>
<header> <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 class="wordmark">bggpipe <small>review</small></span>
</span>
<span id="tally"></span> <span id="tally"></span>
<span class="keyhelp"> <span class="keyhelp">
<kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd><kbd>9</kbd> pick · <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; const waiting = s.summary.unresolved;
html += ` html += `
<div class="done"> <div class="done">
<img class="piper" src="/static/logo-full.jpg"
alt="the bggpipe piper plays a celebratory tune">
<h2>${waiting <h2>${waiting
? "Resolved set fully reviewed" ? "Resolved set fully reviewed"
: "All reviewed — this catalog is diff-ready"}</h2> : "All reviewed — this catalog is diff-ready"}</h2>
+15 -1
View File
@@ -20,7 +20,7 @@ from pathlib import Path
import typer import typer
from defusedxml.ElementTree import fromstring as _safe_fromstring from defusedxml.ElementTree import fromstring as _safe_fromstring
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse from fastapi.responses import FileResponse, HTMLResponse, Response
from pydantic import BaseModel from pydantic import BaseModel
from rich.console import Console 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") raise HTTPException(404, "no such photo")
return FileResponse(cfg.photos_dir / name) 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 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"}, json={"title_raw": "Jokin Ha...", "source_photos": "shelf.jpg"},
) )
assert bad.status_code == 400 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