SPEC_ANSWERHUB_DISPLAY.md
AnswerHub display logic — back to the old application's hierarchy
What this covers: how an answer is presented — in a listing, in search,
and on a question page — plus the two data faults that presentation
uncovered. Drafted from a /grill-me session. Lives in the
apps-siao-ai repo, on top of the shipped frontend restyle
(SPEC_ANSWERHUB_FRONTEND.md) and the SEO/corpus work
(SPEC_ANSWERHUB_SEO.md, tickets 01–10, done).
For what is built or deployed, see ClaudeDocs/HANDOFF.md — status
deliberately does not live here.
"The old application" throughout means ~/Lwopan/learning-platform, the
pre-migration app whose page structure SPEC_ANSWERHUB_FRONTEND.md
already committed AnswerHub to preserving.
Problem Statement
AnswerHub is an answer bank whose answers are invisible until you click.
A visitor searches a question, gets a list of rows carrying a title, a
description, an author and three engagement counters, and has to open a
page to find out what the answer is. The old application did the
opposite: the answer was the headline, rendered large and coloured, and
the question shrank to a single supporting line beneath it. That
hierarchy is the product. The current listing is the shape of a forum
post, and it was arrived at by original design at a point where
SPEC_ANSWERHUB_FRONTEND.md had already specified "the same page
structure the old app had — a visual system change, not an
information-architecture redesign". The owner has asked for the old
application's hierarchy repeatedly; the surfaces still do not have it.
The same habit produced a second, worse fault. The question page
presents three verification states, one of which — DISPUTED, with a
dedicated two-readings-side-by-side layout and a written rationale for
why it asserts neither — has never rendered a single row, because the
stored state is DISPUTED on zero of 12,780 submissions. Layout was
built for a state the data does not contain.
Re-deriving each submission's state from the answers actually stored shows the column is wrong on 4,522 rows (35%):
| Stored → derived | Rows | What it means |
|---|---|---|
UNVERIFIED → DISPUTED |
2,191 | Real disagreements filed as "nobody checked" |
UNVERIFIED → VERIFIED |
1,549 | Agreement never credited |
VERIFIED → UNVERIFIED |
708 | Claims two sources agree with no model answer at all |
VERIFIED → DISPUTED |
74 | Stored state and the agreement function contradict each other |
The 708 are the serious ones: pages currently telling a reader two independent sources agreed, when only one answer exists.
A third fault blocks the hierarchy itself. The old application's card
takes its headline answer by parsing the submission's files JSON.
files is [] on all 12,780 rows — copying that code would render an
empty headline everywhere. And the question text a Q line needs is not
stored: the importer truncates the question to 80 characters into
title (3,473 rows, 27%, are truncated) and keeps nothing else. Where a
reading passage is shared by several sub-questions, their truncations
are byte-identical — one passage accounts for 37 rows whose titles cannot
be told apart. This reads as duplicate content and is in fact 37
distinct questions. The full text still exists in the corpus source
files on disk; only the import discarded it.
Solution
Rebuild AnswerHub's display logic around the old application's information hierarchy, keeping the current design tokens. In every listing the answer is the headline and the question is a supporting line; on a question page the highest-confidence answer sits in an isolated "猜你想找這個" block above everything else, and the frame carries a light confidence tint. Search results order answered questions ahead of unanswered ones. The homepage searches in place — results expand below the hero rather than navigating away — and its latest-answers strip becomes a right-to-left marquee carrying one question line and its source per item.
Route every one of those surfaces through one shared display seam, a pure function that turns a submission into the model both the card and the question page render. Today the homepage, search and question page each decide independently what to show, which is why the same answer looks like a different kind of thing depending on where it is found.
Derive confidence at read time from the stored source answer and
model answer, using the existing agreement module, rather than trusting
the verification column. This is what lets the interface be correct
before the data is: the 708 false cross-verification claims stop being
made the moment the seam ships, without waiting on a migration.
In parallel, fix the data: rewrite the verification column from the
same derivation, and recover the full question text from the corpus
source files — which are still on disk, so no re-crawl is needed.
User Stories
Finding an answer
- As a visitor, I want a search result to show me the answer itself, so that I do not have to open a page to find out what I searched for.
- As a visitor, I want the answer rendered as the largest, most prominent thing on the row, so that I can scan a list of results without reading any of them closely.
- As a visitor, I want the question shown as a single supporting line beneath the answer, so that I can confirm the row is the question I meant.
- As a visitor, I want questions whose answer is known to appear ahead of questions with no answer, so that the top of my results is usable.
- As a visitor, I want a question with no answer to still appear in results, marked as unanswered, so that I learn the question exists here and is simply not solved yet.
- As a visitor, I want an unanswered row to keep the same shape as every other row, so that a list does not fragment into several card designs.
- As a visitor searching from the homepage, I want results to appear below the hero without navigating away, so that I can refine my query without losing the page.
- As a visitor, I want the homepage to show a first page of results with a way to see the rest, so that an in-place expansion does not become an unbounded scroll.
As a visitor, I want two sub-questions of the same reading passage to be distinguishable in a result list.Withdrawn. Written on the belief that 113 groups of look-alike rows were passages with distinct sub-questions. At least 40 of them, 83 rows, are the same question imported more than once with different trailing furniture, and some of those disagree about the answer. A listing that told them apart would be presenting copies as distinct content. Recorded with the measurements in.scratch/answerhub-display/issues/12, which is corpus work, not display work.- As a visitor, I want a result row to carry its language, author, likes, comments and views, so that I can judge a row on more than its answer.
Judging an answer
- As a visitor, I want a small badge on each result telling me how far the answer has been checked, so that I can weigh a result before opening it.
- As a visitor, I want the badge to reflect the answers actually stored for that question, so that a page never claims a confidence its data does not support.
- As a visitor, I want a question page's frame to carry a light tint matching its confidence, so that the page's trustworthiness is legible before I read a word.
- As a visitor, I want a question page to lead with a single isolated "猜你想找這個" block holding the highest-confidence answer, so that the page answers me immediately.
- As a visitor, I want a question whose two sources disagree to show me its answer and say plainly that they disagree, so that I get what I came for and know exactly how much to lean on it.
- As a visitor, I want a question with no second opinion to say that plainly, so that "unchecked" is never dressed up as "verified".
- As a visitor, I want the source's answer and the model's independent answer shown for comparison when both exist, so that I can judge a disagreement myself.
- As a visitor, I want the most similar other questions listed below the answer, so that I have somewhere to go when this question was not quite mine.
- As an owner, I want no page to claim cross-verification without a second answer behind it, so that the product's core claim stays honest.
The homepage
- As a visitor, I want the homepage to lead with the product name and a search box, so that its purpose is obvious in one screen.
- As a visitor, I want a marquee of recent questions scrolling right-to-left, so that the homepage shows the bank is alive.
- As a visitor, I want each marquee item to carry one question line and its source, so that the strip is readable while it moves.
- As a visitor, I want the marquee to pause when I point at it and its items to be clickable, so that it is not decoration I cannot use.
- As a visitor using a keyboard or a screen reader, I want the recent-questions content reachable without interacting with the marquee, so that motion is never the only path to it.
- As a search engine, I want the homepage's recent questions present as static, crawlable links, so that a motion component does not cost the site its index coverage.
Authors and moderators
- As an author, I want my own drafts and pending submissions to show their review state in listings, so that I can tell what has been published.
- As a moderator, I want hidden and archived submissions marked in the listings I see, so that moderation state is visible where I work.
- As a visitor, I want review-state badges absent from public listings, so that a badge reading "published" on every row is not sold to me as information.
Data correctness
- As an owner, I want the stored verification state rewritten to match what the agreement function derives, so that exports, structured data and the admin surface agree with what pages display.
- As an owner, I want the 708 rows claiming verification without a model answer corrected first, so that the most damaging class of error is the first to go.
- As an owner, I want the full question text recovered from the corpus
source files, so that a
Qline can carry the real question rather than an 80-character truncation. - As an owner, I want questions sharing a reading passage to become individually identifiable once their text is recovered, so that 113 groups of look-alike rows resolve without hand-editing.
- As an owner, I want the recovery to run from files already on disk, so that no source site has to be crawled again.
Implementation Decisions
The display seam
A new pure module turns one submission into the model that every surface renders. The card and the question page become renderers with no display logic of their own. It is unit-tested and imports nothing from React.
The shape, which encodes the decisions more precisely than prose (derived during the grilling session from the two corpora's actual contents):
type Headline =
| { kind: "answer"; text: string }
| { kind: "unanswered" }; // no answer in any source
interface AnswerDisplay {
headline: Headline;
questionLine: string; // full question when recovered, else the title
confidence: "VERIFIED" | "DISPUTED" | "UNVERIFIED";
options: { text: string; correct: boolean }[] | null;
}
/** A listing renders no choices, so it does not carry them. */
type AnswerRowDisplay = Omit<AnswerDisplay, "options">;
- A disputed question is not a third headline. The draft of this spec gave it one, reading "兩個來源說法不同" where the answer belongs. Built and looked at, it put a status message in the slot the eye lands on, at the size and weight of an answer, so the sentence read as though it were the answer — and left a visitor with nothing. All 2,265 disputed rows carry a published answer and it is the source site's in every case. The answer is shown; the tint, the mark and the two readings below carry the model's dissent. Confidence is a property of an answer, not a substitute for one.
headlineis the only thing that varies, so every listing row keeps one layout regardless of state.questionLinedegrades totitlewhile the recovered question text is not yet in the database. This is the one accepted regression: until then, the 113 passage groups still render identicalQlines.optionsmarks the correct choice by comparing option text against the published answer. There is deliberately no stored correct-index — the schema comment already records why a second source of truth was refused.
Showing an answer and asserting one are different claims
Visually a disputed question leads with its answer. In the structured
data it emits both readings as suggestedAnswer and no
acceptedAnswer, exactly as before. Showing a reader an answer wrapped
in a warning they can see is a weaker claim than telling a search engine
this is the answer, and only the second is a promise the corpus cannot
keep. The two were conflated in the draft, which is why "asserts
neither" read as one decision instead of two.
Confidence is a tint on the frame, never a colour on the answer
The first build coloured the answer text per state — green, amber,
grey — which put three meanings on the one element the eye is supposed
to land on and stopped the answer's colour meaning "this is the answer".
The old application had it the other way round: one colour for the
answer always, the card behind it carrying the tier. Only VERIFIED and
DISPUTED tint; UNVERIFIED is seven rows in ten and a tint that
common is a background, not a signal.
Confidence is derived, not read
The existing agreement module's verdict function is called at read time
with the stored source answer and model answer. The verification
column is not consulted by any display surface. Consequences accepted:
- The interface is correct on all 12,780 rows the day it ships, ahead of any migration.
DISPUTEDbecomes a state that actually renders — on roughly 2,265 rows rather than zero.- The 74 rows where stored state and derived verdict contradict each
other resolve in favour of the derivation. Some of those are the
agreement function being too strict (
小雨、小盛描述均正確against兩人皆正確is one answer written twice); tuning it is out of scope here and is called out in Further Notes.
The search seam
Search currently builds the same where/orderBy twice — once in the
search API route, once in the search page. This is prefactored into one
pure query builder before ordering changes, so answered-before-unanswered
is decided once.
Ordering is: answered ahead of unanswered, then the existing recency
order. Confidence does not enter the ordering — an unverified answer
is still an answer, and burying DISPUTED would hide exactly the rows
worth reading.
Do not copy the old application's answer parsing
The old application's card derives its headline by parsing the
submission's files JSON for question, options and correctAnswer.
That field is [] on every row in this database. The hierarchy is
copied; the data access is not. The headline comes from the published
answer column, and choices come from the dedicated options column
(populated on 7,296 rows).
Card contents
Answer headline, then the question line, then a metadata row carrying language, author, likes, comments and views. Review-state badges render only for an author viewing their own work and on the moderation surface.
Listings are also one column, not a grid. This is not styling: the
card was rewritten first and dropped back into a three-up grid, where a
text-xl answer sits in a 300px column, wraps onto two lines and reads
smaller than the question under it. The hierarchy was in the markup and
absent from the page. The old application used a single narrow column
for exactly this reason. One shared list component, so three listings
cannot drift apart again — which is how they got there.
The description is shown only when it says something
Never, for an imported question. Measured across all 12,780 rows: 6,324
hold the explanation verbatim, 5,703 hold the title the page's own h1
is already showing, and 753 hold the published answer. Not one carries
something of its own — the importer writes
explanation || answer || title into it. Moving the answer to the top
of the question page put two identical paragraphs in plain sight. It is
rendered when it differs from all three, which is what a manual
submission does.
Question page
An isolated highest-confidence block above all other content; the page frame carries a light confidence tint. Below it: the working, then the source answer and model answer side by side where both exist, then the most similar questions. There is no paginated "other answers" list — outside one cross-source pair, no question in this corpus has more than the two candidate answers already shown.
Homepage
The hero and its search box stay. Search submits in place and expands
results below the hero, first page plus a link to the full search page;
the search page keeps its own URL for indexing and sharing. The latest
answers strip becomes a right-to-left marquee whose items carry one
question line and a source site, pause on hover, and are links. There is no second, separate static list, though the draft called for
one: rendering the same items twice means a screen reader reading the
strip twice and the keyboard tabbing through it twice. Instead the
first track holds the real links — ordinary markup a crawler
indexes, a screen reader reads and the keyboard tabs through — and the
animation is a transform applied to it. A second track, aria-hidden
and untabbable, exists only so the loop has no visible seam. Motion is a
presentation of the content, never the only path to it.
It stops on hover and on focus-within, because a link that keeps moving under a keyboard user is a link they cannot press. The repo's global reduced-motion rule already switches the animation off; the container scrolls so items past the edge stay reachable once it has.
Data track, running in parallel
- Rewrite
verificationfrom the same derivation the interface uses, the 708 no-model-answer rows first. - Add a column for the full question text, backfill it from the corpus
source files already on disk, and only then switch the question
line to it. The 80-character
titlestays as-is for slugs and page titles, which are already bounded by an earlier ticket and must not churn.
Those three steps are three changes, not one. Landing the column and the
queries that read it together left the application unable to start
against any database that had not been pushed — the homepage answered
500 with The column main.Submission.question does not exist. Expand,
migrate, then switch the reads: the column and the backfill script can
land safely because nothing reads them yet.
Scope of surfaces
Search, question detail, homepage, taxonomy index pages, upload, profile
and admin. Account pages are not touched — identity moved to
accounts.siao.ai.
Existing structural guard
A structural test requires any file reading a content model to consult the visibility seam. New modules and surfaces must satisfy it rather than be added to its exemption list.
Testing Decisions
A good test here asserts what a reader sees and never how it was computed. Rendering a submission and asserting "the answer is the headline" is a test; asserting that a particular helper was called is not.
- The display seam carries the bulk of the coverage, as unit tests: each of the three headline shapes; question line falling back to title before recovery and using the recovered text after; correct-option marking including the case where no option text matches the published answer; and the confidence value tracking the derivation rather than the stored column. Prior art: the agreement module's own tests, which are the closest existing example of a pure decision function with the product resting on it.
- The search query builder is unit-tested for ordering: answered before unanswered, recency within each group, and an unanswered question still being returned rather than filtered out.
- Confidence derivation needs no new unit tests — it reuses a tested function. What is new and must be tested is that display surfaces call it instead of reading the column.
- End-to-end coverage extends the existing search and homepage
specs: a search shows an answer without navigation; homepage search
expands in place; an unanswered question renders its marked row; a
question page leads with its isolated answer block. The existing
three-state end-to-end coverage is repointed at the derivation, and
must now include a
DISPUTEDrow, which was previously unreachable with real data. - Accessibility coverage gains the marquee: reduced-motion honoured, items focusable and activatable by keyboard, and the static list present in the DOM. Prior art: the existing accessibility spec.
- Visual snapshots are re-taken for the card and the question page, since the hierarchy change is the point of the work.
- Data migrations are verified by re-deriving after the rewrite and
asserting zero rows disagree, and by asserting no row holds a verified
state without a model answer. Both scripts take
--dry-runand are idempotent, and both were measured against the real corpus before being pointed at it. - The suite seeds itself. Nine specs failed against a database that
had never seen the fixtures they are written for, and they failed like
product defects — index pages answering 404, verification states
asserting nothing — rather than like an empty database. The two seed
scripts have been described as "the fixtures the end-to-end tests
need" the whole time and running them was still something a person had
to know to do. They run in the global setup, which also puts an author
and a category in place first, because both scripts dereference a
findFirstthat is null on a freshly pushed database.
Out of Scope
Account pages, sign-in and profile identity — owned by
accounts.siao.ai.Blog and knowledge base, still cut from the navigable product.
Wiring live search; the query builder stays database-backed and the Algolia request builder is untouched.
Tuning the agreement function's strictness — the cases where it calls one answer written two ways a disagreement, like
Who's he?againstWho is he?. Those need their own measurement.Its looseness was in scope after all, because it was measured. Containment accounts for 324 of 1,597 agreements; 26 have a length gap wide enough to look suspicious; reading all 26 shows 12 where the "source answer" is really the stem and every option —
Java C++ 機器語言 組合語言— which the model then answers correctly from, so containment reports two sources agreeing when one never gave an answer. A guard refuses exactly those 12 and leaves the other 312 alone.Re-crawling any source site. Question-text recovery reads files already on disk; if a question's text turns out not to be recoverable from them, it keeps the truncated title. (It did not: all 12,780 matched.)
Deduplicating the corpus. Recovering the question text is what made the duplicates legible — the same question stored several times with varying amounts of option list and attribution stuck to the end — but merging them, and deciding what to do where two copies disagree about the answer, is its own piece of work.
Any change to slugs or page titles. Both are already bounded and churning them would mean thousands of redirects.
A user-submitted-answers model. Several decisions here were framed around "other answers" existing; the schema has no such relation and adding one is a separate product decision.
Further Notes
How the visual work was checked, after it was not. The first build of the card was verified by grepping the served HTML for the strings it was supposed to contain. They were all there, and the page was still wrong: the rewritten card had been dropped back into a three-up grid, where the answer wrapped and read smaller than the question beneath it. Markup presence is not hierarchy. Everything visual here was afterwards checked by opening it in a browser, which is also how the duplicated description paragraph and the mis-centred column were found — none of them would have failed a test that was looking for text.
Two errors can be stacked. The homepage's 500 after the column landed was reported as the missing column; it was actually a dev server holding a Prisma client generated before the schema changed. The missing column was the next error. The cause was named from grepping a log for a word rather than reading the error, which is the same mistake as the paragraph above in a different medium.
The two faults in this spec share one cause with the display problem
they were found behind: layout and rationale were written for states the
data did not hold. The DISPUTED panel carried a paragraph explaining
why it asserts neither reading, and rendered zero rows. The card carried
a comment justifying the omission of the old application's verification
badge, and omitted the answer instead. Deriving confidence at read time
is the structural fix — a state that cannot be computed from stored
columns cannot be given a layout.
Corpus facts measured during the grilling session, recorded so they are not re-measured: 12,780 submissions across two source sites (happyread 7,298, netholiday 5,482); 6,539 (51%) with no published answer; 7,296 with options; 3,862 with both a source answer and a model answer; exactly one question appearing in both sources; 113 title collision groups, all artefacts of truncation rather than duplicates.