The web layer's serialization story had three gaps: /api/run started a
stage without the lock, so a decision mid-save could pass the rewrite
guard and still be clobbered by the stage's full rewrite (now the start
itself serializes); /api/photos accepted a replacement photo while
extract was running, permanently pairing the new bytes with the old
photo's reads (now refuses like every other mutation); and /api/queue
read session rows lock-free and stale (now freshens under the lock).
The localhost Host allowlist applied only to writes — a DNS-rebound
page could read pipeline state and shelf photos with plain GETs; it
now covers all methods (foreign-Origin reads still pass: without CORS
headers a cross-origin page can't read the response anyway).
Data-loss finds: the off-BGG edit form re-rendered from games.json,
which only sees hand data after enrich — so a second save resubmitted
pre-save blanks and cleared the first (the detail endpoint now overlays
local_games.json live). The local key embeds the photo list, so a new
sighting orphaned hand-written facts silently; enrich now migrates them
when the title still matches exactly one line, and warns instead of
ever dropping. research() left the previous game's version verdicts on
the row, riding a stale version_id onto the next pick; it clears all
four fields as reopen does. find_row now prefers the version-open
sibling on duplicate keys, mirroring _adopt. Re-adding a removed
hand-added title silently no-opped behind a 200 — it now rescinds the
removal (an explicit undo), and a true duplicate add answers 409.
Smaller: parse_search's dedupe collapsed same-id rows under DIFFERENT
names, discarding the alternate-name row whose exact match downstream
scoring needed (now collapses same-name only; research merges its
ballot per game preferring exact evidence); rpgitems rank in their own
family so their rank parsed null; the pipeline badge counted
review-retired queue rows as pending; the catalog pairing cascade ran
per-entry so a tier-3 claim could steal a sibling's exact row (now
tier-by-tier across all entries, as resolve does); library cards
render a lone player bound without "undefined" and the seats filter
tolerates it; added_no_version reads "done · no version" instead of a
bare green done.
Every finding verified against the code before fixing; each fix
carries a regression test. 337 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
The card read "1 version updates" while upload said "skipping 1 already
done". Both were right. Recon on the live site shows the update DID
apply — the version cell reads "English first edition Year: 2012" and
its radio is checked — but BGG's XML collection export still reports
that collid with no version, even on a forced refresh. diff reads the
API, so it re-queued finished work; the log correctly refused it.
Nothing to fix in the flow: the pipeline card now counts PENDING jobs
(queue rows minus what the log completed) for both to_add and
to_update, reports outstanding failures rather than every failure ever
logged, and when everything queued is already applied it says so and
names the cause. Documented under "BGG's collection export lags the
site" so the next person doesn't chase it as a bug.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
All 36 version updates landed and all 36 still read as outstanding:
to_add.csv and to_update.csv are diff-time snapshots that never shrink,
and the page showed them without consulting the upload log. Rows now
carry their last attempt's outcome — pending / done / failed, plus
"retired" for jobs a later review decision withdrew — and each section
heads with a tally instead of a raw row count. A note explains that
finished rows persist until the next diff rebuilds the queue, and that
the log is the permanent record.
On Eric's data: to_update now reads 36 done, 0 pending.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
The CLI could retry failed upload jobs; the web app couldn't, so a run
that hit a bug (twice today) left work only a terminal could reclaim.
/api/pipeline now reports the failed count, and the upload card grows a
"retry N failed" checkbox — shown only when there are failures — that
rides along with both Dry run and the real Upload. Help explains why
failures are skipped by default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
Two blind reviewers swept the 33 commits since 10f65d8 for signs of
machine generation. Verdict: production code and copy largely clean;
the tells clustered in duplication and tests.
JS: the six-times-pasted change-detection loop (three pages honoring a
LAST-after-render invariant, three violating it) becomes one
changeGate() factory in app.js; the reshoot ticket renderer and
dismiss wiring, duplicated across photos/photo pages, become
ticketCard()/wireDismiss(); review.html's hand-rolled fetch/post
collapse onto fetchJSON/apiPost keeping only its unique
saved-but-render-failed path; dead lastGood deleted; page-state naming
unified to CAPS (ACTIVE, RUNNING); a dead defensive rowix branch gone.
CSS: header no longer claims "two pages"; --focus derives from
--accent; five state tints become tokens (the header's tokens-for-roles
promise, kept); component button rules drop declarations the global
rule supplies; duplicate color declarations trimmed.
Python: dead seen_per_title vestige removed from resolve; redundant
ternary arm in the catalog builder collapsed; csv import hoisted; twin
VetoBody/SplitBody merged into RowRef; warn-once idiom deduplicated
into a closure; a stray "a bare arrays" typo.
Tests: the one assertion that could never fail (aria-current check
with an always-true fallback) replaced by a strict per-page check
across all seven pages; the traversal test asserts escape
unconditionally; stale "both pages" names updated; nine redundant
function-local imports hoisted to their module tops.
Docs: aria role="status" set once in the shell instead of per call;
joblog gets role="log"; README's --lan paragraph becomes a proper
"From your phone" quickstart subsection with the command visible, and
the seven-page list stops restating the screenshot captions; Help's
re-extract claim matches actual behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
The horizontally scrolling nav strip lost its scroll position on every
page load and hid the far entries. The top bar is now brand +
hamburger (aria-expanded/aria-controls, 44px target); the nav drops
down as the same stacked list the desktop rail shows — badges, gold
current-page marker — and navigation naturally closes it.
Help gains "The piper": Juniper's full artwork with credit and the
trademark attribution, which mobile previously never showed anywhere
(the sidebar portrait is hidden there). Also fixes the Help cards
flex-rowing their paragraphs into accidental columns on desktop —
visible in the README's own screenshot — via a card.prose block
variant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
Eric's observation: cleaning up raw reads happens BEFORE resolve, but
the app ordered Review ahead of Catalog and never said proofreading was
a step at all. The pipeline has two human checkpoints — proofread the
reads (after extract), decide the matches (after resolve) — and now the
app says so: the sidebar runs Pipeline, Photos, Titles, Review, Queue,
Library, Help in true workflow order; the Titles page (né Catalog — the
old name suggested a finished collection, which is the Library's job;
/catalog redirects) gets a badge counting unresolved shaky reads; an
edit marks its entry human-verified so the badge drains as you
proofread; the extract stage card nudges toward the proofread before
resolving; and the Help flow is rewritten as six stages + two
checkpoints with the loop called out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
/photos/view/{name} shows the photo large in the shell, every title
read from it with status chips and BGG matches, its reshoot tickets
with working dismiss, prev/next navigation with arrow keys, position
in the gallery, and a link to the raw full-size file. Gallery and
catalog photo links point here now (review's shots keep linking to the
raw image — zooming spine text is their whole purpose). The status
chip renderer moves to app.js so the catalog and photo pages can't
drift; render_page learns an `active` override so a detail page keeps
its nav section lit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The findings clustered exactly where prediction said: the unreviewed web
layer. The big ones: decisions made while an extract/resolve job runs
are now refused with a 409 (the job's end-of-run rewrite from a
start-of-run snapshot would silently revert them); a cross-origin guard
blocks preflight-free mutations from hostile webpages (bodyless run
triggers, cross-site photo form posts); the JobRunner sets terminal
status in a finally catching BaseException (a greenlet death could
wedge every future run behind 409s) and writes tracebacks into the
visible job log; and a boot token lets clients accept the revision
reset after a server restart instead of freezing forever.
Even the thrice-audited core yielded one HIGH: an unvetoed bare
typo-read sibling of a confident row duplicated its add when the game
wasn't in the collection — diff now treats it as satisfied. Second-copy
adds carry a flag through to_add.csv and the upload log so verify
honestly reports them unverifiable instead of OK. Also: merged_into
chains collapse transitively; diff/enrich treat a BGG queue timeout
like a missing token; enrich prunes orphaned games.json keys; the
wizard shell-quotes .env values and creates the file 0600 from the
first byte; fsio stats the tmp inode before replace and uses unique tmp
names; an explicit missing --config errors; storage state is
owner-only; extract re-extracts corrupt caches, aborts on 3 identical
failures, and exits nonzero when nothing succeeded; torn JSON artifacts
degrade with in-browser warnings instead of 500ing every page; photo
uploads are atomic with cache-invalidation ordered first; the pipeline
page computes `running` before the buttons that depend on it; the
photo dropzone alerts on network failure; and lost-contact banners
clear on recovery everywhere.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
One shared stylesheet (static/app.css) replaces the two ad-hoc style
blocks, with tokens drawn from the mascot drawing: sky background,
cream game-board cards inside confident outlines with flat offset
shadows, hair-purple for brand and actions, shirt-green for go,
bow-tie orange for danger, pipe-fitting gold for trim, jeans navy for
chrome — and the rainbow game path as a stripe under the header, the
one loud element. Accent colors split into object and *-ink variants
so text on light surfaces holds AA contrast.
Navigation: both pages share a header with brand-home link, a Primary
nav with aria-current, and tally counts that link to the lists they
count (dashboard tallies deep-link into the review catalog; review
tallies jump to their sections). Accessibility: skip link, landmark
nav, polite live region for stage activity, status role on banners,
the dropzone is a real button, candidate rows are focusable and
activate with Enter/Space, focus-visible ring throughout, reduced
motion respected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A dashboard at / joins the review page (now at /review): drag-and-drop
photo upload (re-uploading a photo drops its raw cache so extract
re-reads it), per-stage status cards fed by /api/pipeline (counts and
key NAMES only — never values), and run buttons that execute stages
one-at-a-time in a background JobRunner with captured output streamed
to the page. The real upload sits behind a confirmation, defaults to
dry-run at the API layer, and stays disabled while stub data is
present. The CLI is unchanged and shares all state with the web UI.
python-multipart joins the deps for the upload endpoint; RunBody lives
at module scope because postponed annotations keep FastAPI from
resolving function-local models.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>