Files
learnharness/components/answered-card.tsx
T
amosblomqvist 3691e07118 ui: the document, all-in — card-color bands and the 6px rail
Owner round two on user-vs-agent clarity (the chat-comeback was tried
and set aside): we go all in on documents. No chat sides. User messages
and every transcript user-thinking row are full-width CARD-COLOR BANDS
- the band is the authorship mark against the agent's transparent
prose. No rounded corners, no borders besides the rail.

The rail keeps its muted color but is now 6px wide (was 2px): the
artificially wide 3ch inset (the chevron-alignment law with the agent's
flush rows) is re-split - rail eats the air, the rail-to-text gap drops
from 3ch-2px to a natural 3ch-6px, and the total inset stays 3ch so the
chevrons still align. The 'You' label stays on messages - one register
with the thinking labels.

In-card thinking (inside answered cards) takes the rail but NO band:
card-color on card-color would be invisible; geometry alone carries it.
Transcript rows arrive expanded (clearly sent) - the reading lift now
opens on arrival too; the in-card row stays collapsed. Cmd+T still
rules all rows.

Also this round: the answered-card last-child rhythm law
([&>*:last-child]:mb-0) - on ask cards the thinking row's my-3 stacked
with the card's p-4 into a 28px void below (12px above); the card's own
padding is the bottom rhythm when a row ends the card.
2026-10-08 18:17:11 +03:00

198 lines
8.5 KiB
TypeScript

