import React, { useState, useEffect, useRef, useMemo } from "react";
import {
  ClipboardList, ShieldCheck, ArrowRight, RotateCcw, Check,
  AlertTriangle, Loader2, Printer, Copy, ChevronDown, ChevronRight,
  Layers, Target, Gauge, BookOpen, User, Lock, Wand2, X
} from "lucide-react";

/* ============================================================
   MEASUREMENT STUDIO — Adaptive Standards Assessment
   Michigan Virtual brand system (Figtree / teal palette)

   Architecture note (the core idea):
   ┌─ SPEC LAYER (invariant) ──────────────────────────────┐
   │ component · DOK · difficulty b · format · scoring rule │  <- measurement
   └────────────────────────────────────────────────────────┘
   ┌─ SKIN LAYER (personalized) ───────────────────────────┐
   │ names · setting · objects · narrative framing          │  <- engagement
   └────────────────────────────────────────────────────────┘
   Theta is estimated from the SPEC layer only. Two students on
   different skins sit psychometrically parallel forms.
   ============================================================ */

const MV = {
  blackTeal: "#091d20",
  darkTeal: "#093c44",
  teal: "#115e6e",
  midTeal: "#2f8d98",
  lightTeal: "#c7dee0",
  paleBlue: "#f3fafc",
  green: "#b5cd34",
  orange: "#f7941e",
  sand: "#e4b782",
  cream: "#fff4e7",
  darkGray: "#414042",
  lightGray: "#6d6e71",
  white: "#ffffff",
};

const SANS = "'Figtree', 'Poppins', system-ui, sans-serif";
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";

/* ============================================================
   1. PSYCHOMETRIC CORE  (pure functions — no AI involved)
   ============================================================ */

const GRID = Array.from({ length: 161 }, (_, i) => -4 + i * 0.05);

const logistic = (x) => 1 / (1 + Math.exp(-x));

// Rasch dichotomous
const pDich = (theta, b) => logistic(theta - b);

// Rasch Partial Credit Model, categories 0..K
function pPCM(theta, thresholds) {
  const nums = [0];
  let cum = 0;
  for (const t of thresholds) {
    cum += theta - t;
    nums.push(cum);
  }
  const mx = Math.max(...nums);
  const ex = nums.map((n) => Math.exp(n - mx));
  const denom = ex.reduce((a, c) => a + c, 0);
  return ex.map((e) => e / denom);
}

function likelihoodAt(theta, resp) {
  if (resp.maxScore === 1) {
    const p = pDich(theta, resp.b);
    return resp.score === 1 ? p : 1 - p;
  }
  const ps = pPCM(theta, resp.thresholds);
  return Math.max(ps[resp.score] ?? 1e-12, 1e-12);
}

// EAP estimation over N(0,1) prior
function estimate(responses) {
  let post = GRID.map((t) => Math.exp(-0.5 * t * t));
  for (const r of responses) post = post.map((v, i) => v * likelihoodAt(GRID[i], r));
  const sum = post.reduce((a, c) => a + c, 0) || 1;
  post = post.map((p) => p / sum);
  const theta = GRID.reduce((a, t, i) => a + t * post[i], 0);
  const varr = GRID.reduce((a, t, i) => a + post[i] * (t - theta) ** 2, 0);
  return { theta, se: Math.sqrt(Math.max(varr, 1e-6)), post };
}

// Fisher information at theta
function information(theta, slot) {
  if (slot.maxScore === 1) {
    const p = pDich(theta, slot.b);
    return p * (1 - p);
  }
  const ps = pPCM(theta, slot.thresholds);
  const ex = ps.reduce((a, p, k) => a + k * p, 0);
  const ex2 = ps.reduce((a, p, k) => a + k * k * p, 0);
  return Math.max(ex2 - ex * ex, 1e-4);
}

function levelsFromCuts(cuts) {
  return [
    { name: "Beginning", lo: -Infinity, hi: cuts[0], color: "#b06a3b" },
    { name: "Developing", lo: cuts[0], hi: cuts[1], color: MV.sand },
    { name: "Proficient", lo: cuts[1], hi: cuts[2], color: MV.midTeal },
    { name: "Advanced", lo: cuts[2], hi: Infinity, color: MV.darkTeal },
  ];
}

// Posterior probability of each classification — reports decision confidence,
// not just a point estimate.
function classificationProbs(post, cuts) {
  const lv = levelsFromCuts(cuts);
  return lv.map((l) => ({
    ...l,
    p: GRID.reduce((a, t, i) => (t > l.lo && t <= l.hi ? a + post[i] : a), 0),
  }));
}

const scaleScore = (theta) => Math.round(Math.min(800, Math.max(200, 500 + 40 * theta)));

/* ============================================================
   2. BLUEPRINT — slot pool built in CODE, not by the model.
   This is what keeps the scale fixed across students.
   ============================================================ */

const DOK_B = {
  1: [-1.9, -1.3, -0.8, -0.4],
  2: [-0.7, -0.2, 0.3, 0.8],
  3: [0.4, 0.9, 1.4, 1.9],
  4: [1.3, 1.8, 2.3],
};

const DOK_LABEL = {
  1: "Recall & Reproduction",
  2: "Skill & Concept",
  3: "Strategic Thinking",
  4: "Extended Thinking",
};

function buildSlotPool(components, dokCeiling, allowCR) {
  const slots = [];
  components.forEach((c) => {
    const ceiling = Math.min(c.dokCeiling || dokCeiling, dokCeiling);
    for (let d = 1; d <= ceiling; d++) {
      DOK_B[d].forEach((b, k) => {
        let format = "multiple_choice";
        if (d === 2 && k % 2 === 1) format = "multi_select";
        if (d >= 3) format = allowCR ? "short_constructed" : "multi_select";
        const poly = format !== "multiple_choice";
        slots.push({
          id: `${c.id}·D${d}·${k + 1}`,
          componentId: c.id,
          componentLabel: c.label,
          dok: d,
          b: +b.toFixed(2),
          format,
          maxScore: poly ? 2 : 1,
          thresholds: poly ? [+(b - 0.55).toFixed(2), +(b + 0.55).toFixed(2)] : null,
        });
      });
    }
  });
  return slots;
}

const bandFor = (b) => {
  if (b <= -1.2) return "very accessible — single step, familiar surface, no embedded distractor logic";
  if (b <= -0.4) return "accessible — one clear step, one plausible near-miss distractor";
  if (b <= 0.4) return "moderate — two connected steps, distractors reflect real misconceptions";
  if (b <= 1.2) return "demanding — multi-step, requires selecting the right approach, no cued procedure";
  return "highly demanding — non-routine, requires justification or transfer to an unfamiliar case";
};

/* Content-balanced maximum-information selection.
   Constraint order: (1) coverage round-robin, (2) DOK ramp,
   (3) CR cap, (4) max Fisher information. */
function selectSlot(pool, used, theta, nDone, components, dokCeiling) {
  let avail = pool.filter((s) => !used.has(s.id));
  if (!avail.length) return null;

  const counts = {};
  components.forEach((c) => (counts[c.id] = 0));
  used.forEach((id) => {
    const c = id.split("·")[0];
    if (counts[c] !== undefined) counts[c]++;
  });
  const minCount = Math.min(...Object.values(counts));
  const behind = avail.filter((s) => counts[s.componentId] === minCount);
  if (behind.length) avail = behind;

  // Ramp: never open at the ceiling; ease students in.
  if (nDone === 0) {
    const open = avail.filter((s) => s.dok <= 2 && s.b <= 0);
    if (open.length) avail = open;
  }
  if (nDone === 1) {
    const lim = avail.filter((s) => s.dok <= 2);
    if (lim.length) avail = lim;
  }

  // Cap constructed response — latency and student fatigue.
  const crUsed = [...used].filter((id) => pool.find((s) => s.id === id)?.format === "short_constructed").length;
  if (crUsed >= 3) {
    const noCR = avail.filter((s) => s.format !== "short_constructed");
    if (noCR.length) avail = noCR;
  }

  // Once the student is clearly above the mid cut, guarantee ceiling exposure.
  if (nDone >= 6 && theta > 0.35) {
    const hasCeiling = [...used].some((id) => pool.find((s) => s.id === id)?.dok === dokCeiling);
    if (!hasCeiling) {
      const top = avail.filter((s) => s.dok === dokCeiling);
      if (top.length) avail = top;
    }
  }

  return avail.reduce((best, s) =>
    information(theta, s) > information(theta, best) ? s : best
  );
}

function shouldStop(nDone, se, cfg, counts) {
  if (nDone >= cfg.maxItems) return true;
  if (nDone < cfg.minItems) return false;
  const covered = Object.values(counts).every((c) => c >= 2);
  return covered && se <= cfg.targetSE;
}

/* ============================================================
   3. MODEL CALLS
   ============================================================ */

const SAFETY = `SAFETY (non-negotiable): content must be appropriate for a school setting and for the stated grade band. No violence, weapons, injury, death, crime, or frightening scenarios. No romance. No alcohol, tobacco, drugs, or gambling. No body weight, dieting, calories, or appearance commentary. No real brand names, real minors, or requests for personal information. Use varied, inclusive names. No stereotypes about any group. Keep it warm and ordinary.`;

async function callModel(messages, system, retries = 2) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch("https://api.anthropic.com/v1/messages", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          model: "claude-sonnet-4-6",
          max_tokens: 1000,
          system,
          messages,
        }),
      });
      const data = await res.json();
      const text = (data.content || [])
        .map((c) => (c.type === "text" ? c.text : ""))
        .join("")
        .trim();
      const cleaned = text.replace(/```json/g, "").replace(/```/g, "").trim();
      const start = cleaned.indexOf("{");
      const end = cleaned.lastIndexOf("}");
      if (start === -1 || end === -1) throw new Error("no json");
      return JSON.parse(cleaned.slice(start, end + 1));
    } catch (e) {
      if (attempt === retries) throw e;
      await new Promise((r) => setTimeout(r, 600 * (attempt + 1)));
    }
  }
}

