cert-coach-web 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,7 +52,7 @@
52
52
  <h1>Ready when you are</h1>
53
53
  <p id="idle-note" class="muted"></p>
54
54
  <div class="mock-controls">
55
- <label>Mock <select id="mock-size"><option value="10">10 questions</option><option value="20">20 questions</option><option value="65" selected>65 questions</option></select></label>
55
+ <label>Mock <select id="mock-size"><option value="10">10 questions</option><option value="20">20 questions</option><option value="45">45 questions</option><option value="65" selected>65 questions</option></select></label>
56
56
  <label>Time <select id="mock-minutes"><option value="0">untimed</option><option value="90">90 min</option><option value="130" selected>130 min</option></select></label>
57
57
  </div>
58
58
  <div class="actions">
@@ -0,0 +1,33 @@
1
+ /**
2
+ * JSON-over-HTTP helper for the console.
3
+ *
4
+ * The page is same-origin and cookie-authenticated, so this only has to handle
5
+ * the three statuses the server actually uses: 401 (expired token), 409 (state
6
+ * moved on, so reload) and everything else.
7
+ */
8
+ import { showNotice } from "./spa-ui.js";
9
+
10
+ export async function api(path, body) {
11
+ const res = await fetch(path, {
12
+ method: body === undefined ? "GET" : "POST",
13
+ credentials: "same-origin",
14
+ headers: body === undefined ? {} : { "content-type": "application/json" },
15
+ body: body === undefined ? undefined : JSON.stringify(body),
16
+ });
17
+ let data = {};
18
+ try { data = await res.json(); } catch { /* non-json */ }
19
+ if (!res.ok) {
20
+ if (res.status === 401) showNotice("Session expired. Reopen the token URL printed by the server.");
21
+ throw new HttpError(data.error || `HTTP ${res.status}`, res.status);
22
+ }
23
+ return data;
24
+ }
25
+
26
+ /** Error carrying the HTTP status so callers can react to 409 specifically. */
27
+ export class HttpError extends Error {
28
+ constructor(message, status) {
29
+ super(message);
30
+ this.name = "HttpError";
31
+ this.status = status;
32
+ }
33
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Teaching-chat panel: message log, streaming answer, tool cards, and the
3
+ * lifecycle events the server emits while a pi session answers.
4
+ *
5
+ * The server may compact a long session (chat.rotated); that surfaces here as a
6
+ * dashed "session compacted" line so a context reset is never invisible.
7
+ */
8
+ import { el } from "./spa-dom.js";
9
+ import { store } from "./spa-store.js";
10
+ import { showNotice } from "./spa-ui.js";
11
+ import { renderMarkdownInto } from "./spa-mermaid.js";
12
+ import { api } from "./spa-api.js";
13
+
14
+ function chatScroll(force = false) {
15
+ if (!el.chatLog) return;
16
+ const near = el.chatLog.scrollHeight - el.chatLog.scrollTop - el.chatLog.clientHeight < 80;
17
+ if (force || near) el.chatLog.scrollTop = el.chatLog.scrollHeight;
18
+ }
19
+
20
+ export function appendChatMessage(role, text) {
21
+ const div = document.createElement("div");
22
+ div.className = "chat-msg " + (role === "user" ? "user" : role === "system" ? "system" : "assistant");
23
+ if (role === "assistant") renderMarkdownInto(div, text);
24
+ else div.textContent = text;
25
+ el.chatLog.append(div);
26
+ chatScroll();
27
+ return div;
28
+ }
29
+
30
+ function setChatStatus(kind, text) {
31
+ el.chatStatus.className = "chat-status" + (kind === "live" ? " live" : kind === "busy" ? " busy" : "");
32
+ el.chatStatus.textContent = text;
33
+ }
34
+
35
+ /** Reflect the stored chat readiness into the composer controls. */
36
+ export function syncChatControls() {
37
+ const { enabled, reason, busy } = store.chat;
38
+ el.chatInput.disabled = !enabled;
39
+ el.chatSend.disabled = !enabled || busy;
40
+ el.chatStop.hidden = !busy;
41
+ el.chatSend.textContent = enabled ? "Send" : "Chat off";
42
+ setChatStatus(busy ? "busy" : enabled ? "live" : "off", busy ? "thinking" : enabled ? "ready" : "offline");
43
+ el.chatStatus.title = enabled ? "Teaching chat ready" : (reason || "Teaching chat disabled");
44
+ }
45
+
46
+ export function renderChatHistory(messages) {
47
+ el.chatLog.replaceChildren();
48
+ for (const m of messages || []) {
49
+ if (m.role === "user" || m.role === "assistant") appendChatMessage(m.role, m.text);
50
+ }
51
+ if (!el.chatLog.children.length) {
52
+ const empty = document.createElement("div");
53
+ empty.className = "chat-empty";
54
+ empty.textContent = store.chat.enabled
55
+ ? "Ask about the current question, a concept, or why an answer is right. The coach teaches; the quiz card grades."
56
+ : (store.chat.reason || "Teaching chat is disabled. Start the server with --pi <pi|path> or set PI_BIN.");
57
+ el.chatLog.append(empty);
58
+ }
59
+ }
60
+
61
+ function renderTool(event) {
62
+ let card = store.toolCards.get(event.id);
63
+ if (event.status === "start" || !card) {
64
+ card = document.createElement("div");
65
+ card.className = "chat-tool";
66
+ const head = document.createElement("div");
67
+ head.className = "tool-head";
68
+ const name = document.createElement("span");
69
+ name.textContent = event.name || "tool";
70
+ const state = document.createElement("span");
71
+ state.className = "muted";
72
+ head.append(name, state);
73
+ const body = document.createElement("pre");
74
+ body.className = "tool-body";
75
+ card.append(head, body);
76
+ el.chatLog.append(card);
77
+ store.toolCards.set(event.id, card);
78
+ }
79
+ const state = card.querySelector(".tool-head .muted");
80
+ const body = card.querySelector(".tool-body");
81
+ if (event.status === "end") {
82
+ state.textContent = event.isError ? "error" : "done";
83
+ if (event.isError) card.classList.add("error");
84
+ body.textContent = (event.text || "").slice(0, 3000);
85
+ } else {
86
+ state.textContent = event.status === "start" ? "running" : "…";
87
+ if (event.partial) body.textContent = JSON.stringify(event.partial).slice(0, 1000);
88
+ else if (event.args) body.textContent = JSON.stringify(event.args).slice(0, 1000);
89
+ }
90
+ chatScroll();
91
+ }
92
+
93
+ export function handleChatEvent(event) {
94
+ const chat = store.chat;
95
+ switch (event.type) {
96
+ case "chat.user": {
97
+ if (chat.pendingUser && chat.pendingUser === event.text) chat.pendingUser = "";
98
+ else appendChatMessage("user", event.text);
99
+ break;
100
+ }
101
+ case "chat.start": {
102
+ chat.busy = true;
103
+ syncChatControls();
104
+ chat.stream = { div: appendChatMessage("assistant", ""), text: "" };
105
+ break;
106
+ }
107
+ case "chat.delta": {
108
+ if (!chat.stream) chat.stream = { div: appendChatMessage("assistant", ""), text: "" };
109
+ chat.stream.text += event.text || "";
110
+ chat.stream.div.textContent = chat.stream.text;
111
+ chatScroll();
112
+ break;
113
+ }
114
+ case "chat.message": {
115
+ if (!chat.stream) chat.stream = { div: appendChatMessage("assistant", ""), text: "" };
116
+ renderMarkdownInto(chat.stream.div, event.text || "");
117
+ chat.stream = null;
118
+ chatScroll();
119
+ break;
120
+ }
121
+ case "chat.tool": {
122
+ renderTool(event);
123
+ break;
124
+ }
125
+ case "chat.done": {
126
+ chat.busy = false;
127
+ syncChatControls();
128
+ chat.stream = null;
129
+ break;
130
+ }
131
+ case "chat.error": {
132
+ chat.busy = false;
133
+ chat.stream = null;
134
+ syncChatControls();
135
+ showNotice(event.error || "Chat failed.");
136
+ break;
137
+ }
138
+ case "chat.rotated": {
139
+ // The server compacted the session before it ran out of context.
140
+ appendChatMessage(
141
+ "system",
142
+ event.reason === "context-overflow"
143
+ ? "Context limit reached - earlier turns were summarised. Keep going."
144
+ : "Long session - starting a fresh context with a recap of earlier turns."
145
+ );
146
+ break;
147
+ }
148
+ default:
149
+ break;
150
+ }
151
+ }
152
+
153
+ export async function sendChat() {
154
+ const text = el.chatInput.value.trim();
155
+ if (!text || !store.chat.enabled || store.chat.busy) return;
156
+ el.chatInput.value = "";
157
+ store.chat.pendingUser = text;
158
+ appendChatMessage("user", text);
159
+ store.chat.busy = true;
160
+ syncChatControls();
161
+ try {
162
+ const res = await api("/api/prompt", { text });
163
+ if (res.disposition === "handled") {
164
+ store.chat.busy = false;
165
+ syncChatControls();
166
+ }
167
+ } catch (e) {
168
+ store.chat.busy = false;
169
+ store.chat.pendingUser = "";
170
+ syncChatControls();
171
+ showNotice(e.message);
172
+ }
173
+ }
174
+
175
+ export async function abortChat() {
176
+ try {
177
+ await api("/api/abort", {});
178
+ } catch (e) {
179
+ showNotice(e.message);
180
+ }
181
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * DOM handles for the study console.
3
+ *
4
+ * One lookup table so no module has to know element ids. Modules are loaded as
5
+ * ES modules from a deferred <script type="module">, so the document is parsed
6
+ * by the time this runs.
7
+ */
8
+ export const $ = (id) => document.getElementById(id);
9
+
10
+ export const el = {
11
+ app: $("app"),
12
+ certLine: $("cert-line"),
13
+ certSelect: $("cert-select"),
14
+ certNew: $("cert-new"),
15
+ phasePill: $("phase-pill"),
16
+ readiness: $("readiness"),
17
+ connection: $("connection"),
18
+ notice: $("notice"),
19
+ loading: $("loading"),
20
+ idle: $("idle-card"),
21
+ idleNote: $("idle-note"),
22
+ start: $("start"),
23
+ startMock: $("start-mock"),
24
+ startProbe: $("start-probe"),
25
+ mockSize: $("mock-size"),
26
+ mockMinutes: $("mock-minutes"),
27
+ question: $("question-card"),
28
+ qmeta: $("qmeta"),
29
+ timer: $("timer"),
30
+ stem: $("stem"),
31
+ questionNote: $("question-note"),
32
+ images: $("images"),
33
+ options: $("options"),
34
+ confidenceButtons: $("confidence-buttons"),
35
+ submit: $("submit"),
36
+ feedback: $("feedback-card"),
37
+ verdict: $("verdict"),
38
+ feedbackMeta: $("feedback-meta"),
39
+ feedbackTitle: $("feedback-title"),
40
+ answerLine: $("answer-line"),
41
+ explanation: $("explanation"),
42
+ source: $("source"),
43
+ next: $("next"),
44
+ teach: $("teach-card"),
45
+ teachMeta: $("teach-meta"),
46
+ teachTitle: $("teach-title"),
47
+ teachSummary: $("teach-summary"),
48
+ teachSection: $("teach-section"),
49
+ teachDoc: $("teach-doc"),
50
+ teachNext: $("teach-next"),
51
+ mock: $("mock-card"),
52
+ mockMeta: $("mock-meta"),
53
+ mockTitle: $("mock-title"),
54
+ mockNote: $("mock-note"),
55
+ mockResults: $("mock-results"),
56
+ mockNew: $("mock-new"),
57
+ mockPractice: $("mock-practice"),
58
+ statusLog: $("status-log"),
59
+ chatLog: $("chat-log"),
60
+ chatForm: $("chat-form"),
61
+ chatInput: $("chat-input"),
62
+ chatSend: $("chat-send"),
63
+ chatStop: $("chat-stop"),
64
+ chatStatus: $("chat-status"),
65
+ nav: document.querySelector(".nav"),
66
+ sideNav: document.querySelector(".side-nav"),
67
+ layoutButtons: [...document.querySelectorAll("[data-app-layout]")],
68
+ cmdReadiness: $("cmd-readiness"),
69
+ cmdCoverage: $("cmd-coverage"),
70
+ cmdDue: $("cmd-due"),
71
+ cmdWeak: $("cmd-weak"),
72
+ cmdWeakNote: $("cmd-weak-note"),
73
+ cmdReadinessBar: $("cmd-readiness-bar"),
74
+ cmdCoverageBar: $("cmd-coverage-bar"),
75
+ cmdDomains: $("cmd-domains"),
76
+ studyView: $("study-view"),
77
+ pathView: $("path-view"),
78
+ pathMeta: $("path-meta"),
79
+ pathRoute: $("path-route"),
80
+ pathDetail: $("path-detail"),
81
+ dashboardView: $("dashboard-view"),
82
+ theoryView: $("theory-view"),
83
+ logsView: $("logs-view"),
84
+ dashboardBody: $("dashboard-body"),
85
+ dashboardMeta: $("dashboard-meta"),
86
+ theoryNodes: $("theory-nodes"),
87
+ theoryBody: $("theory-body"),
88
+ logsBody: $("logs-body"),
89
+ };
@@ -0,0 +1,20 @@
1
+ // Pure, DOM-free SPA helpers. Kept in a module so they are unit-testable in
2
+ // node (app.js itself touches `document` at load, so it cannot be imported).
3
+
4
+ /** Allow only http(s) URLs; anything else (javascript:, data:, relative) -> "". */
5
+ export function safeHttpUrl(value) {
6
+ try {
7
+ const u = new URL(value);
8
+ return u.protocol === "http:" || u.protocol === "https:" ? u.href : "";
9
+ } catch {
10
+ return "";
11
+ }
12
+ }
13
+
14
+ /** Remote exhibits go through the guarded proxy; local /assets/ pass through. */
15
+ export function imageSrc(im) {
16
+ const src = String(im?.src || "");
17
+ if (/^https?:\/\//i.test(src)) return "/api/asset?src=" + encodeURIComponent(src);
18
+ if (src.startsWith("/assets/")) return src;
19
+ return "";
20
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Markdown + Mermaid rendering.
3
+ *
4
+ * Mermaid is a ~1 MB vendor bundle, so it is injected on first use rather than
5
+ * shipped in the page. Failures degrade to plain text: the code block stays
6
+ * readable, it just does not become a diagram.
7
+ */
8
+ import { markdownFragment } from "./spa-render.js";
9
+
10
+ let mermaidSeq = 0;
11
+ let mermaidLoading = null;
12
+
13
+ export function ensureMermaid() {
14
+ if (window.mermaid) return Promise.resolve(window.mermaid);
15
+ if (!mermaidLoading) {
16
+ mermaidLoading = new Promise((resolve, reject) => {
17
+ const script = document.createElement("script");
18
+ script.src = "/vendor/mermaid.min.js";
19
+ script.async = true;
20
+ script.onload = () => {
21
+ if (!window.mermaid) return reject(new Error("Mermaid failed to initialize"));
22
+ window.mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "dark" });
23
+ resolve(window.mermaid);
24
+ };
25
+ script.onerror = () => reject(new Error("Mermaid failed to load"));
26
+ document.head.append(script);
27
+ });
28
+ }
29
+ return mermaidLoading;
30
+ }
31
+
32
+ export async function renderMermaidInto(container) {
33
+ if (!container || !container.querySelector("pre.diagram")) return;
34
+ try { await ensureMermaid(); } catch { return; }
35
+ for (const pre of container.querySelectorAll("pre.diagram")) {
36
+ if (pre.dataset.rendered === "1") continue;
37
+ const code = pre.textContent || "";
38
+ if (!code.trim()) continue;
39
+ try {
40
+ const { svg } = await window.mermaid.render("mmd" + (++mermaidSeq), code);
41
+ pre.innerHTML = svg;
42
+ pre.dataset.rendered = "1";
43
+ pre.classList.add("rendered");
44
+ } catch {
45
+ pre.classList.add("mermaid-error");
46
+ }
47
+ }
48
+ }
49
+
50
+ /** Render markdown into a node, then upgrade any mermaid blocks. */
51
+ export function renderMarkdownInto(node, text) {
52
+ node.replaceChildren(markdownFragment(document, text));
53
+ void renderMermaidInto(node);
54
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Study cards: question, feedback, teach, mock summary, idle.
3
+ *
4
+ * Owns everything about *showing* a state snapshot, including the answer timer
5
+ * and selection UI. It never calls the server; the wiring layer does that.
6
+ */
7
+ import { el } from "./spa-dom.js";
8
+ import { store, toggleSelection, timerLabel, stemClass, mockSummary, phaseCard, isMultiSelect } from "./spa-store.js";
9
+ import { only, setPhase } from "./spa-ui.js";
10
+ import { renderMarkdownInto } from "./spa-mermaid.js";
11
+ import { safeHttpUrl, imageSrc } from "./spa-logic.js";
12
+ import { updateCommandStrip } from "./spa-views.js";
13
+
14
+ function renderImages(images) {
15
+ el.images.replaceChildren();
16
+ for (const im of images || []) {
17
+ const src = imageSrc(im);
18
+ if (!src) continue;
19
+ const fig = document.createElement("figure");
20
+ fig.className = "exhibit";
21
+ const img = document.createElement("img");
22
+ img.src = src;
23
+ img.alt = im.label || "exhibit";
24
+ img.loading = "lazy";
25
+ img.addEventListener("click", () => window.open(img.src, "_blank", "noopener"));
26
+ const cap = document.createElement("figcaption");
27
+ cap.textContent = im.label || "exhibit";
28
+ fig.append(img, cap);
29
+ el.images.append(fig);
30
+ }
31
+ el.images.hidden = !el.images.children.length;
32
+ }
33
+
34
+ function renderOptions(q, readOnly) {
35
+ el.options.replaceChildren();
36
+ for (const opt of q.options || []) {
37
+ const b = document.createElement("button");
38
+ b.type = "button";
39
+ b.className = "option";
40
+ b.dataset.value = opt.value;
41
+ b.disabled = readOnly;
42
+ b.setAttribute("aria-pressed", store.selected.has(opt.value) ? "true" : "false");
43
+ if (store.selected.has(opt.value)) b.classList.add("selected");
44
+ const key = document.createElement("span");
45
+ key.className = "key";
46
+ key.textContent = opt.value;
47
+ const text = document.createElement("span");
48
+ text.textContent = opt.text || "(image option)";
49
+ b.append(key, text);
50
+ b.addEventListener("click", () => toggleOption(opt.value));
51
+ el.options.append(b);
52
+ }
53
+ }
54
+
55
+ export function toggleOption(value) {
56
+ if (!store.state || store.state.phase !== "question" || !store.state.q) return;
57
+ store.selected = toggleSelection(store.selected, value, isMultiSelect(store.state));
58
+ updateSelectionUI();
59
+ }
60
+
61
+ export function updateSelectionUI() {
62
+ const multi = isMultiSelect(store.state);
63
+ for (const b of el.options.querySelectorAll(".option")) {
64
+ const on = store.selected.has(b.dataset.value);
65
+ b.classList.toggle("selected", on);
66
+ b.setAttribute("aria-pressed", on ? "true" : "false");
67
+ }
68
+ for (const b of el.confidenceButtons.querySelectorAll("button")) {
69
+ b.classList.toggle("active", Number(b.dataset.confidence) === store.confidence);
70
+ }
71
+ el.submit.disabled = !(store.state?.phase === "question" && store.selected.size > 0);
72
+ el.submit.textContent = multi ? "Submit selection" : "Submit answer";
73
+ }
74
+
75
+ function startTimer(key) {
76
+ if (store.timerHandle) clearInterval(store.timerHandle);
77
+ store.timerHandle = null;
78
+ if (key !== store.questionKey) {
79
+ store.questionKey = key;
80
+ store.startedAt = Date.now();
81
+ store.confidence = 2;
82
+ store.selected = new Set();
83
+ }
84
+ const tick = () => {
85
+ if (!store.state || store.state.phase !== "question") return;
86
+ el.timer.textContent = timerLabel({
87
+ deadline: Number(store.state.meta?.mock?.deadline) || 0,
88
+ startedAt: store.startedAt,
89
+ });
90
+ };
91
+ tick();
92
+ store.timerHandle = setInterval(tick, 1000);
93
+ }
94
+
95
+ function stopTimer() {
96
+ if (store.timerHandle) clearInterval(store.timerHandle);
97
+ store.timerHandle = null;
98
+ if (store.state) el.timer.textContent = "";
99
+ }
100
+
101
+ function setStemSize(node, text) {
102
+ node.classList.remove("stem-long", "stem-xlong");
103
+ const cls = stemClass(text);
104
+ if (cls) node.classList.add(cls);
105
+ }
106
+
107
+ function renderQuestion(s, readOnly) {
108
+ only(el.question);
109
+ const q = s.q || {};
110
+ setPhase(s.phase);
111
+ const mockMeta = s.meta?.mock ? `mock ${(s.meta.mock.answered || 0) + 1}/${s.meta.mock.total || "?"}` : "";
112
+ const probeMeta = s.meta?.probe ? `probe ${s.meta.probe.answered || 1}/${s.meta.probe.total || 20}` : "";
113
+ el.qmeta.textContent = [q.domain, q.node, q.index ? `#${q.index}` : "", mockMeta || probeMeta].filter(Boolean).join(" · ");
114
+ el.stem.textContent = q.stem || "";
115
+ setStemSize(el.stem, q.stem || "");
116
+ el.questionNote.hidden = !readOnly;
117
+ el.questionNote.textContent = readOnly ? "Submitted; grading..." : "";
118
+ renderImages(q.images);
119
+ renderOptions(q, readOnly);
120
+ updateSelectionUI();
121
+ if (!readOnly) startTimer(`${q.id}:${s.rev}`);
122
+ else stopTimer();
123
+ }
124
+
125
+ function renderFeedback(s) {
126
+ only(el.feedback);
127
+ setPhase(s.phase);
128
+ stopTimer();
129
+ const f = s.feedback || {};
130
+ el.verdict.textContent = f.correct ? "Correct" : "Incorrect";
131
+ el.verdict.className = "verdict " + (f.correct ? "good" : "bad");
132
+ el.feedbackMeta.textContent = [f.box != null ? `box ${f.box}` : "", f.due ? `due ${String(f.due).slice(0, 10)}` : "", s.meta?.readiness || ""].filter(Boolean).join(" · ");
133
+ el.feedbackTitle.textContent = s.q?.stem || "Feedback";
134
+ setStemSize(el.feedbackTitle, s.q?.stem || "Feedback");
135
+ el.answerLine.textContent = `Answer: ${(f.answer || []).join(", ") || "?"} · you: ${(f.selected || []).join(", ") || "?"}`;
136
+ el.explanation.textContent = f.explanation || "";
137
+ const src = safeHttpUrl(f.source || "");
138
+ el.source.hidden = !src;
139
+ el.source.href = src || "#";
140
+ el.source.textContent = src || "";
141
+ }
142
+
143
+ function renderTeach(s) {
144
+ only(el.teach);
145
+ setPhase(s.phase);
146
+ stopTimer();
147
+ const t = s.teach || {};
148
+ el.teachMeta.textContent = [t.domain, t.reason].filter(Boolean).join(" · ");
149
+ el.teachTitle.textContent = t.title || t.node || "Teach";
150
+ el.teachSummary.textContent = t.summary || "";
151
+ el.teachTitle.classList.remove("stem-long", "stem-xlong");
152
+ el.teachSection.replaceChildren();
153
+ renderMarkdownInto(el.teachSection, t.section || "");
154
+ const doc = safeHttpUrl(t.docUrl || "");
155
+ el.teachDoc.hidden = !doc;
156
+ el.teachDoc.href = doc || "#";
157
+ el.teachDoc.textContent = doc || "";
158
+ }
159
+
160
+ function renderMock(s) {
161
+ only(el.mock);
162
+ setPhase("mock_summary");
163
+ stopTimer();
164
+ const m = s.mock || {};
165
+ const text = mockSummary(m);
166
+ el.mockMeta.textContent = text.meta;
167
+ el.mockTitle.textContent = text.title;
168
+ el.mockNote.textContent = text.note;
169
+ el.mockResults.replaceChildren();
170
+ for (const a of m.answers || []) {
171
+ const row = document.createElement("div");
172
+ row.className = "mock-row";
173
+ const mark = document.createElement("span");
174
+ mark.className = a.correct ? "ok" : "bad";
175
+ mark.textContent = a.correct ? "OK" : "MISS";
176
+ const q = document.createElement("span");
177
+ q.className = "q";
178
+ q.textContent = `${a.qid} · ${a.domain || ""}/${a.node || ""} · you ${(a.selected || []).join(",")} · answer ${(a.answer || []).join(",")}`;
179
+ const secs = document.createElement("span");
180
+ secs.className = "muted";
181
+ secs.textContent = a.seconds != null ? `${a.seconds}s` : "";
182
+ row.append(mark, q, secs);
183
+ el.mockResults.append(row);
184
+ }
185
+ }
186
+
187
+ function renderIdle(s) {
188
+ only(el.idle);
189
+ setPhase("idle");
190
+ stopTimer();
191
+ el.idleNote.textContent = s.meta?.note || "Start a practice run when you are ready.";
192
+ }
193
+
194
+ /** Render a state snapshot into the study column. */
195
+ export function renderState(s) {
196
+ store.state = s;
197
+ el.certLine.textContent = `${s.cert || "cert"} · ${s.mode || "practice"}`;
198
+ el.readiness.textContent = s.meta?.readiness ? `readiness ${s.meta.readiness}` : "";
199
+ const card = phaseCard(s.phase);
200
+ if (card === "question") renderQuestion(s, s.phase === "submitted");
201
+ else if (card === "feedback") renderFeedback(s);
202
+ else if (card === "teach") renderTeach(s);
203
+ else if (card === "mock") renderMock(s);
204
+ else renderIdle(s);
205
+ if (store.activeLayout === "command") void updateCommandStrip();
206
+ }