From db6bfc40069edc5d818f72b44a1fe1ba1dae99a7 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Mon, 19 Oct 2026 16:32:44 -0700 Subject: [PATCH 1/3] Show series and issue detail under each match candidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Online-tag candidates often share a near-identical cover and an identical title ("Batman #1 (2016)"): a reprint volume, a foreign or a collected edition. comicbox 5.4 carries what tells them apart, and the dialog now shows it in two subdued lines under the title: DC Comics · Limited Series · Series began 2016 · 6 issues "I Am Gotham, Part One" — serialize_candidate passes the six new summary fields through by name, so an older comicbox and prompts cached before them degrade to absent fields. PROMPT_VERSION is unchanged; it gates fingerprints, not payload shape. The serializer test uses a stand-in summary because the pinned comicbox predates the fields. The publisher moves off the title line into the detail line (series_publisher, falling back to the scored publisher, which neither source fills today). The score chip and Pick button no longer shrink, so long detail text wraps instead of clipping them. Co-Authored-By: Claude Opus 5.5 --- codex/librarian/onlinetag/session_state.py | 15 ++++ .../components/online-tag/candidate-row.vue | 75 +++++++++++++++++-- frontend/tests/unit/candidate-row.test.js | 71 ++++++++++++++++++ tests/test_onlinetag_serialize_candidate.py | 30 ++++++++ 4 files changed, 185 insertions(+), 6 deletions(-) diff --git a/codex/librarian/onlinetag/session_state.py b/codex/librarian/onlinetag/session_state.py index bf25c4ab5..d66a68c26 100644 --- a/codex/librarian/onlinetag/session_state.py +++ b/codex/librarian/onlinetag/session_state.py @@ -215,6 +215,21 @@ def serialize_candidate(c) -> dict[str, Any]: # The series' ordinal volume. Metron's issue rows carry one; # Comic Vine's search results have no equivalent. "volume": getattr(summary, "volume", None), + # What tells apart candidates whose covers and titles read + # identically: a reprint volume, a foreign or a collected + # edition. Comic Vine fills most of these from rows it already + # fetched. Metron always has the start year, but gets the + # format, publisher and issue count only when titles collide. + # Absent before comicbox 5.4, and None wherever a source has + # nothing. + "series_start_year": getattr(summary, "series_start_year", None), + "series_publisher": getattr(summary, "series_publisher", None), + "series_issue_count": getattr(summary, "series_issue_count", None), + # Publication format, e.g. "Trade Paperback". Metron only. + "series_type": getattr(summary, "series_type", None), + # The issue's own title and one-line deck. Comic Vine only. + "issue_title": getattr(summary, "issue_title", None), + "issue_summary": getattr(summary, "issue_summary", None), }, "score": float(c.score), # What the blended score is made of. Two candidates can read diff --git a/frontend/src/components/online-tag/candidate-row.vue b/frontend/src/components/online-tag/candidate-row.vue index 2936b7c62..c7adc8377 100644 --- a/frontend/src/components/online-tag/candidate-row.vue +++ b/frontend/src/components/online-tag/candidate-row.vue @@ -6,6 +6,16 @@ a link to the source's own page, and what the blended score is made of (#854). + Covers are often near-identical too: a reprint volume, a foreign or a + collected edition. So two subdued lines under the title carry what the + source knows about the series (publisher, format, start year, issue + count) and about the issue (its title and one-line deck). Comic Vine + supplies most of it from rows comicbox already fetched. Metron always + has the start year, but its format, publisher and issue count cost + comicbox a request, so they arrive only when titles would otherwise + collide. Any of it may be absent, and a prompt cached before comicbox + carried it shows neither line. + The thumbnail loads straight from the source CDN. An is exempt from CORS, and codex's own img-src allows the two upload paths (see _ONLINE_TAG_COVER_SECURE_CSP), so there is nothing to proxy. A blocked @@ -44,9 +54,6 @@ ({{ candidate.summary.year }}) - - — {{ candidate.summary.publisher }} - {{ sourceLabel(candidate.source) }} {{ mdiOpenInNew }} +
+ {{ seriesDetail }} +
+ +
+ {{ issueDetail }} +
@@ -67,8 +81,17 @@ {{ scoreDetail }} - {{ scorePercent }}% - + + + {{ scorePercent }}% + + Pick @@ -107,6 +130,31 @@ export default { scorePercent() { return Math.round(this.candidate.score * PERCENT); }, + seriesDetail() { + const { + seriesPublisher, + publisher, + seriesType, + seriesStartYear, + seriesIssueCount, + } = this.candidate.summary; + return [ + seriesPublisher || publisher, + seriesType, + seriesStartYear ? `Series began ${seriesStartYear}` : "", + seriesIssueCount + ? `${seriesIssueCount} ${seriesIssueCount === 1 ? "issue" : "issues"}` + : "", + ] + .filter(Boolean) + .join(" · "); + }, + issueDetail() { + const { issueTitle, issueSummary } = this.candidate.summary; + return [issueTitle ? `“${issueTitle}”` : "", issueSummary] + .filter(Boolean) + .join(" — "); + }, scoreDetail() { // Prompts cached before these fields existed carry neither, and // the headline percentage alone is still meaningful. @@ -163,17 +211,32 @@ export default { .candidateVolume, .candidateYear, -.candidatePublisher, +.candidateSeriesDetail, +.candidateIssueDetail, .candidateAka, .candidateScoreDetail { color: rgb(var(--v-theme-text-secondary)); } +.candidateSeriesDetail, +.candidateIssueDetail, .candidateAka, .candidateScoreDetail { font-size: 0.8125rem; } +// A deck can run long. Two lines, then an ellipsis: one line left a +// phone-width row about a dozen characters, and touch has no tooltip. +// Same multi-line clamp as browser-table-cell.vue. +.candidateIssueDetail { + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: break-word; +} + .candidateSourceLink { margin-left: 8px; white-space: nowrap; diff --git a/frontend/tests/unit/candidate-row.test.js b/frontend/tests/unit/candidate-row.test.js index a3313f9c4..4a171f602 100644 --- a/frontend/tests/unit/candidate-row.test.js +++ b/frontend/tests/unit/candidate-row.test.js @@ -222,3 +222,74 @@ describe("CandidateRow details", () => { expect(wrapper.emitted("pick")).toHaveLength(1); }); }); + +/* + * Near-identical covers with identical titles are common: a reprint + * volume, a foreign or a collected edition. Two subdued lines under the + * title carry what tells them apart. + */ +describe("CandidateRow series and issue detail", () => { + const DETAIL = { + seriesPublisher: "DC Comics", + seriesType: "Ongoing Series", + seriesStartYear: 2016, + seriesIssueCount: 85, + issueTitle: "I Am Gotham, Part One", + issueSummary: "A new hero arrives.", + }; + + test("joins what the source knows about the series", () => { + const line = mountRow({ summary: DETAIL }).find(".candidateSeriesDetail"); + + expect(line.text()).toBe( + "DC Comics · Ongoing Series · Series began 2016 · 85 issues", + ); + }); + + test("a one-issue series says issue, not issues", () => { + const line = mountRow({ summary: { seriesIssueCount: 1 } }).find( + ".candidateSeriesDetail", + ); + + expect(line.text()).toBe("Dark Horse · 1 issue"); + }); + + test("the series publisher wins over the scored one", () => { + const line = mountRow({ summary: { seriesPublisher: "Panini" } }).find( + ".candidateSeriesDetail", + ); + + expect(line.text()).toBe("Panini"); + }); + + test("the publisher has left the title line", () => { + const wrapper = mountRow(); + + expect(wrapper.find(".candidatePublisher").exists()).toBe(false); + expect(wrapper.find(".candidateSeriesDetail").text()).toBe("Dark Horse"); + }); + + test("quotes the issue title before its deck, the whole as a tooltip", () => { + const line = mountRow({ summary: DETAIL }).find(".candidateIssueDetail"); + const text = "“I Am Gotham, Part One” — A new hero arrives."; + + expect(line.text()).toBe(text); + expect(line.attributes("title")).toBe(text); + }); + + test.each([ + [{ issueTitle: "Title" }, "“Title”"], + [{ issueSummary: "Deck" }, "Deck"], + ])("shows either half of the issue line alone", (summary, expected) => { + const line = mountRow({ summary }).find(".candidateIssueDetail"); + + expect(line.text()).toBe(expected); + }); + + test("a prompt cached before comicbox carried these shows neither line", () => { + const wrapper = mountRow({ summary: { publisher: null } }); + + expect(wrapper.find(".candidateSeriesDetail").exists()).toBe(false); + expect(wrapper.find(".candidateIssueDetail").exists()).toBe(false); + }); +}); diff --git a/tests/test_onlinetag_serialize_candidate.py b/tests/test_onlinetag_serialize_candidate.py index 5759964c4..e3c017b84 100644 --- a/tests/test_onlinetag_serialize_candidate.py +++ b/tests/test_onlinetag_serialize_candidate.py @@ -11,6 +11,7 @@ the serializer against real comicbox objects. """ +from types import MappingProxyType, SimpleNamespace from typing import Final from comicbox.formats.base.online.profile import Candidate, CandidateSummary @@ -25,6 +26,16 @@ _METADATA_SCORE: Final = 1.0 _COVER_SCORE: Final = 0.6 _VOLUME: Final = 2 +_DETAIL: Final = MappingProxyType( + { + "series_start_year": 2016, + "series_publisher": "DC Comics", + "series_issue_count": 85, + "series_type": "Ongoing Series", + "issue_title": "I Am Gotham, Part One", + "issue_summary": "A new hero arrives.", + } +) def _summary( @@ -169,6 +180,25 @@ class _Bare: assert data["cover_score"] is None assert data["cover_hash_attempted"] is False assert data["volume_id"] is None + for key in _DETAIL: + assert data["summary"][key] is None, key + + def test_what_tells_identical_titles_apart_is_carried(self) -> None: + """ + Near-identical covers and titles differ in these. + + The summary is a stand-in because the pinned comicbox predates + the fields; the serializer reads them by name either way. + """ + summary = SimpleNamespace(series="Batman", issue="1", year=2016, **_DETAIL) + candidate = SimpleNamespace( + source="comicvine", issue_id=1, score=0.5, summary=summary + ) + + data = serialize_candidate(candidate) + + for key, value in _DETAIL.items(): + assert data["summary"][key] == value, key def test_display_fields_do_not_bump_the_prompt_version(self) -> None: """ From c201d30a19310f777758c736e6098c7d8e78fce7 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Mon, 19 Oct 2026 17:25:35 -0700 Subject: [PATCH 2/3] Give match-candidate text the row's width on phones At phone width a candidate row held a 48px cover, the text, the score chip and the Pick button side by side, leaving the text about 100px: series titles wrapped a word per line and the new series and issue detail lines showed a few words each. The chip and Pick now sit in one non-shrinking group. Below Vuetify's sm breakpoint the row wraps and that group takes its own right-aligned line, while the text, given a zero flex basis, keeps the cover's line and its full width. Wider layouts are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../components/online-tag/candidate-row.vue | 55 ++++++++++++++----- frontend/tests/unit/candidate-row.test.js | 17 ++++++ 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/online-tag/candidate-row.vue b/frontend/src/components/online-tag/candidate-row.vue index c7adc8377..a586542ca 100644 --- a/frontend/src/components/online-tag/candidate-row.vue +++ b/frontend/src/components/online-tag/candidate-row.vue @@ -81,19 +81,19 @@ {{ scoreDetail }} - - - {{ scorePercent }}% - - - Pick - + +
+ {{ scorePercent }}% + + Pick + +
@@ -186,6 +186,9 @@ export default { diff --git a/frontend/tests/unit/candidate-row.test.js b/frontend/tests/unit/candidate-row.test.js index 4a171f602..79c10f467 100644 --- a/frontend/tests/unit/candidate-row.test.js +++ b/frontend/tests/unit/candidate-row.test.js @@ -214,6 +214,23 @@ describe("CandidateRow details", () => { expect(mountRow().find(".candidateVolume").exists()).toBe(false); }); + /* + * On phones the score and Pick wrap below the text as one unit, so the + * text gets the row's width. happy-dom applies no media queries; this + * pins the grouping the phone styles rely on. + */ + test("groups the score and Pick apart from the text", () => { + const wrapper = mountRow(); + const actions = wrapper.find(".candidateRow > .candidateActions"); + + expect(actions.exists()).toBe(true); + expect(actions.find(".v-chip").text()).toBe("92%"); + expect(actions.find("button").text()).toBe("Pick"); + expect(wrapper.find(".candidateInfo .candidateActions").exists()).toBe( + false, + ); + }); + test("emits pick", async () => { const wrapper = mountRow(); From 36978064ab135600698bc2ca75506e74a338be2b Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Mon, 19 Oct 2026 17:30:07 -0700 Subject: [PATCH 3/3] Shrink the candidate deck and score caveat a step The issue title/deck line and the "Cover not compared" / score breakdown line drop from 0.8125rem to 0.75rem, the smallest size codex already uses. They are the least needed text in a candidate row; the series line and a.k.a. names keep 0.8125rem. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/online-tag/candidate-row.vue | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/online-tag/candidate-row.vue b/frontend/src/components/online-tag/candidate-row.vue index a586542ca..e060c0e81 100644 --- a/frontend/src/components/online-tag/candidate-row.vue +++ b/frontend/src/components/online-tag/candidate-row.vue @@ -222,10 +222,14 @@ export default { } .candidateSeriesDetail, +.candidateAka { + font-size: 0.8125rem; +} + +// A step smaller: the deck and the score caveat are the least needed. .candidateIssueDetail, -.candidateAka, .candidateScoreDetail { - font-size: 0.8125rem; + font-size: 0.75rem; } // A deck can run long. Two lines, then an ellipsis: one line left a