const unpackStandard = (grade, subject, standards) =>
  callModel(
    [{
      role: "user",
      content: `Grade band: ${grade}
Subject: ${subject}
Standard(s):
${standards.map((s, i) => `${i + 1}. ${s.code}${s.text ? " — " + s.text : ""}`).join("\n")}

Unpack this for assessment design. Be terse. Return ONLY JSON:
{"restated":"one plain-language sentence naming what the student must be able to DO",
"components":[{"id":"C1","label":"3-6 words","description":"one sentence","dokCeiling":2}],
"dokCeiling":3,
"methodology":"2 sentences: which item formats yield valid evidence for this standard and why",
"itemRange":[8,14],
"misconceptions":["short misconception phrase"],
"readability":"grade X-Y",
"protectedVocabulary":["terms that carry the construct and must never be swapped out for theme"],
"contextGuidance":"one sentence on what kinds of scenarios fit this standard naturally"}

Rules: 2-4 components, each a distinct assessable sub-skill. dokCeiling reflects the standard's actual cognitive demand — do not inflate it. 3-5 misconceptions. protectedVocabulary max 6 terms.`,
    }],
    `You are a psychometrician and curriculum specialist who designs standards-aligned item banks. You are precise about Depth of Knowledge and never inflate cognitive demand. Output raw JSON only — no prose, no markdown fences.`
  );

const genContexts = (grade, subject, guidance) =>
  callModel(
    [{
      role: "user",
      content: `Grade band: ${grade}. Subject: ${subject}. ${guidance || ""}
Generate 3 assessment themes a student would pick between. Return ONLY JSON:
{"options":[{"label":"2-4 words","blurb":"one short second-person sentence, e.g. 'Questions set in an animal shelter.'","emoji":"one emoji"}]}
Make the three genuinely different from each other. ${SAFETY}`,
    }],
    `You write friendly, age-appropriate assessment framing for K-12 students. Output raw JSON only.`
  );

const checkContext = (text, grade) =>
  callModel(
    [{
      role: "user",
      content: `A ${grade} student typed this as a theme for their test questions: "${text}"
Return ONLY JSON: {"approved":true/false,"label":"2-4 word cleaned-up theme name","reason":"if not approved, one friendly sentence telling the student what to try instead"}
Approve ordinary student interests (sports, animals, games, music, food, hobbies, space, cars, art). Reject anything violent, sexual, hateful, drug-related, self-harm adjacent, targeting a real person, containing personal information, or nonsense that cannot frame a question. ${SAFETY}`,
    }],
    `You are a school content-safety reviewer. Be reasonable, not restrictive — but firm on genuine risk. Output raw JSON only.`
  );

function itemPrompt(slot, bp, ctx, grade, usedStems) {
  const poly = slot.maxScore === 2;
  return `STANDARD: ${bp.restated}
COMPONENT UNDER TEST: ${slot.componentLabel} — ${bp.components.find((c) => c.id === slot.componentId)?.description || ""}
DOK LEVEL: ${slot.dok} (${DOK_LABEL[slot.dok]})
DIFFICULTY TARGET: b = ${slot.b} logits → ${bandFor(slot.b)}
FORMAT: ${slot.format}
GRADE BAND: ${grade} · READABILITY: ${bp.readability}
STUDENT'S CHOSEN THEME: ${ctx}
PROTECTED VOCABULARY (must appear unchanged if relevant): ${(bp.protectedVocabulary || []).join(", ") || "none"}
KNOWN MISCONCEPTIONS to build distractors from: ${(bp.misconceptions || []).join(" | ")}
ALREADY ASKED (do not repeat the underlying task): ${usedStems.slice(-6).join(" | ") || "none"}

CRITICAL — THEME IS SURFACE ONLY. The theme may change names, objects, and setting. It must NOT change: the number of reasoning steps, the numeric or linguistic complexity, the DOK level, or the distractor logic. An item on any other theme at this same b must be equally hard.

Return ONLY JSON:
${
  slot.format === "short_constructed"
    ? `{"stem":"the prompt","rubric":[{"score":2,"criteria":"what a full response contains"},{"score":1,"criteria":"partial"},{"score":0,"criteria":"insufficient"}],"exemplar":"a 2-score response"}`
    : `{"stem":"the question","options":[{"id":"A","text":"...","correct":true,"tag":"key"},{"id":"B","text":"...","correct":false,"tag":"which misconception this catches"}]}`
}
${
  slot.format === "multiple_choice"
    ? "Exactly 4 options, exactly 1 correct. Every distractor maps to a named misconception — no throwaways. Options similar in length."
    : slot.format === "multi_select"
    ? "Exactly 5 options, exactly 2 or 3 correct. Tell the student in the stem how many to choose."
    : "One focused prompt answerable in 2-4 sentences."
}
${SAFETY}`;
}

const genItem = (slot, bp, ctx, grade, usedStems) =>
  callModel(
    [{ role: "user", content: itemPrompt(slot, bp, ctx, grade, usedStems) }],
    `You are an item writer for a calibrated adaptive assessment. You hit the specified difficulty precisely and treat theme as cosmetic. Output raw JSON only.`
  );

const scoreCR = (stem, rubric, response) =>
  callModel(
    [{
      role: "user",
      content: `PROMPT: ${stem}
RUBRIC: ${JSON.stringify(rubric)}
STUDENT RESPONSE: "${response}"
Score it. Ignore spelling, grammar, and handwriting-style errors — score the reasoning only. Return ONLY JSON: {"score":0|1|2,"evidence":"the specific words in the response that earned or cost the point","misconception":"named misconception if visible, else null"}`,
    }],
    `You are a trained scorer applying a rubric consistently. You do not reward length or vocabulary — only the target construct. Output raw JSON only.`
  );

const interpret = (payload) =>
  callModel(
    [{
      role: "user",
      content: `${JSON.stringify(payload)}

Write the interpretation for the teacher. Return ONLY JSON:
{"headline":"one sentence naming what this student can and cannot yet do",
"meaning":"3-4 sentences interpreting the scale score, the confidence interval, and what the DOK pattern shows. Reference the actual numbers.",
"strengths":["specific, tied to a component"],
"gaps":["specific, tied to a component or misconception"],
"nextSteps":[{"move":"a concrete instructional move a teacher can run this week","why":"the evidence that justifies it"}],
"limits":"2 sentences on what this result does NOT license you to conclude",
"studentMessage":"2 warm sentences addressed directly to the student, second person, naming one real strength and one next thing to work on. No score, no jargon."}
3 strengths max, 3 gaps max, 3 next steps.`,
    }],
    `You are an assessment specialist advising a classroom teacher. You are concrete and honest about measurement uncertainty. You never over-claim from a short test. Output raw JSON only.`
  );

/* ============================================================
   4. UI PRIMITIVES
   ============================================================ */

function Btn({ children, onClick, disabled, variant = "primary", style, ...rest }) {
  const base = {
    fontFamily: SANS, fontWeight: 700, fontSize: 15, borderRadius: 10,
    padding: "13px 22px", border: "2px solid transparent", cursor: disabled ? "not-allowed" : "pointer",
    display: "inline-flex", alignItems: "center", gap: 9, transition: "all .15s ease",
    opacity: disabled ? 0.45 : 1, letterSpacing: "-0.01em",
  };
  const skins = {
    primary: { background: MV.orange, color: MV.blackTeal },
    dark: { background: MV.darkTeal, color: MV.white },
    ghost: { background: "transparent", color: MV.teal, border: `2px solid ${MV.lightTeal}` },
    quiet: { background: MV.paleBlue, color: MV.teal, border: `2px solid ${MV.paleBlue}` },
  };
  return (
    <button onClick={onClick} disabled={disabled} style={{ ...base, ...skins[variant], ...style }} {...rest}>
      {children}
    </button>
  );
}

function Field({ label, hint, children }) {
  return (
    <label style={{ display: "block", marginBottom: 18 }}>
      <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.teal, marginBottom: 6 }}>
        {label}
      </div>
      {children}
      {hint && <div style={{ fontSize: 12.5, color: MV.lightGray, marginTop: 5 }}>{hint}</div>}
    </label>
  );
}

const inputStyle = {
  width: "100%", fontFamily: SANS, fontSize: 15, color: MV.blackTeal,
  padding: "11px 13px", borderRadius: 9, border: `2px solid ${MV.lightTeal}`,
  background: MV.white, outline: "none", boxSizing: "border-box",
};

function Chip({ children, tone = "teal" }) {
  const tones = {
    teal: { bg: MV.paleBlue, fg: MV.teal },
    dark: { bg: MV.darkTeal, fg: MV.white },
    sand: { bg: MV.cream, fg: "#8a5a20" },
    gray: { bg: "#f2f2f3", fg: MV.darkGray },
  };
  const t = tones[tone];
  return (
    <span style={{
      fontFamily: MONO, fontSize: 11, fontWeight: 600, background: t.bg, color: t.fg,
      padding: "3px 8px", borderRadius: 5, whiteSpace: "nowrap", letterSpacing: "0.02em",
    }}>{children}</span>
  );
}

function Rule() {
  return <div style={{ height: 3, background: MV.green, borderRadius: 2, width: 56, margin: "0 0 20px" }} />;
}

function Spinner({ label }) {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 11, color: MV.teal, fontFamily: SANS, fontSize: 15, fontWeight: 600 }}>
      <Loader2 size={19} className="mv-spin" />
      {label}
    </div>
  );
}

