cert-coach-web 0.1.1 → 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.
@@ -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,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
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Console session store: the mutable bits that used to be module-level `let`s.
3
+ *
4
+ * Everything lives on one exported object so other modules can mutate fields
5
+ * (`store.state = s`). A module cannot reassign an imported binding, so plain
6
+ * named exports would not work here.
7
+ *
8
+ * The small pure helpers at the bottom are unit-tested in tests/spa-store.test.mjs
9
+ * because they carry the fiddly rules (multi-select toggling, timer labels,
10
+ * which card a phase maps to).
11
+ */
12
+
13
+ export const store = {
14
+ /** Latest state snapshot from the server. Null until bootstrap. */
15
+ state: null,
16
+ /** Currently selected option values. */
17
+ selected: new Set(),
18
+ /** 1 = guess, 2 = unsure, 3 = confident. */
19
+ confidence: 2,
20
+ /** Epoch ms the current question was first shown. */
21
+ startedAt: 0,
22
+ /** `${qid}:${rev}` of the question the timer belongs to. */
23
+ questionKey: "",
24
+ timerHandle: null,
25
+
26
+ eventSource: null,
27
+ theoryNodes: null,
28
+ activeTheoryNode: "",
29
+ activeView: "study",
30
+ activeLayout: "triptych",
31
+
32
+ chat: {
33
+ enabled: false,
34
+ reason: "",
35
+ busy: false,
36
+ /** { div, text } while an assistant answer streams. */
37
+ stream: null,
38
+ /** Echo suppression: the message we already rendered optimistically. */
39
+ pendingUser: "",
40
+ },
41
+ toolCards: new Map(),
42
+ };
43
+
44
+ /** New selection set for a single- or multi-select question. */
45
+ export function toggleSelection(selected, value, multi) {
46
+ const next = new Set(selected || []);
47
+ if (!multi) return new Set([value]);
48
+ if (next.has(value)) next.delete(value);
49
+ else next.add(value);
50
+ return next;
51
+ }
52
+
53
+ /** `m:ss` for a non-negative number of seconds. */
54
+ export function formatTimer(totalSeconds) {
55
+ const s = Math.max(0, Math.floor(Number(totalSeconds) || 0));
56
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
57
+ }
58
+
59
+ /**
60
+ * Timer text for a question. A timed mock counts down to its deadline;
61
+ * otherwise the timer counts up from when the question appeared.
62
+ */
63
+ export function timerLabel({ deadline = 0, startedAt = 0 }, now = Date.now()) {
64
+ if (deadline) return `${formatTimer((deadline - now) / 1000)} left`;
65
+ return formatTimer((now - startedAt) / 1000);
66
+ }
67
+
68
+ /** Stem length drives a CSS class so long question text gets a smaller font. */
69
+ export function stemClass(text) {
70
+ const len = String(text || "").length;
71
+ if (len > 420) return "stem-xlong";
72
+ if (len > 220) return "stem-long";
73
+ return "";
74
+ }
75
+
76
+ /** Human-readable mock summary, built from the loop's mock payload. */
77
+ export function mockSummary(m = {}) {
78
+ const completed = m.completed ?? m.total ?? 0;
79
+ const requested = m.requested ?? m.total ?? 0;
80
+ const score = m.correct || 0;
81
+ return {
82
+ meta: `${score}/${completed} correct · ${completed}/${requested} completed`,
83
+ title: m.score != null ? `Mock score: ${Math.round(m.score * 100)}%` : "Mock complete",
84
+ note: `${m.timedOut ? "Time expired. " : ""}Requested ${m.requested ?? "?"} questions; completed ${completed}. Feedback is shown now that the run is over. progress.json was not modified.`,
85
+ };
86
+ }
87
+
88
+ /** Which card a server phase maps to (`loading` means nothing to show yet). */
89
+ export function phaseCard(phase) {
90
+ if (phase === "question" || phase === "submitted") return "question";
91
+ if (phase === "feedback") return "feedback";
92
+ if (phase === "teach") return "teach";
93
+ if (phase === "mock_summary") return "mock";
94
+ return "idle";
95
+ }
96
+
97
+ /** True when the server says the current question takes multiple answers. */
98
+ export function isMultiSelect(state) {
99
+ return state?.q?.multi === true;
100
+ }