ui: the unified ThinkingBlock — one visual language for the learner's thinking

Owner round (2026-10-05): the same act (a human thinking in text) had two
visual languages — the quiz cards' quote block vs the practice container
with a collapsing chevron and raw pre-wrap text that read oversized.

- components/thinking-block.tsx: the quiz quote-block recipe verbatim
  (rail + uppercase label + lh-prose at foreground/90) carried by a
  faint user-side shell — ml-auto, max-w-[95%] mirroring the user
  bubble's geometry, px-4/py-3 rhythm. Never collapsible. Rails reuse
  the existing vocabulary: primary = committed register, muted =
  working register.
- ThinkingCard becomes the practice adapter: 'Your solution' (primary)
  / 'Your thinking' (muted) — one component, two registers.
- The reading channel LIFTS out of the answered card: chronologically
  prior to the question, it renders above it as its own user-side
  block under the plain 'Your thinking' label; the in-card inset and
  the 'while reading' phrasing are gone.
- lh-card-enter (globals.css): the one-shot 150ms fade+rise mount,
  the app's motion register; honors reduced-motion.
This commit is contained in:
amosblomqvist
2026-10-05 02:25:13 +03:00
parent 09480979c4
commit d19df86b13
5 changed files with 110 additions and 34 deletions
+26
View File
@@ -530,3 +530,29 @@ body {
@theme { @theme {
--font-title: "lh-cmu", serif; --font-title: "lh-cmu", serif;
} }
/* Card enter (owner round, 2026-10-05): the transcript cards' one-shot
mount — a subtle fade + 4px rise, the app's motion register (150ms,
dock-variant/settings-pop taste). Nothing on update, nothing on
unmount. Applied by the unified ThinkingBlock and the practice
cards; honors reduced-motion by appearing. */
@keyframes lh-card-enter {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.lh-card-enter {
animation: lh-card-enter 150ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.lh-card-enter {
animation: none;
}
}
+11 -12
View File
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { ConfidenceRail } from "@/components/confidence-rail"; import { ConfidenceRail } from "@/components/confidence-rail";
import { GlyphMark } from "@/components/ui/glyph-mark"; import { GlyphMark } from "@/components/ui/glyph-mark";
import { ThinkingBlock } from "@/components/thinking-block";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MessageResponse } from "@/components/ai-elements/message"; import { MessageResponse } from "@/components/ai-elements/message";
import type { QuestionAnswerData } from "@/src/core"; import type { QuestionAnswerData } from "@/src/core";
@@ -33,6 +34,15 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
const correctIdx = new Set(answer.correctIndices ?? []); const correctIdx = new Set(answer.correctIndices ?? []);
return ( 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} rail="muted" />
)}
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card"> <div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card">
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <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.questionType === "predict" ? "Prediction" : "Quiz") : isExplain ? "Explain" : "Question"}
@@ -133,18 +143,6 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
</div> </div>
)} )}
{/* The reading channel (owner 2026-10-03): thoughts captured while
reading the teaching prose — a separate block from the question
thinking, same quiet voice. */}
{answer.readingThinking && (
<div data-testid="your-reading-thinking" className="mt-3 border-l-2 border-muted-foreground/30 pl-3">
<span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground">
Your thinking while reading
</span>
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.readingThinking}</span>
</div>
)}
{/* px-3→removed: card p-4 + box px-3 double-inset sat the "Why" label deeper {/* 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 than the option box edges (owner, round 3). Sides flush with everything
else; py-2 stays for vertical air. */} else; py-2 stays for vertical air. */}
@@ -190,5 +188,6 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
</div> </div>
)} )}
</div> </div>
</>
); );
} }
+15 -19
View File
@@ -17,9 +17,13 @@ import { cn } from "@/lib/utils";
* (the committed solution, shown when the problem ended). A dismissal * (the committed solution, shown when the problem ended). A dismissal
* synthesis (outcome:"dismissed") renders as one muted line. * synthesis (outcome:"dismissed") renders as one muted line.
* - ThinkingCard — the learner's own submission, user-side: the register * - ThinkingCard — the learner's own submission, user-side: the register
* is the two buttons' (Nudge = working trace, Commit = answer). * is the two buttons' (Nudge = working trace, Commit = answer). The
* UNIFIED ThinkingBlock (owner round 2026-10-05): the quiz quote-block
* language in a faint user-side shell, never collapsible.
*/ */
import { ThinkingBlock } from "@/components/thinking-block";
/** The practice-thinking marker's details (native-messages.ts). */ /** The practice-thinking marker's details (native-messages.ts). */
export interface PracticeThinkingData { export interface PracticeThinkingData {
problemCallId: string; problemCallId: string;
@@ -115,24 +119,16 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
} }
export function ThinkingCard({ data }: { data: PracticeThinkingData }) { export function ThinkingCard({ data }: { data: PracticeThinkingData }) {
const [open, setOpen] = useState(true); // One component, two registers (owner ruling): the committed register
// carries the primary rail (the quiz answer thinking's paint), the
// working register the muted one.
return ( return (
<div <ThinkingBlock
data-testid="thinking-card" testId="thinking-card"
data-commit={data.commit ? "true" : "false"} dataCommit={data.commit}
className="ml-auto max-w-[85%] rounded-2xl border border-border/60 bg-muted/40 px-4 py-3" label={data.commit ? "Your solution" : "Your thinking"}
> text={data.text}
<button rail={data.commit ? "primary" : "muted"}
type="button" />
onClick={() => setOpen((v) => !v)}
className="flex w-full cursor-pointer items-center gap-2 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
{data.commit ? "Your solution" : "Your thinking"}
<ChevronRightIcon className={cn("size-3.5 shrink-0 transition-transform", open && "rotate-90")} />
</button>
{open && (
<div className="mt-1.5 whitespace-pre-wrap text-[15px]">{data.text}</div>
)}
</div>
); );
} }
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { cn } from "@/lib/utils";
/** The unified user-thinking block (owner round, 2026-10-05): ONE visual
* language for every surface where the learner's own thinking renders —
* practice thinking traces (nudge / commit), the reading channel lifted
* above the answered card. The recipe is the quiz cards' quote block
* verbatim (rail + uppercase label + prose), carried by a faint shell
* so it still reads user-side at a glance — right-aligned, message
* width, never collapsible (they arrive open; collapsing bought
* nothing and cost the chevron-on-the-right weirdness).
*
* Rails reuse the existing vocabulary: primary for the committed
* register (the quiz answer thinking, practice commits), muted for the
* working register (nudges, reading thoughts). */
export function ThinkingBlock({
label,
text,
rail = "muted",
testId,
dataCommit,
}: {
label: string;
text: string;
rail?: "primary" | "muted";
testId?: string;
/** The practice marker's register flag (commit vs nudge). */
dataCommit?: boolean;
}) {
return (
<div
data-testid={testId}
data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"}
className="lh-card-enter ml-auto max-w-[95%] rounded-2xl border border-border/60 bg-muted/40 px-4 py-3"
>
<div className={cn("border-l-2 pl-3", rail === "primary" ? "border-primary/40" : "border-muted-foreground/30")}>
<span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</span>
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{text}</span>
</div>
</div>
);
}
+12 -3
View File
@@ -572,15 +572,24 @@ describe("predict vs check rendering", () => {
assert.ok(!html.includes("tucked-explanation")); assert.ok(!html.includes("tucked-explanation"));
}); });
it("the reading-thinking channel renders as its own block; legacy `note` still renders", () => { it("the reading-thinking channel is LIFTED above the card as its own user-side block", () => {
const withReading = buildAnswer(quizQD(), { const withReading = buildAnswer(quizQD(), {
values: ["Mercury"], values: ["Mercury"],
thinking: "answering thought", thinking: "answering thought",
readingThinking: "was unsure mid-paragraph", readingThinking: "was unsure mid-paragraph",
}); });
const html = renderToStaticMarkup(<AnsweredCard answer={withReading} />); const html = renderToStaticMarkup(<AnsweredCard answer={withReading} />);
assert.ok(html.includes("Your thinking while reading"), "the reading channel is labeled distinctly"); // The unified ThinkingBlock (owner round, 2026-10-05): the reading
assert.ok(html.includes("answering thought")); // thoughts render BEFORE the card, user-side (right shell), under the
// plain "Your thinking" label — no longer an inset inside the answer.
const blockAt = html.indexOf("reading-thinking-block");
const cardAt = html.indexOf("answered-card");
assert.ok(blockAt !== -1, "the lifted block renders");
assert.ok(blockAt < cardAt, "it sits ABOVE the answered card");
assert.ok(html.includes("Your thinking"), "the plain label");
assert.ok(!html.includes("Your thinking while reading"), "the old verbose label is gone");
assert.ok(!html.includes("your-reading-thinking"), "the in-card inset is gone");
assert.ok(html.includes("answering thought"), "the answer thinking stays inside the card");
assert.ok(html.includes("was unsure mid-paragraph")); assert.ok(html.includes("was unsure mid-paragraph"));
const legacy = { ...buildAnswer(quizQD(), { values: ["Mercury"] }), note: "a 2026-09 answer" }; const legacy = { ...buildAnswer(quizQD(), { values: ["Mercury"] }), note: "a 2026-09 answer" };
const legacyHtml = renderToStaticMarkup(<AnsweredCard answer={legacy} />); const legacyHtml = renderToStaticMarkup(<AnsweredCard answer={legacy} />);