/* THE SIGNATURE ELEMENT — uncertainty made visible.
   The band narrows as evidence accumulates. */
function ThetaRibbon({ theta, se, cuts, compact }) {
  const W = 100;
  const pos = (t) => ((Math.max(-3, Math.min(3, t)) + 3) / 6) * W;
  const lo = pos(theta - 1.96 * se);
  const hi = pos(theta + 1.96 * se);
  const lv = levelsFromCuts(cuts);
  const h = compact ? 44 : 66;
  return (
    <div style={{ width: "100%" }}>
      <svg viewBox={`0 0 100 ${compact ? 18 : 26}`} style={{ width: "100%", height: h, display: "block", overflow: "visible" }} preserveAspectRatio="none">
        {lv.map((l, i) => {
          const a = pos(l.lo === -Infinity ? -3 : l.lo);
          const b = pos(l.hi === Infinity ? 3 : l.hi);
          return <rect key={i} x={a} y={compact ? 5 : 8} width={Math.max(b - a, 0)} height={compact ? 8 : 10} fill={l.color} opacity={0.18} />;
        })}
        {cuts.map((c, i) => (
          <line key={i} x1={pos(c)} x2={pos(c)} y1={compact ? 3 : 5} y2={compact ? 15 : 21} stroke={MV.darkTeal} strokeWidth="0.35" strokeDasharray="1 1" />
        ))}
        <rect x={lo} y={compact ? 5 : 8} width={Math.max(hi - lo, 0.6)} height={compact ? 8 : 10}
          fill={MV.midTeal} opacity={0.5} rx="1"
          style={{ transition: "x .5s cubic-bezier(.2,.8,.2,1), width .5s cubic-bezier(.2,.8,.2,1)" }} />
        <line x1={pos(theta)} x2={pos(theta)} y1={compact ? 3 : 5} y2={compact ? 15 : 21}
          stroke={MV.darkTeal} strokeWidth="0.8"
          style={{ transition: "x1 .5s cubic-bezier(.2,.8,.2,1), x2 .5s cubic-bezier(.2,.8,.2,1)" }} />
      </svg>
      {!compact && (
        <div style={{ display: "flex", justifyContent: "space-between", fontFamily: MONO, fontSize: 10.5, color: MV.lightGray, marginTop: 2 }}>
          {lv.map((l) => <span key={l.name}>{l.name}</span>)}
        </div>
      )}
    </div>
  );
}

/* ============================================================
   5. MAIN
   ============================================================ */

