ui: the document look — one language for every thinking, no bubbles
Owner round 2 (2026-10-05), Slice F: - USER TEXT: the right-aligned bubble is gone. User messages are full-width document rows — muted 'You' label, muted quote rail, full-opacity text. Learner words are content, not a chat-app tell. - USER THINKING (everywhere): arrives COLLAPSED as a trigger row in the exact register of the agent's reasoning rows (muted label + chevron + hover affordance); expanding opens the quote block — rail, 50% opacity, italics. The inner-voice signature. The ThinkingBlock loses its shell/border: pure document material. - QUIZ CARDS: the answer's thinking is the SAME collapsible row, same placement, just inside the container; the explanation drops to 50% opacity (upright — italics stays the thinking signature). - AGENT'S THINKING: the duration bookkeeping is gone (it was live-turn state that died to 'a few seconds' on remount anyway) — the settled row reads static 'Agent's thinking', pairing with 'Your thinking'. Spinner + shimmer stay the working signal while streaming. - Glance law: user thinking = rail + 50% italic; agent thinking = no rail; user text = upright, full opacity, 'You' label.
This commit is contained in:
+2
-2
@@ -286,7 +286,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dock-variant {
|
.dock-variant {
|
||||||
animation: dock-variant-in 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
animation: dock-variant-in 120ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -548,7 +548,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lh-card-enter {
|
.lh-card-enter {
|
||||||
animation: lh-card-enter 150ms ease-out;
|
animation: lh-card-enter 120ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ interface ReasoningContextValue {
|
|||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (open: boolean) => void;
|
setIsOpen: (open: boolean) => void;
|
||||||
duration: number | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||||
@@ -49,11 +48,9 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
|||||||
open?: boolean;
|
open?: boolean;
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
duration?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUTO_CLOSE_DELAY = 1000;
|
const AUTO_CLOSE_DELAY = 1000;
|
||||||
const MS_IN_S = 1000;
|
|
||||||
|
|
||||||
export const Reasoning = memo(
|
export const Reasoning = memo(
|
||||||
({
|
({
|
||||||
@@ -62,7 +59,6 @@ export const Reasoning = memo(
|
|||||||
open,
|
open,
|
||||||
defaultOpen,
|
defaultOpen,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
duration: durationProp,
|
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: ReasoningProps) => {
|
}: ReasoningProps) => {
|
||||||
@@ -79,27 +75,8 @@ export const Reasoning = memo(
|
|||||||
onChange: onOpenChange,
|
onChange: onOpenChange,
|
||||||
prop: open,
|
prop: open,
|
||||||
});
|
});
|
||||||
const [duration, setDuration] = useControllableState<number | undefined>({
|
|
||||||
defaultProp: undefined,
|
|
||||||
prop: durationProp,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasEverStreamedRef = useRef(isStreaming);
|
const hasEverStreamedRef = useRef(isStreaming);
|
||||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||||
const startTimeRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
// Track when streaming starts and compute duration
|
|
||||||
useEffect(() => {
|
|
||||||
if (isStreaming) {
|
|
||||||
hasEverStreamedRef.current = true;
|
|
||||||
if (startTimeRef.current === null) {
|
|
||||||
startTimeRef.current = Date.now();
|
|
||||||
}
|
|
||||||
} else if (startTimeRef.current !== null) {
|
|
||||||
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
|
|
||||||
startTimeRef.current = null;
|
|
||||||
}
|
|
||||||
}, [isStreaming, setDuration]);
|
|
||||||
|
|
||||||
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -126,8 +103,8 @@ export const Reasoning = memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({ duration, isOpen, isStreaming, setIsOpen }),
|
() => ({ isOpen, isStreaming, setIsOpen }),
|
||||||
[duration, isOpen, isStreaming, setIsOpen]
|
[isOpen, isStreaming, setIsOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -148,17 +125,19 @@ export const Reasoning = memo(
|
|||||||
export type ReasoningTriggerProps = ComponentProps<
|
export type ReasoningTriggerProps = ComponentProps<
|
||||||
typeof CollapsibleTrigger
|
typeof CollapsibleTrigger
|
||||||
> & {
|
> & {
|
||||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
getThinkingMessage?: (isStreaming: boolean) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
/* The settled label (owner round, 2026-10-05): static "Agent's thinking" —
|
||||||
if (isStreaming || duration === 0) {
|
the duration was live-turn state that died on remount anyway ("thought
|
||||||
|
for a few seconds"), bookkeeping for information we knowingly lose. The
|
||||||
|
label pairs with the user's collapsed "Your thinking" rows — one
|
||||||
|
language; motion (spinner + shimmer) stays the working signal. */
|
||||||
|
const defaultGetThinkingMessage = (isStreaming: boolean) => {
|
||||||
|
if (isStreaming) {
|
||||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||||
}
|
}
|
||||||
if (duration === undefined) {
|
return <p>Agent's thinking</p>;
|
||||||
return <p>Thought for a few seconds</p>;
|
|
||||||
}
|
|
||||||
return <p>Thought for {duration} seconds</p>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ReasoningTrigger = memo(
|
export const ReasoningTrigger = memo(
|
||||||
@@ -168,7 +147,7 @@ export const ReasoningTrigger = memo(
|
|||||||
getThinkingMessage = defaultGetThinkingMessage,
|
getThinkingMessage = defaultGetThinkingMessage,
|
||||||
...props
|
...props
|
||||||
}: ReasoningTriggerProps) => {
|
}: ReasoningTriggerProps) => {
|
||||||
const { isStreaming, isOpen, duration } = useReasoning();
|
const { isStreaming, isOpen } = useReasoning();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsibleTrigger
|
<CollapsibleTrigger
|
||||||
@@ -188,7 +167,7 @@ export const ReasoningTrigger = memo(
|
|||||||
spinner (owner, 2026-10-02): the handoff between the two
|
spinner (owner, 2026-10-02): the handoff between the two
|
||||||
states must be visually seamless, one size everywhere. */}
|
states must be visually seamless, one size everywhere. */}
|
||||||
{isStreaming && <BrailleSpinner label="Thinking" />}
|
{isStreaming && <BrailleSpinner label="Thinking" />}
|
||||||
{getThinkingMessage(isStreaming, duration)}
|
{getThinkingMessage(isStreaming)}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4 transition-transform",
|
"size-4 transition-transform",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
|||||||
ThinkingBlock, instead of an inset inside the answer. Same muted
|
ThinkingBlock, instead of an inset inside the answer. Same muted
|
||||||
rail as before; the label is now plain "Your thinking". */}
|
rail as before; the label is now plain "Your thinking". */}
|
||||||
{answer.readingThinking && (
|
{answer.readingThinking && (
|
||||||
<ThinkingBlock testId="reading-thinking-block" label="Your thinking" text={answer.readingThinking} rail="muted" />
|
<ThinkingBlock testId="reading-thinking-block" label="Your thinking" text={answer.readingThinking} />
|
||||||
)}
|
)}
|
||||||
<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">
|
||||||
@@ -135,12 +135,11 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(answer.thinking ?? answer.note) && (
|
{(answer.thinking ?? answer.note) && (
|
||||||
<div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3">
|
/* The SAME unified row (owner round, 2026-10-05): the quiz answer's
|
||||||
<span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
thinking is the identical collapsible ThinkingBlock — it just
|
||||||
Your thinking
|
happens to sit inside the quiz container. Same placement, same
|
||||||
</span>
|
trigger, same 50%-italic quote block on expand. */
|
||||||
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.thinking ?? answer.note}</span>
|
<ThinkingBlock testId="your-thinking" label="Your thinking" text={answer.thinking ?? answer.note ?? ""} />
|
||||||
</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
|
||||||
@@ -160,8 +159,8 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
|||||||
{whyOpen ? "Hide why" : "Why"}
|
{whyOpen ? "Hide why" : "Why"}
|
||||||
</button>
|
</button>
|
||||||
{whyOpen && (
|
{whyOpen && (
|
||||||
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
|
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-foreground/50">
|
||||||
<span className="font-medium text-foreground">Why: </span>
|
<span className="font-medium text-muted-foreground">Why: </span>
|
||||||
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -169,8 +168,8 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
|||||||
) : (
|
) : (
|
||||||
isQuiz &&
|
isQuiz &&
|
||||||
answer.explanation && (
|
answer.explanation && (
|
||||||
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
|
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-foreground/50">
|
||||||
<span className="font-medium text-foreground">Why: </span>
|
<span className="font-medium text-muted-foreground">Why: </span>
|
||||||
{/* The markdown wrapper must go truly inline — a block <p> after the
|
{/* The markdown wrapper must go truly inline — a block <p> after the
|
||||||
"Why:" label created the oversized leading gap (owner's fix list). */}
|
"Why:" label created the oversized leading gap (owner's fix list). */}
|
||||||
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
||||||
|
|||||||
+25
-13
@@ -1249,16 +1249,28 @@ export function Chat({
|
|||||||
{m.role === "assistant" && mi > 0 && phases[mi] === "teach" && phases[mi - 1] !== "teach" ? (
|
{m.role === "assistant" && mi > 0 && phases[mi] === "teach" && phases[mi - 1] !== "teach" ? (
|
||||||
<PhaseDivider key={`teach-${m.id || `m-${mi}`}`} phase="teach" />
|
<PhaseDivider key={`teach-${m.id || `m-${mi}`}`} phase="teach" />
|
||||||
) : null}
|
) : null}
|
||||||
<Message from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
|
{m.role === "user" ? (
|
||||||
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}>
|
/* THE DOCUMENT ROW (owner round, 2026-10-05): no right-aligned
|
||||||
{m.role === "user" ? (
|
bubble — the transcript reads as a document. User text is a
|
||||||
// User text renders plain: the model's LaTeX pipeline is not
|
full-width quote block: muted "You" label, muted rail,
|
||||||
// applied to learner-authored text ($ stays $, no surprises).
|
FULL-opacity text (learner words are content, not whisper).
|
||||||
<div className="lh-prose whitespace-pre-wrap">
|
Distinguished from user thinking at a glance: thinking is
|
||||||
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
collapsed + 50% italic; this is upright and present.
|
||||||
|
User text renders plain — the model's LaTeX pipeline is not
|
||||||
|
applied to learner-authored text ($ stays $, no surprises).
|
||||||
|
Rendered WITHOUT the Message wrapper — that is the bubble. */
|
||||||
|
<div data-testid="user-message" className="my-1 w-full">
|
||||||
|
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">You</div>
|
||||||
|
<div className="border-l-2 border-muted-foreground/30 pl-3 text-foreground">
|
||||||
|
<div className="lh-prose whitespace-pre-wrap">
|
||||||
|
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
m.parts.map((part, pi) => {
|
) : (
|
||||||
|
<Message from="assistant" className="max-w-full">
|
||||||
|
<MessageContent className="w-full">
|
||||||
|
{m.parts.map((part, pi) => {
|
||||||
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
|
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
|
||||||
// A tool call that errored BACK TO THE AGENT (validation
|
// A tool call that errored BACK TO THE AGENT (validation
|
||||||
// failure, malformed args) is invisible work — the agent
|
// failure, malformed args) is invisible work — the agent
|
||||||
@@ -1329,10 +1341,10 @@ export function Chat({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})}
|
||||||
)}
|
</MessageContent>
|
||||||
</MessageContent>
|
|
||||||
</Message>
|
</Message>
|
||||||
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -1983,7 +1995,7 @@ function DockOverlay({
|
|||||||
measureBlock();
|
measureBlock();
|
||||||
if (p && Math.abs(p.height - h) > 1) {
|
if (p && Math.abs(p.height - h) > 1) {
|
||||||
el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], {
|
el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], {
|
||||||
duration: 180,
|
duration: 120,
|
||||||
easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
|
easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,16 +97,16 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ThinkingCard({ data }: { data: PracticeThinkingData }) {
|
export function ThinkingCard({ data }: { data: PracticeThinkingData }) {
|
||||||
// One component, two registers (owner ruling): the committed register
|
// The unified row (owner round, 2026-10-05): one style — collapsed
|
||||||
// carries the primary rail (the quiz answer thinking's paint), the
|
// arrival, 50%-italic quote block on expand. The register survives only
|
||||||
// working register the muted one.
|
// in the label ("Your solution" when committed) and the data-commit
|
||||||
|
// flag for the tests.
|
||||||
return (
|
return (
|
||||||
<ThinkingBlock
|
<ThinkingBlock
|
||||||
testId="thinking-card"
|
testId="thinking-card"
|
||||||
dataCommit={data.commit}
|
dataCommit={data.commit}
|
||||||
label={data.commit ? "Your solution" : "Your thinking"}
|
label={data.commit ? "Your solution" : "Your thinking"}
|
||||||
text={data.text}
|
text={data.text}
|
||||||
rail={data.commit ? "primary" : "muted"}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,52 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** The unified user-thinking block (owner round, 2026-10-05): ONE visual
|
/** The unified user-thinking row (owner round, 2026-10-05): ONE visual
|
||||||
* language for every surface where the learner's own thinking renders —
|
* language for every surface where the learner's own thinking renders —
|
||||||
* practice thinking traces (nudge / commit), the reading channel lifted
|
* practice thinking traces, the reading channel lifted above the answered
|
||||||
* above the answered card. The recipe is the quiz cards' quote block
|
* card, the quiz answer's thinking inside its container. It arrives
|
||||||
* verbatim (rail + uppercase label + prose), carried by a faint shell
|
* COLLAPSED as a trigger row in the exact register of the agent's
|
||||||
* so it still reads user-side at a glance — right-aligned, message
|
* reasoning rows (muted label + chevron + hover affordance); expanding
|
||||||
* width, never collapsible (they arrive open; collapsing bought
|
* opens the quote block — rail, 50% opacity, italics. The italics + the
|
||||||
* nothing and cost the chevron-on-the-right weirdness).
|
* half-opacity text are the inner-voice signature; the rail marks it as
|
||||||
|
* learner material. No shell, no border: document material, full width.
|
||||||
*
|
*
|
||||||
* Rails reuse the existing vocabulary: primary for the committed
|
* The glance-level law (owner): user thinking = rail + 50% italic;
|
||||||
* register (the quiz answer thinking, practice commits), muted for the
|
* agent thinking = no rail. */
|
||||||
* working register (nudges, reading thoughts). */
|
|
||||||
|
|
||||||
export function ThinkingBlock({
|
export function ThinkingBlock({
|
||||||
label,
|
label = "Your thinking",
|
||||||
text,
|
text,
|
||||||
rail = "muted",
|
|
||||||
testId,
|
testId,
|
||||||
dataCommit,
|
dataCommit,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label?: string;
|
||||||
text: string;
|
text: string;
|
||||||
rail?: "primary" | "muted";
|
|
||||||
testId?: string;
|
testId?: string;
|
||||||
/** The practice marker's register flag (commit vs nudge). */
|
/** The practice marker's register flag (commit vs nudge). */
|
||||||
dataCommit?: boolean;
|
dataCommit?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [open, setOpen] = useState(false); // arrives collapsed, always
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-testid={testId} data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"} className="lh-card-enter my-1 w-full">
|
||||||
data-testid={testId}
|
<button
|
||||||
data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"}
|
type="button"
|
||||||
className="lh-card-enter ml-auto max-w-[95%] rounded-2xl border border-border/60 bg-muted/40 px-4 py-3"
|
data-testid={testId ? `${testId}-trigger` : undefined}
|
||||||
>
|
onClick={() => setOpen((v) => !v)}
|
||||||
<div className={cn("border-l-2 pl-3", rail === "primary" ? "border-primary/40" : "border-muted-foreground/30")}>
|
aria-expanded={open}
|
||||||
<span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
|
||||||
{label}
|
>
|
||||||
</span>
|
{label}
|
||||||
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{text}</span>
|
<ChevronDownIcon className={cn("size-4 transition-transform", open ? "rotate-180" : "rotate-0")} />
|
||||||
</div>
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="mt-1.5 border-l-2 border-muted-foreground/30 pl-3">
|
||||||
|
<span className="lh-prose block whitespace-pre-wrap italic text-foreground/50">{text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,9 +286,13 @@ describe("render smoke tests", () => {
|
|||||||
thinking: "I picked Venus because it looks brightest — but now I realize brightness isn't distance.",
|
thinking: "I picked Venus because it looks brightest — but now I realize brightness isn't distance.",
|
||||||
});
|
});
|
||||||
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
|
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
|
||||||
assert.ok(html.includes('data-testid="your-thinking"'), "the thinking block exists");
|
assert.ok(html.includes('data-testid="your-thinking"'), "the thinking row exists");
|
||||||
assert.match(html, /Your thinking/, "labeled as the learner's voice");
|
assert.match(html, /Your thinking/, "labeled as the learner's voice");
|
||||||
assert.match(html, /brightness isn't distance/, "the thinking itself renders");
|
// COLLAPSED ARRIVAL (owner round 2, 2026-10-05): the SSR output is the
|
||||||
|
// trigger row only — the thinking text is one click away, never loud.
|
||||||
|
assert.ok(html.includes('data-testid="your-thinking-trigger"'), "the trigger row");
|
||||||
|
assert.ok(html.includes('aria-expanded="false"'), "arrives collapsed");
|
||||||
|
assert.ok(!html.includes("brightness isn't distance"), "the text is hidden until expanded");
|
||||||
assert.ok(!html.includes("Note:"), "no longer a muted 'Note:' footnote");
|
assert.ok(!html.includes("Note:"), "no longer a muted 'Note:' footnote");
|
||||||
// The thinking sits ABOVE the verdict's explanation — the dialogue of
|
// The thinking sits ABOVE the verdict's explanation — the dialogue of
|
||||||
// thinking, not an afterthought.
|
// thinking, not an afterthought.
|
||||||
@@ -580,8 +584,8 @@ describe("predict vs check rendering", () => {
|
|||||||
});
|
});
|
||||||
const html = renderToStaticMarkup(<AnsweredCard answer={withReading} />);
|
const html = renderToStaticMarkup(<AnsweredCard answer={withReading} />);
|
||||||
// The unified ThinkingBlock (owner round, 2026-10-05): the reading
|
// The unified ThinkingBlock (owner round, 2026-10-05): the reading
|
||||||
// thoughts render BEFORE the card, user-side (right shell), under the
|
// thoughts render BEFORE the card as the collapsed user-thinking row
|
||||||
// plain "Your thinking" label — no longer an inset inside the answer.
|
// — same trigger, same 50%-italic quote block on expand.
|
||||||
const blockAt = html.indexOf("reading-thinking-block");
|
const blockAt = html.indexOf("reading-thinking-block");
|
||||||
const cardAt = html.indexOf("answered-card");
|
const cardAt = html.indexOf("answered-card");
|
||||||
assert.ok(blockAt !== -1, "the lifted block renders");
|
assert.ok(blockAt !== -1, "the lifted block renders");
|
||||||
@@ -589,10 +593,11 @@ describe("predict vs check rendering", () => {
|
|||||||
assert.ok(html.includes("Your thinking"), "the plain label");
|
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 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("your-reading-thinking"), "the in-card inset is gone");
|
||||||
assert.ok(html.includes("answering thought"), "the answer thinking stays inside the card");
|
// COLLAPSED ARRIVAL: SSR shows the triggers, not the texts.
|
||||||
assert.ok(html.includes("was unsure mid-paragraph"));
|
assert.ok(html.includes('data-testid="your-thinking-trigger"'), "the answer thinking stays inside the card");
|
||||||
|
assert.ok(!html.includes("answering thought") && !html.includes("was unsure mid-paragraph"), "texts hidden until expanded");
|
||||||
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} />);
|
||||||
assert.ok(legacyHtml.includes("a 2026-09 answer"), "records from before the rename still render");
|
assert.ok(legacyHtml.includes("a 2026-09 answer") || legacyHtml.includes('data-testid="your-thinking-trigger"'), "records from before the rename still render");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user