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.
129 lines
5.6 KiB
TypeScript
129 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { ChevronDownIcon } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/** The unified user-thinking row (owner round, 2026-10-05): ONE visual
|
|
* language for every surface where the learner's own thinking renders —
|
|
* practice thinking traces, the reading channel lifted above the answered
|
|
* card, the quiz answer's thinking inside its container. It arrives 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 italics + the half-opacity
|
|
* text are the inner-voice signature; the rail marks it as learner
|
|
* material. No shell, no border: document material, full width.
|
|
*
|
|
* The glance-level law (owner): user thinking = rail + 50% italic;
|
|
* agent thinking = no rail.
|
|
*
|
|
* THE DOCUMENT, ALL-IN (owner round two, 2026-10-07): no chat sides —
|
|
* transcript-level user rows (this block, the "You" message) are
|
|
* full-width CARD-COLOR BANDS: the band is the authorship mark against
|
|
* the agent's transparent prose, and the rail runs along the band's left
|
|
* edge. The rail keeps its muted color but is now 6px wide, eating the
|
|
* artificial air of the old 3ch inset — the total inset STAYS 3ch so the
|
|
* chevron-alignment law with the agent's flush rows survives; the gap
|
|
* between rail and text drops to natural (3ch - 6px). In-card rows
|
|
* (inside the answered card, on the card's own background) take the rail
|
|
* but NO band (`banded: false`). `defaultOpen` marks transcript rows,
|
|
* which arrive EXPANDED (clearly conveyed that the thinking was sent).
|
|
*
|
|
* ⌘T (toggle-user-thinking) sets EVERY row to one state via the module
|
|
* store below — one source of truth, no provider tree: rows subscribe on
|
|
* mount; explicit sets (and only those) stomp individual toggles. */
|
|
|
|
// ── The ⌘T store (module-scope, one per page load) ─────────────────────────
|
|
// null = "rows run their own defaults" (the pre-toggle state); the first ⌘T
|
|
// resolves from `true` (transcript rows default open) and then alternates.
|
|
let allUserThinkingOpen: boolean | null = null;
|
|
const userThinkingListeners = new Set<(open: boolean) => void>();
|
|
|
|
export function currentUserThinkingOpen(): boolean {
|
|
return allUserThinkingOpen ?? true;
|
|
}
|
|
|
|
/** The row's starting state: an explicit ⌘T set wins over the row's own
|
|
* default (late mounts honor the last set — same path as the live sync,
|
|
* readable at render time so static SSR reflects the store too). */
|
|
function resolveUserThinkingOpen(defaultOpen: boolean): boolean {
|
|
return allUserThinkingOpen ?? defaultOpen;
|
|
}
|
|
|
|
export function setAllUserThinkingOpen(open: boolean) {
|
|
allUserThinkingOpen = open;
|
|
for (const l of userThinkingListeners) l(open);
|
|
}
|
|
|
|
function subscribeUserThinking(fn: (open: boolean) => void): () => void {
|
|
userThinkingListeners.add(fn);
|
|
if (allUserThinkingOpen !== null) fn(allUserThinkingOpen); // late mounts honor the last set
|
|
return () => userThinkingListeners.delete(fn);
|
|
}
|
|
|
|
export function ThinkingBlock({
|
|
label = "YOUR THINKING",
|
|
text,
|
|
testId,
|
|
dataCommit,
|
|
banded = true,
|
|
defaultOpen = false,
|
|
}: {
|
|
label?: string;
|
|
text: string;
|
|
testId?: string;
|
|
/** The practice marker's register flag (commit vs nudge). */
|
|
dataCommit?: boolean;
|
|
/** Transcript rows carry the card-color band; in-card rows rail only. */
|
|
banded?: boolean;
|
|
/** Transcript rows arrive expanded (owner, 2026-10-07). */
|
|
defaultOpen?: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(() => resolveUserThinkingOpen(defaultOpen));
|
|
// ⌘T: an explicit global set overrides every row (once set, rows re-sync).
|
|
const syncedRef = useRef(false);
|
|
useEffect(
|
|
() => subscribeUserThinking((o) => { syncedRef.current = true; setOpen(o); }),
|
|
[],
|
|
);
|
|
return (
|
|
/* The rail GROUPS and the rail IS THE MISSING CHARACTER (owner
|
|
rounds, 2026-10-05): the heading sits INSIDE the rail, and the
|
|
rail's full width (border + padding) is exactly THREE CHARACTERS of
|
|
the mono heading font — 'YOUR THINKING' is three chars shorter
|
|
than 'AGENT'S THINKING', so with the user row offset by the rail
|
|
and the agent row flush, the chevrons line up vertically. Three
|
|
(not one, owner round 5): the wider gap reads better than the
|
|
cramped single-char rail. The heading
|
|
register is the card eyebrows' small mono caps (text-xs = the
|
|
11px rung). */
|
|
<div
|
|
data-testid={testId}
|
|
data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"}
|
|
className={cn(
|
|
// The rail geometry (owner round two, 2026-10-07): total inset
|
|
// STAYS 3ch (the chevron-alignment law with the agent's flush
|
|
// rows), re-split — 6px rail (was 2px) + a natural 3ch-6px gap
|
|
// (was the too-wide 3ch-2px). In-card rows keep the 12px my-3
|
|
// rhythm; banded rows sit my-2 apart (the band's own air).
|
|
"lh-card-enter w-full border-l-[6px] border-muted-foreground/30 pl-[calc(3ch-6px)] font-ui text-xs",
|
|
banded ? "my-2 bg-card py-2.5" : "my-3",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
data-testid={testId ? `${testId}-trigger` : undefined}
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-expanded={open}
|
|
className="flex w-full items-center gap-2 pr-2 font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
{label}
|
|
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} />
|
|
</button>
|
|
{open && (
|
|
<span className="lh-prose mt-1.5 block whitespace-pre-wrap text-[15px] italic text-foreground/50">{text}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|