diff --git a/components/practice-cards.tsx b/components/practice-cards.tsx
index 7fd7c3b..7c686af 100644
--- a/components/practice-cards.tsx
+++ b/components/practice-cards.tsx
@@ -21,9 +21,10 @@ import type { ArtifactPartProps } from "@/src/core";
*/
import { ChevronDownIcon } from "lucide-react";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { ThinkingBlock } from "@/components/thinking-block";
+import { usePopoverDismiss } from "@/components/popover-dismiss";
/* TARGET NAMES (owner round 17, 2026-10-06): targets are normalized to
* opaque node ids at the record seam (owner round 9 — the mastery trace
@@ -116,7 +117,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
}
return (
-
+
{/* The heading row (owner round 3, 2026-10-05): plain 'Concluded'
eyebrow + the outcome in the quiz cards' EXACT chip register —
no more two same-weight headings stacked. */}
@@ -148,7 +149,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
>
)}
-
@@ -156,13 +157,19 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
);
}
-/* THE AGENT'S DATA DRAWER (owner round 17, 2026-10-06 — full
- * transparency): a collapsible at the very bottom of the conclude card,
- * arriving collapsed, showing every field the agent filled in both the
- * problem call (mode, targets) and the conclude call (outcome, rung,
- * miss class, retest). Statement and modelSolution stay out — they are
- * the visible card above. Pure rows, the eyebrow register, mono values. */
-function AgentsDataDrawer({
+/* THE METADATA POPOVER (owner round 17 refinement, 2026-10-06 — full
+ * transparency): a quiet METADATA trigger at the very bottom of the
+ * conclude card (no divider — it just sits there); click opens the
+ * info-box register of the graph-view node cards (rounded-xl border
+ * bg-card p-4 shadow-lg, floating ABOVE the trigger); any scroll or
+ * click outside closes it (plus Esc, claimed from the layered chain
+ * per the popover law). Shows every field the agent filled in both the
+ * problem call (mode, targets — names resolved lazily) and the
+ * conclude call (outcome, rung, miss class, retest). Statement and
+ * modelSolution stay out — they are the visible card. The VALUES match
+ * the model solution's font size (text-[15px], owner-verified law);
+ * labels keep the eyebrow register. */
+function MetadataPopover({
concludeInput,
problemInput,
}: {
@@ -170,6 +177,7 @@ function AgentsDataDrawer({
problemInput: { mode?: unknown; target?: unknown };
}) {
const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
const [names, setNames] = useState | null>(null);
const targets = Array.isArray(problemInput.target)
? problemInput.target.filter((t): t is string => typeof t === "string")
@@ -186,6 +194,16 @@ function AgentsDataDrawer({
};
}, [open, targets.length, names]);
+ usePopoverDismiss(rootRef, open, () => setOpen(false));
+ // ANY scroll closes it (owner law): capture-phase, so every scroller
+ // in the tree counts — the transcript's own scroll included.
+ useEffect(() => {
+ if (!open) return;
+ const close = () => setOpen(false);
+ window.addEventListener("scroll", close, { capture: true, passive: true });
+ return () => window.removeEventListener("scroll", close, { capture: true });
+ }, [open]);
+
const mode = typeof problemInput.mode === "string" ? problemInput.mode : "";
const rung = typeof concludeInput.rung === "string" ? concludeInput.rung : "";
const miss = typeof concludeInput.missClass === "string" ? concludeInput.missClass : "";
@@ -194,25 +212,29 @@ function AgentsDataDrawer({
if (!mode && targets.length === 0 && !rung && !miss && retest === undefined) return null;
return (
- <>
-
-
-
);
}
diff --git a/tests/render-practice-surface.test.tsx b/tests/render-practice-surface.test.tsx
index b2546da..1ee4123 100644
--- a/tests/render-practice-surface.test.tsx
+++ b/tests/render-practice-surface.test.tsx
@@ -137,8 +137,8 @@ describe("the practice transcript cards", () => {
assert.match(html, /Pair them; each pair is even\./);
});
- it("ConcludeCard: THE AGENT'S DATA DRAWER (owner round 17) — arrives collapsed, opens to every field both calls filled", () => {
- const collapsed = renderToStaticMarkup(
+ it("ConcludeCard: THE METADATA POPOVER (owner round 17 refinement) — quiet trigger at the bottom, box hidden until clicked", () => {
+ const html = renderToStaticMarkup(
{
})}
/>,
);
- assert.ok(collapsed.includes('data-testid="agents-data-toggle"'), "the toggle row");
- assert.ok(collapsed.includes("Agent"), "the label");
- assert.ok(!collapsed.includes('data-testid="agents-data"'), "arrives COLLAPSED — no rows in the markup");
- // The drawer is a client useState fold; the OPEN face is pinned by the
- // rows' data shape (view join) below — static render cannot toggle.
+ assert.ok(html.includes('data-testid="metadata-toggle"'), "the trigger row");
+ assert.match(html, /Metadata/, "the label (CSS renders it mono-caps)");
+ assert.ok(!html.includes('data-testid="metadata-box"'), "the info box is CLOSED until clicked — no rows in the markup");
+ assert.ok(!html.includes("border-t \"/>"), "no separate divider — the trigger just sits at the bottom");
+ // The open face (info-box rows at the model solution's 15px) is a
+ // client useState fold — static render cannot click; the rows' data
+ // shape is pinned by the view-join test in practice-record.
});
- it("ConcludeCard: no drawer when the calls filled nothing beyond the visible card", () => {
+ it("ConcludeCard: no metadata trigger when the calls filled nothing beyond the visible card", () => {
const html = renderToStaticMarkup(
,
);
- assert.ok(!html.includes("agents-data"), "the dismissal line carries no drawer");
+ assert.ok(!html.includes("metadata-"), "the dismissal line carries no trigger");
const legacy = renderToStaticMarkup(
,
);
- assert.ok(!legacy.includes("agents-data"), "empty problemInput + bare outcome: nothing to reveal");
+ assert.ok(!legacy.includes("metadata-"), "empty problemInput + bare outcome: nothing to reveal");
});
it("ConcludeCard: the dismissal synthesis is one muted line — no reveal card", () => {