export default function AdaptiveAssessmentStudio() {
  const [phase, setPhase] = useState("setup");
  const [err, setErr] = useState(null);
  const [busy, setBusy] = useState(null);

  // Setup
  const [grade, setGrade] = useState("Grade 7");
  const [subject, setSubject] = useState("Mathematics");
  const [stdA, setStdA] = useState({ code: "", text: "" });
  const [stdB, setStdB] = useState({ code: "", text: "" });
  const [useSecond, setUseSecond] = useState(false);

  // Blueprint
  const [bp, setBp] = useState(null);
  // targetSE 0.45 ≈ reliability .80. Simulation shows SE below ~0.38 is not
  // reachable inside 20 Rasch items, so a tighter target just pins every test
  // to the item cap and the stopping rule stops doing any work.
  const [cfg, setCfg] = useState({ minItems: 8, maxItems: 14, targetSE: 0.45, allowCR: true, cuts: [-0.85, 0.0, 1.1] });

  // Student
  const [studentName, setStudentName] = useState("");
  const [ctxOptions, setCtxOptions] = useState([]);
  const [ctxChoice, setCtxChoice] = useState(null);
  const [customCtx, setCustomCtx] = useState("");
  const [ctxError, setCtxError] = useState(null);

  // Session
  const [pool, setPool] = useState([]);
  const [used, setUsed] = useState(new Set());
  const [responses, setResponses] = useState([]);
  const [record, setRecord] = useState([]);
  const [current, setCurrent] = useState(null);
  const [answer, setAnswer] = useState(null);
  const [multi, setMulti] = useState([]);
  const [crText, setCrText] = useState("");
  const [report, setReport] = useState(null);
  const [openItem, setOpenItem] = useState(null);
  const [copied, setCopied] = useState(false);

  const cache = useRef({});
  const usedStems = useRef([]);

  const est = useMemo(() => estimate(responses), [responses]);

  // Pool size the current blueprint would produce — the item cap can't exceed it.
  const projectedPool = useMemo(
    () => (bp?.components?.length ? buildSlotPool(bp.components, bp.dokCeiling, cfg.allowCR) : []),
    [bp, cfg.allowCR]
  );
  const poolCeiling = Math.max(6, Math.min(20, projectedPool.length));

  // Posterior precision = 1 (prior) + Σ information. Average item information in
  // this pool runs ≈0.40, which matched simulated stopping points within half an item.
  const expectedLength = Math.max(
    cfg.minItems,
    Math.min(cfg.maxItems, Math.ceil((1 / cfg.targetSE ** 2 - 1) / 0.4))
  );

  useEffect(() => {
    if (cfg.maxItems > poolCeiling) {
      setCfg((c) => ({ ...c, maxItems: poolCeiling, minItems: Math.min(c.minItems, poolCeiling - 1) }));
    }
  }, [poolCeiling]);

  useEffect(() => {
    const l = document.createElement("link");
    l.rel = "stylesheet";
    l.href = "https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;600&display=swap";
    document.head.appendChild(l);
    return () => { try { document.head.removeChild(l); } catch (e) {} };
  }, []);

  /* ---- Step 1: unpack ---- */
  async function doUnpack() {
    setErr(null); setBusy("Unpacking the standard…");
    try {
      const list = [stdA, ...(useSecond ? [stdB] : [])].filter((s) => s.code.trim() || s.text.trim());
      const r = await unpackStandard(grade, subject, list);
      // Normalize: the slot-ID scheme and the DOK tables both assume clean values.
      const ceiling = Math.max(1, Math.min(4, Math.round(r.dokCeiling || 2)));
      r.dokCeiling = ceiling;
      r.components = (r.components || [])
        .slice(0, 4)
        .map((c, i) => ({
          ...c,
          id: `C${i + 1}`,
          label: c.label || `Component ${i + 1}`,
          dokCeiling: Math.max(1, Math.min(ceiling, Math.round(c.dokCeiling || ceiling))),
        }));
      if (!r.components.length) {
        r.components = [{ id: "C1", label: "Core skill", description: r.restated || "", dokCeiling: ceiling }];
      }
      setBp(r);
      setCfg((c) => ({
        ...c,
        minItems: Math.max(6, r.itemRange?.[0] ?? 8),
        maxItems: Math.min(20, r.itemRange?.[1] ?? 14),
      }));
      setPhase("blueprint");
    } catch (e) { setErr("Could not unpack that standard. Check the code and try again."); }
    setBusy(null);
  }

  /* ---- Step 2: lock blueprint ---- */
  async function lockBlueprint() {
    setErr(null); setBusy("Building the item pool…");
    try {
      const p = buildSlotPool(bp.components, bp.dokCeiling, cfg.allowCR);
      setPool(p);
      const r = await genContexts(grade, subject, bp.contextGuidance);
      setCtxOptions(r.options || []);
      setPhase("handoff");
    } catch (e) { setErr("Could not build the pool. Try again."); }
    setBusy(null);
  }

  /* ---- Step 3: student context ---- */
  async function confirmContext() {
    setCtxError(null);
    let label = ctxChoice;
    if (ctxChoice === "__other__") {
      if (customCtx.trim().length < 3) { setCtxError("Type a few words about what you'd like the questions to be about."); return; }
      setBusy("Checking that this works…");
      try {
        const r = await checkContext(customCtx.trim(), grade);
        if (!r.approved) { setCtxError(r.reason || "Let's pick something else. Try a hobby, a sport, or an animal."); setBusy(null); return; }
        label = r.label;
      } catch (e) { setCtxError("Try that again, or pick one of the options above."); setBusy(null); return; }
    }
    setCtxChoice(label);
    setBusy("Writing your first question…");
    try {
      const slot = selectSlot(pool, new Set(), 0, 0, bp.components, bp.dokCeiling);
      const item = await genItem(slot, bp, label, grade, []);
      setCurrent({ slot, item });
      usedStems.current = [item.stem];
      setPhase("testing");
      prefetch({
        baseResponses: [], pendingSlot: slot, usedSet: new Set([slot.id]),
        nDone: 1, ctx: label, blueprint: bp, itemPool: pool,
      });
    } catch (e) { setCtxError("Something went wrong starting the assessment."); }
    setBusy(null);
  }

  /* ---- Prefetch both branches while the student works.
     Args are passed explicitly: React state here is a render behind. ---- */
  async function prefetch({ baseResponses, pendingSlot, usedSet, nDone, ctx, blueprint, itemPool }) {
    if (!pendingSlot) return;
    const branches = [];
    for (const outcome of [pendingSlot.maxScore, 0]) {
      const hypothetical = [...baseResponses, {
        b: pendingSlot.b, maxScore: pendingSlot.maxScore,
        thresholds: pendingSlot.thresholds, score: outcome,
      }];
      const t = estimate(hypothetical).theta;
      const slot = selectSlot(itemPool, usedSet, t, nDone, blueprint.components, blueprint.dokCeiling);
      if (slot && !cache.current[slot.id] && !branches.some((b) => b.slot.id === slot.id)) {
        branches.push({ slot });
      }
    }
    await Promise.all(branches.map(async (br) => {
      try { cache.current[br.slot.id] = await genItem(br.slot, blueprint, ctx, grade, usedStems.current); }
      catch (e) { /* prefetch is best-effort; a miss just costs latency */ }
    }));
  }

  /* ---- Step 4: submit an answer ---- */
  async function submitAnswer() {
    if (!current) return;
    const { slot, item } = current;
    setBusy("Checking your answer…");
    let score = 0, detail = {};

    try {
      if (slot.format === "multiple_choice") {
        const opt = item.options.find((o) => o.id === answer);
        score = opt?.correct ? 1 : 0;
        detail = { chose: answer, tag: opt?.tag, chosenText: opt?.text };
      } else if (slot.format === "multi_select") {
        const keys = item.options.filter((o) => o.correct).map((o) => o.id);
        const right = multi.filter((m) => keys.includes(m)).length;
        const wrong = multi.filter((m) => !keys.includes(m)).length;
        // 2 = complete and clean · 1 = partial with no false positives, or complete
        // with a single over-select · 0 = otherwise. All-or-nothing overstates
        // difficulty and wastes the information in a near-miss.
        if (right === keys.length && wrong === 0) score = 2;
        else if (wrong === 0 && right > 0) score = 1;
        else if (right === keys.length && wrong === 1) score = 1;
        else score = 0;
        detail = { chose: multi.join(","), tag: wrong ? item.options.filter((o) => multi.includes(o.id) && !o.correct).map((o) => o.tag).join("; ") : "key" };
      } else {
        const r = await scoreCR(item.stem, item.rubric, crText);
        score = Math.max(0, Math.min(2, r.score ?? 0));
        detail = { chose: crText, tag: r.misconception || "key", evidence: r.evidence };
      }
    } catch (e) { score = 0; detail = { chose: crText || answer || multi.join(","), tag: "unscored" }; }

    const resp = { b: slot.b, maxScore: slot.maxScore, thresholds: slot.thresholds, score };
    const nextResponses = [...responses, resp];
    const nextUsed = new Set([...used, slot.id]);
    const nextRecord = [...record, { slot, item, score, detail }];

    setResponses(nextResponses);
    setUsed(nextUsed);
    setRecord(nextRecord);
    setAnswer(null); setMulti([]); setCrText("");

    const e2 = estimate(nextResponses);
    const counts = {};
    (bp.components || []).forEach((c) => (counts[c.id] = 0));
    nextRecord.forEach((r) => counts[r.slot.componentId]++);

    if (shouldStop(nextRecord.length, e2.se, cfg, counts)) {
      setCurrent(null);
      setBusy("Scoring and interpreting…");
      await buildReport(nextRecord, e2);
      setBusy(null);
      setPhase("results");
      return;
    }

    const slot2 = selectSlot(pool, nextUsed, e2.theta, nextRecord.length, bp.components, bp.dokCeiling);
    if (!slot2) {
      setCurrent(null); setBusy("Scoring and interpreting…");
      await buildReport(nextRecord, e2); setBusy(null); setPhase("results"); return;
    }
    let nextItem = cache.current[slot2.id];
    if (!nextItem) {
      setBusy("Writing your next question…");
      try { nextItem = await genItem(slot2, bp, ctxChoice, grade, usedStems.current); }
      catch (e) { setErr("Lost connection while writing the next question."); setBusy(null); return; }
    }
    delete cache.current[slot2.id];
    usedStems.current = [...usedStems.current, nextItem.stem];
    setCurrent({ slot: slot2, item: nextItem });
    setBusy(null);
    prefetch({
      baseResponses: nextResponses,
      pendingSlot: slot2,
      usedSet: new Set([...nextUsed, slot2.id]),
      nDone: nextRecord.length + 1,
      ctx: ctxChoice,
      blueprint: bp,
      itemPool: pool,
    });
  }

  async function buildReport(rec, e2) {
    const probs = classificationProbs(e2.post, cfg.cuts);
    const top = probs.reduce((a, b) => (b.p > a.p ? b : a));
    const payload = {
      standard: bp.restated,
      grade, subject,
      theta: +e2.theta.toFixed(2),
      se: +e2.se.toFixed(2),
      scaleScore: scaleScore(e2.theta),
      ci: [scaleScore(e2.theta - 1.96 * e2.se), scaleScore(e2.theta + 1.96 * e2.se)],
      level: top.name,
      levelConfidence: +top.p.toFixed(2),
      itemsAdministered: rec.length,
      byComponent: bp.components.map((c) => {
        const its = rec.filter((r) => r.slot.componentId === c.id);
        return {
          component: c.label,
          n: its.length,
          earned: its.reduce((a, r) => a + r.score, 0),
          possible: its.reduce((a, r) => a + r.slot.maxScore, 0),
        };
      }),
      byDOK: [1, 2, 3, 4].map((d) => {
        const its = rec.filter((r) => r.slot.dok === d);
        return its.length ? { dok: d, n: its.length, earned: its.reduce((a, r) => a + r.score, 0), possible: its.reduce((a, r) => a + r.slot.maxScore, 0) } : null;
      }).filter(Boolean),
      missedTags: rec.filter((r) => r.score < r.slot.maxScore).map((r) => r.detail.tag).filter((t) => t && t !== "key"),
      theme: ctxChoice,
    };
    try { setReport({ ...(await interpret(payload)), payload, probs }); }
    catch (e) { setReport({ headline: "Interpretation unavailable — the measurement below is still valid.", payload, probs, nextSteps: [], strengths: [], gaps: [] }); }
  }

  function reset(keepBlueprint) {
    setResponses([]); setUsed(new Set()); setRecord([]); setCurrent(null);
    setReport(null); setAnswer(null); setMulti([]); setCrText("");
    setCtxChoice(null); setCustomCtx(""); setStudentName("");
    cache.current = {}; usedStems.current = [];
    setPhase(keepBlueprint ? "handoff" : "setup");
    if (!keepBlueprint) setBp(null);
  }

  function copyRecord() {
    const out = {
      exportedAt: new Date().toISOString(),
      student: studentName || "unnamed",
      standard: bp?.restated, grade, subject, theme: ctxChoice,
      thetaEAP: +est.theta.toFixed(3), se: +est.se.toFixed(3),
      calibrationNote: "b parameters are provisional expert-assigned values, not empirically calibrated. Pool this file across administrations to estimate real parameters.",
      items: record.map((r, i) => ({
        seq: i + 1, slotId: r.slot.id, component: r.slot.componentLabel,
        dok: r.slot.dok, b: r.slot.b, format: r.slot.format,
        maxScore: r.slot.maxScore, score: r.score,
        stem: r.item.stem, response: r.detail.chose, diagnosticTag: r.detail.tag,
      })),
    };
    const txt = JSON.stringify(out, null, 2);
    if (navigator.clipboard?.writeText) navigator.clipboard.writeText(txt);
    else {
      const ta = document.createElement("textarea");
      ta.value = txt; document.body.appendChild(ta); ta.select();
      try { document.execCommand("copy"); } catch (e) {}
      document.body.removeChild(ta);
    }
    setCopied(true); setTimeout(() => setCopied(false), 2200);
  }

  const canSubmit =
    current &&
    (current.slot.format === "multiple_choice" ? !!answer
      : current.slot.format === "multi_select" ? multi.length > 0
      : crText.trim().length > 8);

  /* ---------- shells ---------- */
  const page = { minHeight: "100vh", background: MV.paleBlue, fontFamily: SANS, color: MV.blackTeal };
  const card = { background: MV.white, borderRadius: 14, border: `1px solid ${MV.lightTeal}`, padding: 26 };

  return (
    <div style={page}>
      <style>{`
        @keyframes mvspin { to { transform: rotate(360deg) } }
        .mv-spin { animation: mvspin 1s linear infinite }
        @keyframes mvrise { from { opacity:0; transform: translateY(10px) } to { opacity:1; transform:none } }
        .mv-rise { animation: mvrise .34s cubic-bezier(.2,.8,.2,1) both }
        button:focus-visible, input:focus-visible, textarea:focus-visible, div[role=button]:focus-visible {
          outline: 3px solid ${MV.orange}; outline-offset: 2px;
        }
        input:focus, textarea:focus { border-color: ${MV.midTeal} !important }
        @media (prefers-reduced-motion: reduce) { .mv-rise, .mv-spin { animation: none !important } * { transition: none !important } }
        @media print { .no-print { display:none !important } body { background:#fff } }
      `}</style>

      {/* ===== HEADER ===== */}
      <header className="no-print" style={{ background: MV.blackTeal, padding: "16px 22px", display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
          <Gauge size={22} color={MV.green} />
          <div>
            <div style={{ color: MV.white, fontWeight: 900, fontSize: 16, letterSpacing: "-0.02em" }}>Measurement Studio</div>
            <div style={{ color: MV.lightTeal, fontSize: 11.5, fontFamily: MONO }}>adaptive · standards-referenced · Rasch</div>
          </div>
        </div>
        <div style={{ flex: 1 }} />
        {["setup", "blueprint", "handoff", "testing", "results"].map((p, i) => (
          <div key={p} style={{
            fontFamily: MONO, fontSize: 10.5, padding: "4px 9px", borderRadius: 5,
            background: phase === p ? MV.green : "transparent",
            color: phase === p ? MV.blackTeal : MV.lightTeal,
            fontWeight: phase === p ? 700 : 400,
            opacity: phase === p ? 1 : 0.55,
          }}>{i + 1} {p}</div>
        ))}
      </header>

      <main style={{ maxWidth: phase === "testing" ? 780 : 1080, margin: "0 auto", padding: "30px 20px 70px" }}>
        {err && (
          <div className="no-print" style={{ ...card, borderColor: MV.orange, background: MV.cream, marginBottom: 20, display: "flex", gap: 11, alignItems: "flex-start" }}>
            <AlertTriangle size={19} color={MV.orange} style={{ flexShrink: 0, marginTop: 1 }} />
            <div style={{ flex: 1 }}>
              <div style={{ fontWeight: 700 }}>{err}</div>
            </div>
            <button onClick={() => setErr(null)} style={{ background: "none", border: "none", cursor: "pointer" }}><X size={17} color={MV.lightGray} /></button>
          </div>
        )}

        {/* ================= PHASE 1: SETUP ================= */}
        {phase === "setup" && (
          <div className="mv-rise" style={{ display: "grid", gridTemplateColumns: "minmax(320px, 1.35fr) minmax(260px, 1fr)", gap: 24 }}>
            <div style={card}>
              <Rule />
              <h1 style={{ margin: "0 0 6px", fontSize: 30, fontWeight: 900, color: MV.darkTeal, letterSpacing: "-0.03em" }}>
                Start with the standard
              </h1>
              <p style={{ margin: "0 0 26px", color: MV.darkGray, fontSize: 15, lineHeight: 1.55 }}>
                One standard gives you a clean, interpretable score. Two is the practical ceiling — beyond that the test gets long and the score stops meaning one thing.
              </p>

              <div style={{ display: "flex", gap: 14 }}>
                <div style={{ flex: 1 }}>
                  <Field label="Grade band">
                    <select value={grade} onChange={(e) => setGrade(e.target.value)} style={inputStyle}>
                      {["Grades K-2", "Grades 3-5", "Grade 6", "Grade 7", "Grade 8", "Grades 9-10", "Grades 11-12"].map((g) => <option key={g}>{g}</option>)}
                    </select>
                  </Field>
                </div>
                <div style={{ flex: 1 }}>
                  <Field label="Subject">
                    <select value={subject} onChange={(e) => setSubject(e.target.value)} style={inputStyle}>
                      {["Mathematics", "English Language Arts", "Science", "Social Studies", "Computer Science", "World Language"].map((s) => <option key={s}>{s}</option>)}
                    </select>
                  </Field>
                </div>
              </div>

              <Field label="Standard 1" hint="Code alone works. Pasting the full text produces a tighter blueprint.">
                <input value={stdA.code} onChange={(e) => setStdA({ ...stdA, code: e.target.value })}
                  placeholder="7.RP.A.2" style={{ ...inputStyle, marginBottom: 8, fontFamily: MONO }} />
                <textarea value={stdA.text} onChange={(e) => setStdA({ ...stdA, text: e.target.value })}
                  placeholder="Recognize and represent proportional relationships between quantities…"
                  rows={3} style={{ ...inputStyle, resize: "vertical" }} />
              </Field>

              {!useSecond ? (
                <Btn variant="quiet" onClick={() => setUseSecond(true)} style={{ marginBottom: 22 }}>+ Add a second standard</Btn>
              ) : (
                <Field label="Standard 2">
                  <input value={stdB.code} onChange={(e) => setStdB({ ...stdB, code: e.target.value })}
                    placeholder="7.RP.A.3" style={{ ...inputStyle, marginBottom: 8, fontFamily: MONO }} />
                  <textarea value={stdB.text} onChange={(e) => setStdB({ ...stdB, text: e.target.value })}
                    rows={2} style={{ ...inputStyle, resize: "vertical" }} />
                  <button onClick={() => { setUseSecond(false); setStdB({ code: "", text: "" }); }}
                    style={{ background: "none", border: "none", color: MV.lightGray, fontSize: 12.5, cursor: "pointer", padding: "6px 0", fontFamily: SANS }}>
                    Remove
                  </button>
                </Field>
              )}

              {busy ? <Spinner label={busy} /> : (
                <Btn onClick={doUnpack} disabled={!stdA.code.trim() && !stdA.text.trim()}>
                  Unpack the standard <ArrowRight size={17} />
                </Btn>
              )}
            </div>

            <aside style={{ ...card, background: MV.blackTeal, color: MV.white }}>
              <div style={{ height: 3, background: MV.green, width: 46, borderRadius: 2, marginBottom: 18 }} />
              <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.green, marginBottom: 14 }}>
                How the score stays comparable
              </div>
              <p style={{ fontSize: 14, lineHeight: 1.65, color: MV.lightTeal, margin: "0 0 16px" }}>
                Every item is built from a fixed <strong style={{ color: MV.white }}>spec</strong>: which sub-skill, what DOK, what difficulty in logits, what scoring rule. The student's chosen theme is a <strong style={{ color: MV.white }}>skin</strong> over that spec.
              </p>
              <div style={{ background: "rgba(255,255,255,.06)", borderRadius: 9, padding: 14, fontFamily: MONO, fontSize: 11.5, lineHeight: 1.85, color: MV.lightTeal }}>
                <div style={{ color: MV.green }}>SPEC (fixed)</div>
                <div>C2 · DOK 2 · b=+0.30 · 2 steps</div>
                <div style={{ height: 9 }} />
                <div style={{ color: MV.sand }}>SKIN (student's choice)</div>
                <div>skate park · dog rescue · concert</div>
              </div>
              <p style={{ fontSize: 13.5, lineHeight: 1.6, color: MV.lightTeal, margin: "16px 0 0" }}>
                Theta is estimated from the spec layer only. Three students on three themes sit psychometrically parallel forms, so their scores land on one scale.
              </p>
            </aside>
          </div>
        )}

        {/* ================= PHASE 2: BLUEPRINT VERIFICATION ================= */}
        {phase === "blueprint" && bp && (
          <div className="mv-rise">
            <Rule />
            <h1 style={{ margin: "0 0 6px", fontSize: 30, fontWeight: 900, color: MV.darkTeal, letterSpacing: "-0.03em" }}>Verify the blueprint</h1>
            <p style={{ margin: "0 0 24px", color: MV.darkGray, fontSize: 15, maxWidth: 680, lineHeight: 1.55 }}>
              This is the measurement contract. Everything below is editable, and nothing is measured until you approve it — the cut scores in particular are your professional judgment, not the model's.
            </p>

            <div style={{ display: "grid", gridTemplateColumns: "minmax(340px, 1.5fr) minmax(300px, 1fr)", gap: 22 }}>
              <div style={{ display: "grid", gap: 18 }}>
                <div style={card}>
                  <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.teal, marginBottom: 8 }}>What will be measured</div>
                  <textarea value={bp.restated} onChange={(e) => setBp({ ...bp, restated: e.target.value })}
                    rows={2} style={{ ...inputStyle, fontSize: 16.5, fontWeight: 600, lineHeight: 1.45, resize: "vertical" }} />
                  <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 13 }}>
                    <Chip tone="dark">DOK ceiling {bp.dokCeiling}</Chip>
                    <Chip>{bp.readability}</Chip>
                    <Chip>{grade}</Chip>
                    <Chip>{subject}</Chip>
                  </div>
                </div>

                <div style={card}>
                  <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.teal, marginBottom: 4 }}>Components</div>
                  <div style={{ fontSize: 13, color: MV.lightGray, marginBottom: 15 }}>The adaptive engine rotates across these so no sub-skill goes unmeasured.</div>
                  {bp.components.map((c, i) => (
                    <div key={c.id} style={{ display: "flex", gap: 13, padding: "13px 0", borderTop: i ? `1px solid ${MV.lightTeal}` : "none", alignItems: "flex-start" }}>
                      <Chip tone="gray">{c.id}</Chip>
                      <div style={{ flex: 1 }}>
                        <input value={c.label} onChange={(e) => {
                          const n = [...bp.components]; n[i] = { ...c, label: e.target.value }; setBp({ ...bp, components: n });
                        }} style={{ ...inputStyle, fontWeight: 700, padding: "7px 10px", marginBottom: 5 }} />
                        <div style={{ fontSize: 13.5, color: MV.darkGray, lineHeight: 1.5 }}>{c.description}</div>
                      </div>
                      <select value={c.dokCeiling} onChange={(e) => {
                        const n = [...bp.components]; n[i] = { ...c, dokCeiling: +e.target.value }; setBp({ ...bp, components: n });
                      }} style={{ ...inputStyle, width: 92, padding: "7px 9px", fontFamily: MONO, fontSize: 13 }}>
                        {[1, 2, 3, 4].map((d) => <option key={d} value={d}>DOK {d}</option>)}
                      </select>
                    </div>
                  ))}
                </div>

                <div style={card}>
                  <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.teal, marginBottom: 8 }}>Assessment methodology</div>
                  <p style={{ fontSize: 14.5, lineHeight: 1.6, color: MV.darkGray, margin: "0 0 14px" }}>{bp.methodology}</p>
                  <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
                    {(bp.misconceptions || []).map((m, i) => <Chip key={i} tone="sand">{m}</Chip>)}
                  </div>
                </div>
              </div>

              <div style={{ display: "grid", gap: 18, alignContent: "start" }}>
                <div style={{ ...card, background: MV.blackTeal, color: MV.white }}>
                  <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.green, marginBottom: 16 }}>Stopping rules</div>

                  <div style={{ marginBottom: 16 }}>
                    <div style={{ display: "flex", justifyContent: "space-between", fontSize: 13.5, marginBottom: 6, color: MV.lightTeal }}>
                      <span>Minimum items</span><span style={{ fontFamily: MONO, color: MV.white, fontWeight: 600 }}>{cfg.minItems}</span>
                    </div>
                    <input type="range" min={5} max={14} value={cfg.minItems}
                      onChange={(e) => setCfg({ ...cfg, minItems: Math.min(+e.target.value, cfg.maxItems - 1) })}
                      style={{ width: "100%", accentColor: MV.green }} />
                  </div>

                  <div style={{ marginBottom: 16 }}>
                    <div style={{ display: "flex", justifyContent: "space-between", fontSize: 13.5, marginBottom: 6, color: MV.lightTeal }}>
                      <span>Maximum items</span><span style={{ fontFamily: MONO, color: MV.white, fontWeight: 600 }}>{cfg.maxItems}</span>
                    </div>
                    <input type="range" min={6} max={poolCeiling} value={cfg.maxItems}
                      onChange={(e) => setCfg({ ...cfg, maxItems: Math.max(+e.target.value, cfg.minItems + 1) })}
                      style={{ width: "100%", accentColor: MV.green }} />
                    <div style={{ fontSize: 12, color: MV.lightTeal, opacity: 0.85, marginTop: 5 }}>
                      {projectedPool.length} item specs in the pool at these settings.
                    </div>
                  </div>

                  <div style={{ marginBottom: 8 }}>
                    <div style={{ display: "flex", justifyContent: "space-between", fontSize: 13.5, marginBottom: 6, color: MV.lightTeal }}>
                      <span>Stop when SE ≤</span><span style={{ fontFamily: MONO, color: MV.white, fontWeight: 600 }}>{cfg.targetSE.toFixed(2)}</span>
                    </div>
                    <input type="range" min={0.38} max={0.6} step={0.01} value={cfg.targetSE}
                      onChange={(e) => setCfg({ ...cfg, targetSE: +e.target.value })}
                      style={{ width: "100%", accentColor: MV.green }} />
                    <div style={{ fontSize: 12, color: MV.lightTeal, opacity: 0.85, marginTop: 5, lineHeight: 1.45 }}>
                      ≈ reliability {(1 - cfg.targetSE ** 2).toFixed(2)} · typically stops near{" "}
                      <span style={{ fontFamily: MONO, color: MV.white }}>{expectedLength}</span> items for a
                      mid-range student, longer at the tails. Below 0.38 is not reachable inside 20 items.
                    </div>
                  </div>

                  <label style={{ display: "flex", gap: 10, alignItems: "center", marginTop: 16, cursor: "pointer", fontSize: 14, color: MV.lightTeal }}>
                    <input type="checkbox" checked={cfg.allowCR} onChange={(e) => setCfg({ ...cfg, allowCR: e.target.checked })}
                      style={{ width: 17, height: 17, accentColor: MV.green }} />
                    Allow written response at DOK 3+
                  </label>
                </div>

                <div style={card}>
                  <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.teal, marginBottom: 6 }}>Cut scores</div>
                  <div style={{ fontSize: 13, color: MV.lightGray, marginBottom: 14, lineHeight: 1.5 }}>
                    Where proficiency begins is a standard-setting decision. These are defensible defaults — move them to match your building.
                  </div>
                  <ThetaRibbon theta={cfg.cuts[1]} se={0.5} cuts={cfg.cuts} />
                  {["Beginning → Developing", "Developing → Proficient", "Proficient → Advanced"].map((lbl, i) => (
                    <div key={i} style={{ marginTop: 13 }}>
                      <div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 4 }}>
                        <span style={{ color: MV.darkGray }}>{lbl}</span>
                        <span style={{ fontFamily: MONO, fontWeight: 600, color: MV.teal }}>
                          θ {cfg.cuts[i] >= 0 ? "+" : ""}{cfg.cuts[i].toFixed(2)} · SS {scaleScore(cfg.cuts[i])}
                        </span>
                      </div>
                      <input type="range" min={-2.5} max={2.5} step={0.05} value={cfg.cuts[i]}
                        onChange={(e) => {
                          const n = [...cfg.cuts];
                          const lo = i === 0 ? -2.5 : n[i - 1] + 0.1;
                          const hi = i === 2 ? 2.5 : n[i + 1] - 0.1;
                          n[i] = Math.max(lo, Math.min(hi, +e.target.value));
                          setCfg({ ...cfg, cuts: n });
                        }} style={{ width: "100%", accentColor: MV.midTeal }} />
                    </div>
                  ))}
                </div>

                {busy ? <Spinner label={busy} /> : (
                  <div style={{ display: "flex", gap: 10 }}>
                    <Btn onClick={lockBlueprint}><Lock size={16} /> Approve and build</Btn>
                    <Btn variant="ghost" onClick={() => setPhase("setup")}>Back</Btn>
                  </div>
                )}
              </div>
            </div>
          </div>
        )}

        {/* ================= PHASE 3: HANDOFF + STUDENT CONTEXT ================= */}
        {phase === "handoff" && bp && (
          <div className="mv-rise" style={{ maxWidth: 720, margin: "0 auto" }}>
            <div style={{ ...card, borderColor: MV.green, borderWidth: 2, marginBottom: 22, display: "flex", gap: 13, alignItems: "flex-start" }}>
              <ShieldCheck size={21} color={MV.teal} style={{ flexShrink: 0, marginTop: 2 }} />
              <div>
                <div style={{ fontWeight: 800, fontSize: 15, marginBottom: 3 }}>Blueprint locked · {pool.length} item specs ready</div>
                <div style={{ fontSize: 13.5, color: MV.darkGray, lineHeight: 1.5 }}>
                  Hand the device to the student now. Everything below this line is written for them.
                </div>
              </div>
            </div>

            <div style={{ ...card, padding: 34 }}>
              <Rule />
              <h1 style={{ margin: "0 0 8px", fontSize: 29, fontWeight: 900, color: MV.darkTeal, letterSpacing: "-0.03em" }}>
                Let's set up your questions
              </h1>
              <p style={{ margin: "0 0 26px", fontSize: 16, color: MV.darkGray, lineHeight: 1.55 }}>
                You get to pick what these questions are about. The math stays the same either way — you just get to choose the story around it.
              </p>

              <Field label="Your name">
                <input value={studentName} onChange={(e) => setStudentName(e.target.value)}
                  placeholder="First name or initials" style={{ ...inputStyle, fontSize: 16 }} />
              </Field>

              <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.teal, marginBottom: 10, marginTop: 6 }}>
                Pick a theme
              </div>

              <div style={{ display: "grid", gap: 11 }}>
                {ctxOptions.map((o) => {
                  const sel = ctxChoice === o.label;
                  return (
                    <div key={o.label} role="button" tabIndex={0}
                      onClick={() => { setCtxChoice(o.label); setCtxError(null); }}
                      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setCtxChoice(o.label); } }}
                      style={{
                        display: "flex", gap: 14, alignItems: "center", padding: "16px 18px", borderRadius: 12,
                        border: `2px solid ${sel ? MV.midTeal : MV.lightTeal}`,
                        background: sel ? MV.paleBlue : MV.white, cursor: "pointer", transition: "all .15s ease",
                      }}>
                      <span style={{ fontSize: 26 }}>{o.emoji}</span>
                      <div style={{ flex: 1 }}>
                        <div style={{ fontWeight: 800, fontSize: 16.5, color: MV.darkTeal }}>{o.label}</div>
                        <div style={{ fontSize: 14, color: MV.darkGray, marginTop: 2 }}>{o.blurb}</div>
                      </div>
                      {sel && <Check size={21} color={MV.midTeal} />}
                    </div>
                  );
                })}

                <div role="button" tabIndex={0}
                  onClick={() => { setCtxChoice("__other__"); setCtxError(null); }}
                  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setCtxChoice("__other__"); } }}
                  style={{
                    padding: "16px 18px", borderRadius: 12,
                    border: `2px dashed ${ctxChoice === "__other__" ? MV.midTeal : MV.lightTeal}`,
                    background: ctxChoice === "__other__" ? MV.paleBlue : MV.white, cursor: "pointer",
                  }}>
                  <div style={{ display: "flex", gap: 14, alignItems: "center" }}>
                    <Wand2 size={22} color={MV.teal} />
                    <div style={{ flex: 1 }}>
                      <div style={{ fontWeight: 800, fontSize: 16.5, color: MV.darkTeal }}>Something else</div>
                      <div style={{ fontSize: 14, color: MV.darkGray, marginTop: 2 }}>Tell us what you're into and we'll build around it.</div>
                    </div>
                  </div>
                  {ctxChoice === "__other__" && (
                    <input autoFocus value={customCtx} onChange={(e) => setCustomCtx(e.target.value)}
                      onClick={(e) => e.stopPropagation()} maxLength={60}
                      placeholder="soccer, drawing, trains, cooking…"
                      style={{ ...inputStyle, marginTop: 13, fontSize: 16 }} />
                  )}
                </div>
              </div>

              {ctxError && (
                <div style={{ marginTop: 14, padding: 14, borderRadius: 10, background: MV.cream, border: `1px solid ${MV.sand}`, fontSize: 14.5, color: "#8a5a20", lineHeight: 1.5 }}>
                  {ctxError}
                </div>
              )}

              <div style={{ marginTop: 24 }}>
                {busy ? <Spinner label={busy} /> : (
                  <Btn onClick={confirmContext} disabled={!ctxChoice}>
                    Start <ArrowRight size={17} />
                  </Btn>
                )}
              </div>
              <div style={{ marginTop: 18, fontSize: 13, color: MV.lightGray, lineHeight: 1.55 }}>
                The test gets easier or harder based on how you're doing, so it won't feel the same as your neighbor's. That's on purpose — it's finding what you actually know instead of making you sit through questions that are way too easy or way too hard.
              </div>
            </div>
          </div>
        )}

        {/* ================= PHASE 4: TESTING ================= */}
        {phase === "testing" && current && (
          <div className="mv-rise" key={record.length}>
            <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 18, flexWrap: "wrap" }}>
              <Chip tone="dark">Question {record.length + 1}</Chip>
              <div style={{ flex: 1, minWidth: 140 }}>
                <div style={{ height: 6, background: MV.lightTeal, borderRadius: 3, overflow: "hidden" }}>
                  <div style={{
                    height: "100%", background: MV.green, borderRadius: 3,
                    width: `${Math.min(100, Math.max(8, record.length ? (1 - Math.min(est.se, 1)) / (1 - cfg.targetSE) * 100 : 8))}%`,
                    transition: "width .6s cubic-bezier(.2,.8,.2,1)",
                  }} />
                </div>
              </div>
              <div style={{ fontSize: 12.5, color: MV.lightGray }}>
                {record.length < cfg.minItems ? "Getting started" : est.se > cfg.targetSE ? "Almost there" : "Nearly done"}
              </div>
            </div>

            <div style={{ ...card, padding: 34 }}>
              <div style={{ fontSize: 20, lineHeight: 1.55, fontWeight: 600, color: MV.blackTeal, marginBottom: 26, whiteSpace: "pre-wrap" }}>
                {current.item.stem}
              </div>

              {current.slot.format !== "short_constructed" && (
                <div style={{ display: "grid", gap: 10 }}>
                  {(current.item.options || []).map((o) => {
                    const isMulti = current.slot.format === "multi_select";
                    const sel = isMulti ? multi.includes(o.id) : answer === o.id;
                    return (
                      <div key={o.id} role="button" tabIndex={0}
                        onClick={() => isMulti
                          ? setMulti(sel ? multi.filter((m) => m !== o.id) : [...multi, o.id])
                          : setAnswer(o.id)}
                        onKeyDown={(e) => {
                          if (e.key === "Enter" || e.key === " ") {
                            e.preventDefault();
                            isMulti ? setMulti(sel ? multi.filter((m) => m !== o.id) : [...multi, o.id]) : setAnswer(o.id);
                          }
                        }}
                        style={{
                          display: "flex", gap: 14, alignItems: "flex-start", padding: "15px 18px",
                          borderRadius: isMulti ? 9 : 11,
                          border: `2px solid ${sel ? MV.midTeal : MV.lightTeal}`,
                          background: sel ? MV.paleBlue : MV.white, cursor: "pointer", transition: "all .13s ease",
                        }}>
                        <div style={{
                          width: 27, height: 27, flexShrink: 0, borderRadius: isMulti ? 6 : 14,
                          border: `2px solid ${sel ? MV.midTeal : MV.lightTeal}`,
                          background: sel ? MV.midTeal : MV.white, color: MV.white,
                          display: "flex", alignItems: "center", justifyContent: "center",
                          fontFamily: MONO, fontSize: 12.5, fontWeight: 700,
                        }}>
                          {sel ? <Check size={15} /> : <span style={{ color: MV.teal }}>{o.id}</span>}
                        </div>
                        <div style={{ fontSize: 16.5, lineHeight: 1.5, paddingTop: 2 }}>{o.text}</div>
                      </div>
                    );
                  })}
                </div>
              )}

              {current.slot.format === "short_constructed" && (
                <div>
                  <textarea value={crText} onChange={(e) => setCrText(e.target.value)} rows={6}
                    placeholder="Explain your thinking. Full sentences aren't required — showing how you got there is."
                    style={{ ...inputStyle, fontSize: 16.5, lineHeight: 1.55, resize: "vertical" }} />
                  <div style={{ fontSize: 12.5, color: MV.lightGray, marginTop: 7 }}>
                    Spelling isn't scored. Your reasoning is.
                  </div>
                </div>
              )}

              <div style={{ marginTop: 28, display: "flex", alignItems: "center", gap: 16 }}>
                {busy ? <Spinner label={busy} /> : (
                  <Btn onClick={submitAnswer} disabled={!canSubmit}>
                    Submit answer <ArrowRight size={17} />
                  </Btn>
                )}
                {!busy && current.slot.format === "multi_select" && (
                  <span style={{ fontSize: 13.5, color: MV.lightGray }}>Choose all that apply.</span>
                )}
              </div>
            </div>

            <div style={{ textAlign: "center", marginTop: 20, fontSize: 12.5, color: MV.lightGray }}>
              You can't go back to change an earlier answer — the next question is built from this one.
            </div>
          </div>
        )}

        {phase === "testing" && !current && busy && (
          <div style={{ ...card, padding: 44, textAlign: "center" }}><Spinner label={busy} /></div>
        )}

        {/* ================= PHASE 5: RESULTS ================= */}
        {phase === "results" && report && (() => {
          const p = report.payload;
          const top = report.probs.reduce((a, b) => (b.p > a.p ? b : a));
          const straddles = report.probs.filter((x) => x.p > 0.2).length > 1;
          return (
            <div className="mv-rise">
              {/* Student-facing card first — they're still sitting there */}
              <div className="no-print" style={{ ...card, background: MV.cream, borderColor: MV.sand, marginBottom: 26, padding: 28 }}>
                <div style={{ display: "flex", gap: 13, alignItems: "flex-start" }}>
                  <User size={21} color={MV.orange} style={{ flexShrink: 0, marginTop: 3 }} />
                  <div>
                    <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: "#8a5a20", marginBottom: 7 }}>
                      For {studentName || "the student"}
                    </div>
                    <div style={{ fontSize: 17.5, lineHeight: 1.6, color: MV.blackTeal, fontWeight: 500 }}>
                      {report.studentMessage || "You finished — nice work. Your teacher will go over this with you."}
                    </div>
                  </div>
                </div>
              </div>

              <Rule />
              <h1 style={{ margin: "0 0 22px", fontSize: 30, fontWeight: 900, color: MV.darkTeal, letterSpacing: "-0.03em" }}>Teacher report</h1>

              {/* Score block */}
              <div style={{ ...card, marginBottom: 20, padding: 30 }}>
                <div style={{ display: "flex", gap: 34, flexWrap: "wrap", alignItems: "flex-start", marginBottom: 26 }}>
                  <div>
                    <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.teal, marginBottom: 4 }}>Scale score</div>
                    <div style={{ fontFamily: MONO, fontSize: 52, fontWeight: 600, color: MV.darkTeal, lineHeight: 1 }}>{p.scaleScore}</div>
                    <div style={{ fontFamily: MONO, fontSize: 13, color: MV.lightGray, marginTop: 5 }}>95% CI  {p.ci[0]}–{p.ci[1]}</div>
                  </div>
                  <div>
                    <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.teal, marginBottom: 4 }}>Level</div>
                    <div style={{ fontSize: 27, fontWeight: 900, color: top.color, letterSpacing: "-0.02em" }}>{top.name}</div>
                    <div style={{ fontSize: 13, color: MV.lightGray, marginTop: 5 }}>{Math.round(top.p * 100)}% classification confidence</div>
                  </div>
                  <div style={{ display: "flex", gap: 22 }}>
                    {[["θ", (p.theta >= 0 ? "+" : "") + p.theta.toFixed(2)], ["SE", p.se.toFixed(2)],
                      ["Rel.", Math.max(0, 1 - p.se ** 2).toFixed(2)], ["Items", p.itemsAdministered]].map(([k, v]) => (
                      <div key={k}>
                        <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.08em", textTransform: "uppercase", color: MV.lightGray, marginBottom: 3 }}>{k}</div>
                        <div style={{ fontFamily: MONO, fontSize: 21, fontWeight: 600, color: MV.blackTeal }}>{v}</div>
                      </div>
                    ))}
                  </div>
                </div>

                <ThetaRibbon theta={p.theta} se={p.se} cuts={cfg.cuts} />

                {straddles && (
                  <div style={{ marginTop: 20, padding: 15, borderRadius: 10, background: MV.cream, border: `1px solid ${MV.sand}`, display: "flex", gap: 11, alignItems: "flex-start" }}>
                    <AlertTriangle size={18} color={MV.orange} style={{ flexShrink: 0, marginTop: 1 }} />
                    <div style={{ fontSize: 14, lineHeight: 1.55, color: MV.blackTeal }}>
                      <strong>The interval crosses a cut score.</strong> This student's true ability plausibly falls in more than one level ({report.probs.filter((x) => x.p > 0.2).map((x) => `${x.name} ${Math.round(x.p * 100)}%`).join(" · ")}). Treat the level label as provisional and gather more evidence before it drives a placement decision.
                    </div>
                  </div>
                )}
              </div>

              {report.headline && (
                <div style={{ ...card, marginBottom: 20, background: MV.blackTeal, color: MV.white, padding: 28 }}>
                  <div style={{ height: 3, background: MV.green, width: 46, borderRadius: 2, marginBottom: 16 }} />
                  <div style={{ fontSize: 20.5, fontWeight: 700, lineHeight: 1.45, marginBottom: 14, letterSpacing: "-0.02em" }}>{report.headline}</div>
                  <p style={{ fontSize: 15, lineHeight: 1.68, color: MV.lightTeal, margin: 0 }}>{report.meaning}</p>
                </div>
              )}

              {/* Profiles */}
              <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: 20, marginBottom: 20 }}>
                <div style={card}>
                  <div style={{ display: "flex", gap: 9, alignItems: "center", marginBottom: 15 }}>
                    <Layers size={17} color={MV.teal} />
                    <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.teal }}>By component</div>
                  </div>
                  {p.byComponent.map((c) => (
                    <div key={c.component} style={{ marginBottom: 15 }}>
                      <div style={{ display: "flex", justifyContent: "space-between", fontSize: 14, marginBottom: 5 }}>
                        <span style={{ fontWeight: 600 }}>{c.component}</span>
                        <span style={{ fontFamily: MONO, color: MV.lightGray }}>{c.earned}/{c.possible}</span>
                      </div>
                      <div style={{ height: 8, background: MV.paleBlue, borderRadius: 4, overflow: "hidden" }}>
                        <div style={{ height: "100%", width: `${c.possible ? (c.earned / c.possible) * 100 : 0}%`, background: MV.midTeal, borderRadius: 4 }} />
                      </div>
                      {c.n < 3 && (
                        <div style={{ fontSize: 11.5, color: MV.orange, marginTop: 4, fontWeight: 600 }}>
                          {c.n} item{c.n === 1 ? "" : "s"} — too thin to read on its own
                        </div>
                      )}
                    </div>
                  ))}
                </div>

                <div style={card}>
                  <div style={{ display: "flex", gap: 9, alignItems: "center", marginBottom: 15 }}>
                    <Target size={17} color={MV.teal} />
                    <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.teal }}>By depth of knowledge</div>
                  </div>
                  {p.byDOK.map((d) => (
                    <div key={d.dok} style={{ display: "flex", alignItems: "center", gap: 13, marginBottom: 13 }}>
                      <Chip tone="gray">DOK {d.dok}</Chip>
                      <div style={{ flex: 1 }}>
                        <div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: 4 }}>{DOK_LABEL[d.dok]}</div>
                        <div style={{ height: 8, background: MV.paleBlue, borderRadius: 4, overflow: "hidden" }}>
                          <div style={{ height: "100%", width: `${d.possible ? (d.earned / d.possible) * 100 : 0}%`, background: MV.darkTeal, borderRadius: 4 }} />
                        </div>
                      </div>
                      <span style={{ fontFamily: MONO, fontSize: 12.5, color: MV.lightGray }}>{d.earned}/{d.possible}</span>
                    </div>
                  ))}
                </div>
              </div>

              {/* Instructional ramifications */}
              {(report.nextSteps?.length > 0 || report.strengths?.length > 0) && (
                <div style={{ ...card, marginBottom: 20 }}>
                  <div style={{ display: "flex", gap: 9, alignItems: "center", marginBottom: 18 }}>
                    <BookOpen size={17} color={MV.teal} />
                    <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.teal }}>What to do about it</div>
                  </div>

                  <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px,1fr))", gap: 22, marginBottom: 22 }}>
                    <div>
                      <div style={{ fontSize: 13.5, fontWeight: 800, color: MV.darkTeal, marginBottom: 8 }}>Holding</div>
                      {(report.strengths || []).map((s, i) => (
                        <div key={i} style={{ display: "flex", gap: 8, fontSize: 14.5, lineHeight: 1.5, marginBottom: 7 }}>
                          <Check size={16} color={MV.midTeal} style={{ flexShrink: 0, marginTop: 3 }} />{s}
                        </div>
                      ))}
                    </div>
                    <div>
                      <div style={{ fontSize: 13.5, fontWeight: 800, color: MV.darkTeal, marginBottom: 8 }}>Not yet</div>
                      {(report.gaps || []).map((s, i) => (
                        <div key={i} style={{ display: "flex", gap: 8, fontSize: 14.5, lineHeight: 1.5, marginBottom: 7 }}>
                          <ChevronRight size={16} color={MV.orange} style={{ flexShrink: 0, marginTop: 3 }} />{s}
                        </div>
                      ))}
                    </div>
                  </div>

                  {(report.nextSteps || []).map((n, i) => (
                    <div key={i} style={{ padding: "16px 18px", background: MV.paleBlue, borderRadius: 11, marginBottom: 10, borderLeft: `4px solid ${MV.green}` }}>
                      <div style={{ fontWeight: 700, fontSize: 15.5, marginBottom: 4, color: MV.darkTeal }}>{n.move}</div>
                      <div style={{ fontSize: 13.5, color: MV.darkGray, lineHeight: 1.55 }}>{n.why}</div>
                    </div>
                  ))}

                  {report.limits && (
                    <div style={{ marginTop: 18, paddingTop: 16, borderTop: `1px solid ${MV.lightTeal}`, fontSize: 13.5, color: MV.lightGray, fontStyle: "italic", lineHeight: 1.6 }}>
                      {report.limits}
                    </div>
                  )}
                </div>
              )}

              {/* Evidence log — the spec/skin proof */}
              <div style={{ ...card, marginBottom: 20 }}>
                <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MV.teal, marginBottom: 6 }}>Evidence log</div>
                <div style={{ fontSize: 13, color: MV.lightGray, marginBottom: 16, lineHeight: 1.5 }}>
                  Each row shows the fixed spec on the left and what the student actually saw on the right. Theme changed; the spec did not.
                </div>
                {record.map((r, i) => (
                  <div key={i} style={{ borderTop: i ? `1px solid ${MV.lightTeal}` : "none" }}>
                    <div role="button" tabIndex={0}
                      onClick={() => setOpenItem(openItem === i ? null : i)}
                      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenItem(openItem === i ? null : i); } }}
                      style={{ display: "flex", gap: 11, alignItems: "center", padding: "11px 0", cursor: "pointer", flexWrap: "wrap" }}>
                      <span style={{ fontFamily: MONO, fontSize: 12, color: MV.lightGray, width: 22 }}>{i + 1}</span>
                      <div style={{
                        width: 26, height: 26, borderRadius: 6, flexShrink: 0,
                        background: r.score === r.slot.maxScore ? MV.midTeal : r.score > 0 ? MV.sand : "#f2f2f3",
                        color: r.score > 0 ? MV.white : MV.lightGray,
                        display: "flex", alignItems: "center", justifyContent: "center",
                        fontFamily: MONO, fontSize: 12, fontWeight: 700,
                      }}>{r.score}</div>
                      <Chip tone="gray">{r.slot.id}</Chip>
                      <Chip>DOK {r.slot.dok}</Chip>
                      <Chip>b {r.slot.b >= 0 ? "+" : ""}{r.slot.b.toFixed(2)}</Chip>
                      <span style={{ flex: 1, fontSize: 13.5, color: MV.darkGray, minWidth: 120, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                        {r.item.stem}
                      </span>
                      <ChevronDown size={16} color={MV.lightGray} style={{ transform: openItem === i ? "rotate(180deg)" : "none", transition: "transform .2s" }} />
                    </div>
                    {openItem === i && (
                      <div style={{ padding: "4px 0 18px 33px", display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(240px,1fr))", gap: 18 }}>
                        <div style={{ background: MV.blackTeal, borderRadius: 9, padding: 15, fontFamily: MONO, fontSize: 11.5, lineHeight: 1.85, color: MV.lightTeal }}>
                          <div style={{ color: MV.green, marginBottom: 4 }}>SPEC — invariant</div>
                          <div>component  {r.slot.componentLabel}</div>
                          <div>dok        {r.slot.dok}</div>
                          <div>b          {r.slot.b >= 0 ? "+" : ""}{r.slot.b.toFixed(2)} logits</div>
                          <div>format     {r.slot.format}</div>
                          <div>maxScore   {r.slot.maxScore}</div>
                          <div>earned     {r.score}</div>
                        </div>
                        <div style={{ background: MV.paleBlue, borderRadius: 9, padding: 15, fontSize: 13.5, lineHeight: 1.6 }}>
                          <div style={{ fontFamily: MONO, fontSize: 11, color: MV.teal, marginBottom: 6, fontWeight: 700 }}>SKIN — “{p.theme}”</div>
                          <div style={{ marginBottom: 9 }}>{r.item.stem}</div>
                          <div style={{ color: MV.darkGray }}><strong>Response:</strong> {String(r.detail.chose || "—").slice(0, 200)}</div>
                          {r.detail.tag && r.detail.tag !== "key" && (
                            <div style={{ marginTop: 7, color: "#8a5a20" }}><strong>Diagnostic:</strong> {r.detail.tag}</div>
                          )}
                          {r.detail.evidence && (
                            <div style={{ marginTop: 7, color: MV.darkGray }}><strong>Scoring evidence:</strong> {r.detail.evidence}</div>
                          )}
                        </div>
                      </div>
                    )}
                  </div>
                ))}
              </div>

              {/* Provenance */}
              <div style={{ ...card, background: MV.cream, borderColor: MV.sand, marginBottom: 22 }}>
                <div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
                  <ClipboardList size={19} color="#8a5a20" style={{ flexShrink: 0, marginTop: 2 }} />
                  <div style={{ fontSize: 13.5, lineHeight: 1.65, color: MV.blackTeal }}>
                    <strong>Measurement provenance.</strong> The difficulty parameters here are expert-assigned from the DOK and complexity spec — they are not yet empirically calibrated against real student response data. That makes this a criterion-referenced estimate suitable for instructional decisions, not a normed score suitable for placement, retention, or evaluation. Ability is estimated by EAP, which pulls extreme scores toward the middle: a very strong or very weak student's true ability is likely slightly beyond what is reported here. Export the response record from every administration; once you have roughly 200 responses per item spec you can estimate real parameters and the scale becomes empirically anchored.
                  </div>
                </div>
              </div>

              <div className="no-print" style={{ display: "flex", gap: 11, flexWrap: "wrap" }}>
                <Btn variant="dark" onClick={() => window.print()}><Printer size={16} /> Print report</Btn>
                <Btn variant="ghost" onClick={copyRecord}>
                  {copied ? <><Check size={16} /> Copied</> : <><Copy size={16} /> Copy calibration record</>}
                </Btn>
                <Btn variant="quiet" onClick={() => reset(true)}><RotateCcw size={16} /> Next student, same blueprint</Btn>
                <Btn variant="quiet" onClick={() => reset(false)}>New standard</Btn>
              </div>
            </div>
          );
        })()}

        {phase === "results" && !report && busy && (
          <div style={{ ...card, padding: 44, textAlign: "center" }}><Spinner label={busy} /></div>
        )}
      </main>
    </div>
  );
}
