Six-page app: sidebar shell with Juniper's portrait, whole-workflow IA

The two-page dashboard/review split becomes a proper information
architecture: Pipeline (stages + live activity), Photos (drag-and-drop,
gallery with per-photo extraction state, reshoot tickets — photo work
lives with photos), Review (decisions only, keyboard-first), Catalog
(the full title ledger with filtering), Queue (what upload will do and
everything it has done), and Library (the enriched collection browser,
with an honest empty state until real BGG data lands). Pages render
server-side from a shared shell — sidebar rail with the rainbow path
running its edge, live count badges on Photos/Review/Queue, and
Juniper's full portrait finally displayed, with her credit and a
standard third-party trademark attribution beneath it (one notice, not
per-mention symbols — the convention for referring to another party's
mark).

Shared client plumbing moves to static/app.js (escaping contract
documented at the innerHTML sink). New endpoints: /api/photos-list,
/api/queue, /api/library, plus a reshoot count in /api/pipeline.
Screenshot review caught two real bugs: photos-list crashed on
bare-array raw caches, and .DS_Store was listed as a shelf photo —
photo_names() now filters by suffix everywhere, including the /photos
allowlist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-02 17:34:17 -04:00
parent d3cf0c6151
commit 189c534315
14 changed files with 844 additions and 356 deletions
+101
View File
@@ -214,3 +214,104 @@ def test_both_pages_carry_navigation_and_skip_link(tmp_path):
def test_activity_region_announces_politely(tmp_path):
html = _app(_cfg(tmp_path)).get("/").text
assert 'aria-live="polite"' in html
# -- six-page shell -----------------------------------------------------
def test_every_page_serves_with_shared_shell(tmp_path):
web = _app(_cfg(tmp_path))
for path, marker in (
("/", "Pipeline"),
("/photos", "Reshoot"),
("/review", "Review"),
("/catalog", "Catalog"),
("/queue", "Upload queue"),
("/library", "Library"),
):
html = web.get(path).text
assert marker in html, path
assert 'nav aria-label="Primary"' in html, path
assert 'aria-current="page"' in html, path
assert "logo-full.jpg" in html, path # Juniper's portrait in the rail
assert "art by Juniper" in html, path
assert "trademarks of" in html, path # attribution notice
def test_photos_list_reports_extraction_state(tmp_path):
import json as _json
cfg = _cfg(tmp_path)
(cfg.photos_dir / "done.jpg").write_bytes(b"x")
(cfg.photos_dir / "fresh.jpg").write_bytes(b"x")
cfg.extract_raw_dir.mkdir(parents=True)
(cfg.extract_raw_dir / "done.jpg.json").write_text(
_json.dumps({"titles": [{"title_raw": "Catan"}], "unidentified": [{}, {}]})
)
listing = {p["name"]: p for p in _app(cfg).get("/api/photos-list").json()}
assert listing["done.jpg"] == {
"name": "done.jpg",
"extracted": True,
"titles": 1,
"unidentified": 2,
}
assert listing["fresh.jpg"]["extracted"] is False
def test_queue_endpoint_serves_all_three_ledgers(tmp_path):
cfg = _cfg(tmp_path)
(cfg.to_add_path).write_text("bgg_id,bgg_name\n13,Catan\n")
q = _app(cfg).get("/api/queue").json()
assert q["to_add"] == [{"bgg_id": "13", "bgg_name": "Catan"}]
assert q["to_update"] == [] and q["log"] == []
def test_library_serves_games_sorted_or_empty(tmp_path):
import json as _json
cfg = _cfg(tmp_path)
web = _app(cfg)
assert web.get("/api/library").json() == []
cfg.games_path.write_text(
_json.dumps(
{
"13": {"name": "Catan", "year": 1995},
"266192": {"name": "Wingspan", "year": 2019},
"1": {"name": "aliens", "year": 2000},
}
)
)
names = [g["name"] for g in web.get("/api/library").json()]
assert names == ["aliens", "Catan", "Wingspan"] # casefold sort
def test_pipeline_reports_reshoot_count(tmp_path):
import json as _json
cfg = _cfg(tmp_path)
cfg.unidentified_path.write_text(
_json.dumps({"a.jpg": [{"location": "top shelf"}]})
)
assert _app(cfg).get("/api/pipeline").json()["reshoot"] == 1
def test_photos_list_tolerates_bare_array_raw_cache(tmp_path):
import json as _json
cfg = _cfg(tmp_path)
(cfg.photos_dir / "old.jpg").write_bytes(b"x")
cfg.extract_raw_dir.mkdir(parents=True)
(cfg.extract_raw_dir / "old.jpg.json").write_text(
_json.dumps([{"title_raw": "Catan"}, {"title_raw": "Risk"}])
)
(item,) = _app(cfg).get("/api/photos-list").json()
assert item["extracted"] is True and item["titles"] == 2
def test_non_photo_files_are_invisible(tmp_path):
cfg = _cfg(tmp_path)
(cfg.photos_dir / "shelf.jpg").write_bytes(b"x")
(cfg.photos_dir / ".DS_Store").write_bytes(b"junk")
web = _app(cfg)
assert [p["name"] for p in web.get("/api/photos-list").json()] == ["shelf.jpg"]
assert web.get("/photos/.DS_Store").status_code == 404