diff --git a/app/globals.css b/app/globals.css index ab97756..c213836 100644 --- a/app/globals.css +++ b/app/globals.css @@ -530,3 +530,29 @@ body { @theme { --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; + } +} diff --git a/components/answered-card.tsx b/components/answered-card.tsx index 59c1bcb..f34754a 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -3,6 +3,7 @@ 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"; @@ -33,6 +34,15 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { 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 && ( + + )}
{isQuiz ? (answer.questionType === "predict" ? "Prediction" : "Quiz") : isExplain ? "Explain" : "Question"} @@ -133,18 +143,6 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
)} - {/* 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 && ( -
- - Your thinking while reading - - {answer.readingThinking} -
- )} - {/* 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. */} @@ -190,5 +188,6 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
)} + ); } diff --git a/components/practice-cards.tsx b/components/practice-cards.tsx index fce3a3e..c895100 100644 --- a/components/practice-cards.tsx +++ b/components/practice-cards.tsx @@ -17,9 +17,13 @@ import { cn } from "@/lib/utils"; * (the committed solution, shown when the problem ended). A dismissal * synthesis (outcome:"dismissed") renders as one muted line. * - 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). */ export interface PracticeThinkingData { problemCallId: string; @@ -115,24 +119,16 @@ export function ConcludeCard({ part }: ArtifactPartProps) { } 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 ( -
- - {open && ( -
{data.text}
- )} -
+ ); } diff --git a/components/thinking-block.tsx b/components/thinking-block.tsx new file mode 100644 index 0000000..0299017 --- /dev/null +++ b/components/thinking-block.tsx @@ -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 ( +
+
+ + {label} + + {text} +
+
+ ); +} diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index 58d59f1..acd7661 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -572,15 +572,24 @@ describe("predict vs check rendering", () => { 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(), { values: ["Mercury"], thinking: "answering thought", readingThinking: "was unsure mid-paragraph", }); const html = renderToStaticMarkup(); - assert.ok(html.includes("Your thinking while reading"), "the reading channel is labeled distinctly"); - assert.ok(html.includes("answering thought")); + // The unified ThinkingBlock (owner round, 2026-10-05): the reading + // 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")); const legacy = { ...buildAnswer(quizQD(), { values: ["Mercury"] }), note: "a 2026-09 answer" }; const legacyHtml = renderToStaticMarkup();