Mobile nav becomes a hamburger menu; Juniper joins the Help page

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
This commit is contained in:
Eric Wagoner
2026-08-03 16:47:40 -04:00
co-authored by Claude Fable 5
parent f82489716a
commit 78debdd62f
5 changed files with 70 additions and 24 deletions
+39 -15
View File
@@ -121,6 +121,7 @@ nav[aria-label="Primary"] a[aria-current="page"] {
display: block; margin: 0 auto;
}
.piperbox figcaption { font-size: .72rem; opacity: .8; margin-top: .45rem; }
.navtoggle { display: none; }
.piperbox .legal { font-size: .62rem; opacity: .6; line-height: 1.45; text-align: left; }
.skip {
position: absolute; left: -999px; top: 0; z-index: 10;
@@ -192,6 +193,17 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
display: flex; gap: 1.1rem;
scroll-margin-top: 5rem;
}
.card.prose { display: block; }
.card.prose p, .card.prose ol { margin: .55rem 0; line-height: 1.55; }
.card.prose > :first-child { margin-top: 0; }
.card.prose > :last-child { margin-bottom: 0; }
.artcard { text-align: center; }
.artcard img {
width: 100%; max-width: 15rem;
border: var(--line); border-radius: var(--radius-lg);
background: var(--sky);
}
.artcard .legal { font-size: .74rem; color: var(--ink-soft); max-width: 30rem; margin: .6rem auto 0; }
.card.active {
border-color: var(--accent);
box-shadow: var(--shadow-raised);
@@ -464,32 +476,44 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
/* -- responsive -------------------------------------------------------- */
/* The rail collapses to a top bar: brand line, then one horizontally
* scrollable nav row (no wrapping — a second row costs shelf space). */
/* The rail collapses to a top bar: brand + hamburger; the nav drops
* down as a stacked menu (a scrolling strip lost its position on every
* page load and hid the far entries). */
@media (max-width: 900px) {
body { display: block; }
.sidebar {
position: sticky; top: 0; height: auto; display: block; z-index: 5;
position: sticky; top: 0; height: auto; z-index: 5;
display: flex; flex-direction: row; align-items: center; flex-wrap: wrap;
border-right: none; border-bottom: 4px solid transparent;
border-image: var(--path) 1;
}
.brand { padding: .5rem .8rem .1rem; }
.brand { padding: .5rem .8rem; flex: 1; }
.brand img { width: 30px; height: 30px; }
.wordmark { font-size: 1.1rem; }
.wordmark small { display: inline; margin-left: .5rem; font-size: .68rem; }
.navtoggle {
display: inline-flex; align-items: center; justify-content: center;
width: 44px; height: 40px; margin-right: .6rem;
background: none; border: 2px solid rgba(255, 255, 255, .75);
border-radius: var(--radius); box-shadow: none; padding: 0;
}
.navtoggle .bars {
display: block; position: relative;
width: 18px; height: 2px; background: #fff; border-radius: 2px;
}
.navtoggle .bars::before, .navtoggle .bars::after {
content: ""; position: absolute; left: 0;
width: 18px; height: 2px; background: #fff; border-radius: 2px;
}
.navtoggle .bars::before { top: -6px; }
.navtoggle .bars::after { top: 6px; }
body.navopen .navtoggle { background: rgba(255, 255, 255, .15); }
nav[aria-label="Primary"] {
flex-direction: row; padding: 0 .4rem .1rem;
overflow-x: auto;
display: none; flex-basis: 100%; flex-direction: column;
padding: 0 0 .4rem;
}
nav[aria-label="Primary"] a {
flex: 0 0 auto;
border-left: none; border-bottom: 3px solid transparent;
padding: .45rem .6rem;
}
nav[aria-label="Primary"] a[aria-current="page"] {
border-bottom-color: var(--gold); background: none;
}
.navbadge { margin-left: .35rem; }
body.navopen nav[aria-label="Primary"] { display: flex; }
nav[aria-label="Primary"] a { padding: .6rem 1rem; }
.piperbox { display: none; }
}
+10
View File
@@ -66,6 +66,16 @@ function pollLoop(fn, ms, recovered) {
}, ms);
}
/* Mobile: the top bar's hamburger opens the nav; navigation reloads the
* page, so each page starts with the menu closed. */
{
const toggle = document.querySelector(".navtoggle");
if (toggle) toggle.addEventListener("click", () => {
const open = document.body.classList.toggle("navopen");
toggle.setAttribute("aria-expanded", String(open));
});
}
/* Sidebar badges: the counts that mean "something wants your attention". */
async function refreshBadges() {
const p = await fetchJSON("/api/pipeline");
+16 -6
View File
@@ -9,7 +9,7 @@
</div>
<h2 id="flow">The flow: shelves → collection</h2>
<div class="card">
<div class="card prose">
<p>Six pipeline stages and two checkpoints that are yours. Stages run from the <a href="/">Pipeline</a> page (or the CLI — both share all state and either can pick up where the other left off):</p>
<ol>
<li><b>extract</b> — every photo goes to Claude vision once; what it reads (titles plus edition cues: publisher, edition wording, year, language) lands on the Titles page. Boxes it can see but can't read become <b>reshoot tickets</b> on the Photos page.</li>
@@ -24,7 +24,7 @@
</div>
<h2 id="pages">What each page is for</h2>
<div class="card">
<div class="card prose">
<p><b><a href="/">Pipeline</a></b> — run stages one at a time and watch their live output. Shows what's blocking (missing keys, stub data) and the counts at every step.</p>
<p><b><a href="/photos">Photos</a></b> — drag photos in (or drop them in the <code>photos/</code> folder). Each photo has its own page listing every title read from it and any reshoot tickets — boxes seen but not identified. Photograph those up close, drop the new shot in, and extract again. Re-uploading a photo with the same name re-extracts it.</p>
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
@@ -34,7 +34,7 @@
</div>
<h2 id="curation">Fixing the titles: edit, split, remove</h2>
<div class="card">
<div class="card prose">
<p>Vision reads aren't perfect, and you know things the photos don't show. Every line on the Titles page has curation actions, and every one of them is <b>durable</b>: the decision is saved in a small committed file and replayed on every rebuild, so re-running extract or resolve can never undo it.</p>
<p><b>edit</b> — fix a misread title or add cues you already know (publisher, edition, year, language). A corrected misspelling automatically merges with a correctly-read sighting of the same game from another photo. If the line already had a BGG match, saving re-queues it so resolve searches again with the corrected data.</p>
<p><b>split into copies</b> — one line, several physical boxes? Splitting makes each photo its own copy, and each copy picks its own edition afterward. Appears on any line whose title was seen in more than one photo. Splitting one game never affects a same-named different edition.</p>
@@ -43,7 +43,7 @@
</div>
<h2 id="statuses">What the status chips mean</h2>
<div class="card">
<div class="card prose">
<p><span class="chip wait">awaiting BGG</span> extracted, not yet matched — usually waiting on the BGG API token.</p>
<p><span class="chip ok">auto</span> matched confidently, no review needed. <span class="chip ok">approved</span> you picked the match yourself.</p>
<p><span class="chip open">ambiguous</span> several plausible games — needs your pick on Review. <span class="chip open">unmatched</span> nothing plausible found — enter a BGG id or re-search on Review.</p>
@@ -53,14 +53,24 @@
</div>
<h2 id="keys">Keyboard shortcuts</h2>
<div class="card">
<div class="card prose">
<p><b>Review:</b> <kbd>j</kbd>/<kbd>k</kbd> move between cards · <kbd>1</kbd><kbd>9</kbd> pick a candidate · <kbd>r</kbd> reject · <kbd>m</kbd> manual BGG id · <kbd>u</kbd> edition unknown · <kbd>v</kbd> veto a merge.</p>
<p><b>Photo pages:</b> <kbd></kbd>/<kbd></kbd> move between photos.</p>
</div>
<h2 id="files">Your data, on disk</h2>
<div class="card">
<div class="card prose">
<p>Everything lives in flat files under <code>data/</code> — inspectable, hand-editable, and git-friendly. The pipeline artifacts: <code>titles.json</code> (what was read), <code>matches.csv</code> (what it matched), <code>to_add.csv</code>/<code>to_update.csv</code> (what upload will do), <code>upload_log.csv</code> (what it did), <code>games.json</code> (the library). Your curation: <code>title_edits.json</code>, <code>title_splits.json</code>, <code>title_removals.json</code>, <code>unidentified_dismissed.json</code>.</p>
<p>Credentials never live in files — only environment variables, set up by <code>bggpipe init</code>. The app serves localhost only, unless started with <code>--lan</code> — that opens it to your network behind a per-run access key printed at startup (no login beyond the key; trusted networks only).</p>
<p>More depth: the README covers setup and photo technique; <code>docs/bgg-upload-flow.md</code> documents the upload automation.</p>
</div>
<h2 id="mascot">The piper</h2>
<div class="card prose artcard">
<img src="/static/logo-full.jpg"
alt="the bggpipe piper — a bagpiper whose bag is a board game box">
<p>Mascot art by Juniper, used with pride.</p>
<p class="legal">BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC.
bggpipe is an independent project, not affiliated with or endorsed by
BoardGameGeek.</p>
</div>
+3 -1
View File
@@ -14,7 +14,9 @@
<img src="/static/logo.jpg" alt="">
<span class="wordmark">bggpipe<small>shelf → BGG pipeline</small></span>
</a>
<nav aria-label="Primary">
<button class="navtoggle" aria-expanded="false" aria-controls="primary-nav"
aria-label="Menu"><span class="bars"></span></button>
<nav id="primary-nav" aria-label="Primary">
<!--NAV-->
</nav>
<figure class="piperbox">
+2 -2
View File
@@ -205,7 +205,7 @@ def test_both_pages_carry_navigation_and_skip_link(tmp_path):
web = _app(_cfg(tmp_path))
for path, current in (("/", 'href="/"'), ("/review", 'href="/review"')):
html = web.get(path).text
assert 'nav aria-label="Primary"' in html
assert 'aria-label="Primary"' in html
assert f'<a {current} aria-current="page"' in html.replace("\n", " ") or (
current in html and 'aria-current="page"' in html
)
@@ -232,7 +232,7 @@ def test_every_page_serves_with_shared_shell(tmp_path):
):
html = web.get(path).text
assert marker in html, path
assert 'nav aria-label="Primary"' in html, path
assert '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