"use client";
import { useState } from "react";
import { ConfidenceRail } from "@/components/confidence-rail";
import { GlyphMark } from "@/components/ui/glyph-mark";
import { ThinkingBlock } from "@/components/thinking-block";
import { cn } from "@/lib/utils";
import { MessageResponse } from "@/components/ai-elements/message";
import type { QuestionAnswerData } from "@/src/core";
/**
* Static rendering of an answered question (tool part in `output-available`).
*
* Quiz answers are GRADED: ✓/✗ marks, the correct answers, and the
* explanation. Ask answers are NOT graded — a plain record of what was said.
* (The v1 bug this pins: ask answers must never get quiz grading UI.)
* Explain answers are graded by the MODEL in its next reply, not by the
* client — the card reveals his answer, the committed correct answer, and
* the explanation, with NO verdict chips (owner ruling 2026-09-27).
*/
export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
if (answer.dismissed) {
return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
<span className="font-medium">{answer.questionText}</span> — skipped
</div>
);
}
const isQuiz = answer.kind === "quiz";
const isExplain = answer.kind === "explain";
const selectedIdx = new Set(answer.answers.map((a) => a.index));
const correctIdx = new Set(answer.correctIndices ?? []);
return (
<>
{/* The reading channel LIFTED (owner round, 2026-10-05): thoughts
captured while reading are chronologically PRIOR to the question —
rendered as its own user-side block ABOVE the card, the unified
ThinkingBlock, instead of an inset inside the answer. Same muted
rail as before; the label is now plain "Your thinking". */}
{answer.readingThinking && (
<ThinkingBlock testId="reading-thinking-block" label="YOUR THINKING" text={answer.readingThinking} defaultOpen />
)}
{/* [&>*:last-child]:mb-0 (owner round, 2026-10-07): on ask cards the
thinking row is the last child — its my-3 stacked with the card's
p-4 into a 28px void below (12px above); the card's own padding is
the bottom rhythm when a row ends the card. */}
<div
className="rounded-xl border bg-card p-4 text-card-foreground [&>*:last-child]:mb-0"
data-testid="answered-card"
>
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{isQuiz ? (answer.questionType === "predict" ? "Prediction" : "Quiz") : isExplain ? "Explain" : "Question"}
{isQuiz &&
(answer.dontKnow ? (
<span className="rounded bg-muted px-1.5 py-0.5">didn't know</span>
) : answer.correct ? (
<span className="rounded bg-green-500/15 px-1.5 py-0.5 text-green-500">correct</span>
) : (
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">incorrect</span>
))}
</div>
<div className="mb-3 font-medium">
<MessageResponse>{answer.questionText}</MessageResponse>
</div>
{((answer.options?.length ?? 0) > 0 || answer.other) && (
<div className="flex flex-col gap-1.5 text-sm">
{answer.options?.map((opt) => {
const sel = selectedIdx.has(opt.index);
const key = isQuiz && correctIdx.has(opt.index);
return (
<div
key={opt.index}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5",
sel && key && "border-green-500/60 bg-green-500/10",
sel && !key && isQuiz && "border-red-500/60 bg-red-500/10",
sel && !isQuiz && "border-primary bg-accent",
!sel && key && "border-green-500/40",
)}
>
{isQuiz && (
// Zapf-Dingbats-derived SVG outlines — the exact LaTeX \ding{51}/
// \ding{55} hand-drawn shapes (owner, round 6); see ui/glyph-mark.
<span
aria-hidden
data-mark={sel && key ? "check" : sel && !key ? "x" : !sel && key ? "check" : undefined}
className={cn(
"flex shrink-0 items-center",
sel && key && "text-green-500",
sel && !key && "text-red-500",
!sel && key && "text-green-500",
)}
>
{/* Owner round 8: a touch smaller than the text (size-4→size-3.5,
≈14px vs the 16px row) while centered. */}
{(key || sel) && <GlyphMark kind={key ? "check" : "x"} className="size-3.5" />}
</span>
)}
<span className={cn(!sel && "text-muted-foreground")}>
<MessageResponse>{opt.label}</MessageResponse>
</span>
</div>
);
})}
{/* The learner's own typed option is an OPTION — same container,
same selected styling as its siblings, not a loose quote
floating outside the list. Quotes keep the learner's words. */}
{answer.other && (
<div
data-testid="answered-other"
className="flex items-center gap-2 rounded-lg border border-primary bg-accent px-3 py-1.5"
>
<span>“{answer.other}”</span>
</div>
)}
</div>
)}
{answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“{answer.text}”</div>}
{/* The confidence record (owner round 6): the slider's rail, disc
removed — the missing handle itself says "this is a record, not a
control". Same paint, same ticks, same ramp, same % read-out as the
live dock. Quiz answers only, and only when a confidence exists:
"I don't know" sent none (its slider was grayed out), explain and
ask never had one, and records from before the feature carry no
field — nothing to show, nothing rendered. */}
{isQuiz && answer.confidence != null && (
<div className="mt-3 flex items-center gap-3" data-testid="answered-confidence">
<ConfidenceRail value={answer.confidence} className="min-w-0 flex-1" />
<span className="w-9 shrink-0 text-right font-mono text-sm tabular-nums text-muted-foreground">
{answer.confidence}%
</span>
</div>
)}
{answer.dontKnow && isQuiz && (
<div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>
)}
{(answer.thinking ?? answer.note) && (
/* The SAME unified row (owner round, 2026-10-05): the quiz answer's
thinking is the identical collapsible ThinkingBlock — it just
happens to sit INSIDE the card, so it takes the rail but no band
(owner round two, 2026-10-07: card-color on card-color would be
invisible; geometry alone carries it there). */
<ThinkingBlock
testId="your-thinking"
label="YOUR THINKING"
text={answer.thinking ?? answer.note ?? ""}
banded={false}
/>
)}
{/* px-3→removed: card p-4 + box px-3 double-inset sat the "Why" label deeper
than the option box edges (owner, round 3). Sides flush with everything
else; py-2 stays for vertical air. */}
{isQuiz && answer.explanation && (
/* The TUCKED explanation — ALL questions (owner, 2026-10-07): the
verdict shows, the explanation sits collapsed behind the Why
toggle — viewable, never center stage. Identical to the
prediction pattern it generalizes (owner, 2026-10-03). */
<div className="mt-2" data-testid="tucked-explanation">
<button
type="button"
onClick={() => setWhyOpen((v) => !v)}
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{whyOpen ? "Hide why" : "Why"}
</button>
{whyOpen && (
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-foreground/50">
<span className="font-medium text-muted-foreground">Why: </span>
{/* The markdown wrapper must go truly inline — a block <p> after the
"Why:" label created the oversized leading gap (owner's fix list). */}
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
</div>
)}
</div>
)}
{/* The explain reveal (owner ruling 2026-09-27): the committed answer
— one field, reasoning included — shown on submit; no verdict,
the model's next reply IS the grading. */}
{isExplain && answer.committed && (
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">The answer: </span>
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.committed}</MessageResponse>
</div>
)}
</div>
</>
);
}