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.
package/web/public/app.js CHANGED
@@ -1,443 +1,21 @@
1
- const $ = (id) => document.getElementById(id);
2
-
3
- const el = {
4
- app: $("app"),
5
- certLine: $("cert-line"),
6
- certSelect: $("cert-select"),
7
- certNew: $("cert-new"),
8
- phasePill: $("phase-pill"),
9
- readiness: $("readiness"),
10
- connection: $("connection"),
11
- notice: $("notice"),
12
- loading: $("loading"),
13
- idle: $("idle-card"),
14
- idleNote: $("idle-note"),
15
- start: $("start"),
16
- startMock: $("start-mock"),
17
- startProbe: $("start-probe"),
18
- mockSize: $("mock-size"),
19
- mockMinutes: $("mock-minutes"),
20
- question: $("question-card"),
21
- qmeta: $("qmeta"),
22
- timer: $("timer"),
23
- stem: $("stem"),
24
- questionNote: $("question-note"),
25
- images: $("images"),
26
- options: $("options"),
27
- confidenceButtons: $("confidence-buttons"),
28
- submit: $("submit"),
29
- feedback: $("feedback-card"),
30
- verdict: $("verdict"),
31
- feedbackMeta: $("feedback-meta"),
32
- feedbackTitle: $("feedback-title"),
33
- answerLine: $("answer-line"),
34
- explanation: $("explanation"),
35
- source: $("source"),
36
- next: $("next"),
37
- teach: $("teach-card"),
38
- teachMeta: $("teach-meta"),
39
- teachTitle: $("teach-title"),
40
- teachSummary: $("teach-summary"),
41
- teachSection: $("teach-section"),
42
- teachDoc: $("teach-doc"),
43
- teachNext: $("teach-next"),
44
- mock: $("mock-card"),
45
- mockMeta: $("mock-meta"),
46
- mockTitle: $("mock-title"),
47
- mockNote: $("mock-note"),
48
- mockResults: $("mock-results"),
49
- mockNew: $("mock-new"),
50
- mockPractice: $("mock-practice"),
51
- statusLog: $("status-log"),
52
- chatLog: $("chat-log"),
53
- chatForm: $("chat-form"),
54
- chatInput: $("chat-input"),
55
- chatSend: $("chat-send"),
56
- chatStop: $("chat-stop"),
57
- chatStatus: $("chat-status"),
58
- nav: document.querySelector(".nav"),
59
- sideNav: document.querySelector(".side-nav"),
60
- layoutButtons: [...document.querySelectorAll("[data-app-layout]")],
61
- cmdReadiness: $("cmd-readiness"),
62
- cmdCoverage: $("cmd-coverage"),
63
- cmdDue: $("cmd-due"),
64
- cmdWeak: $("cmd-weak"),
65
- cmdWeakNote: $("cmd-weak-note"),
66
- cmdReadinessBar: $("cmd-readiness-bar"),
67
- cmdCoverageBar: $("cmd-coverage-bar"),
68
- cmdDomains: $("cmd-domains"),
69
- studyView: $("study-view"),
70
- pathView: $("path-view"),
71
- pathMeta: $("path-meta"),
72
- pathRoute: $("path-route"),
73
- pathDetail: $("path-detail"),
74
- dashboardView: $("dashboard-view"),
75
- theoryView: $("theory-view"),
76
- logsView: $("logs-view"),
77
- dashboardBody: $("dashboard-body"),
78
- dashboardMeta: $("dashboard-meta"),
79
- theoryNodes: $("theory-nodes"),
80
- theoryBody: $("theory-body"),
81
- logsBody: $("logs-body"),
82
- };
83
-
84
- let state = null;
85
- let selected = new Set();
86
- let confidence = 2;
87
- let startedAt = 0;
88
- let questionKey = "";
89
- let timerHandle = null;
90
- let eventSource = null;
91
- let chatEnabled = false;
92
- let chatReason = "";
93
- let chatBusy = false;
94
- let chatStream = null;
95
- let pendingUser = "";
96
- const toolCards = new Map();
97
- let mermaidSeq = 0;
98
- let mermaidLoading = null;
99
-
100
- function ensureMermaid() {
101
- if (window.mermaid) return Promise.resolve(window.mermaid);
102
- if (!mermaidLoading) {
103
- mermaidLoading = new Promise((resolve, reject) => {
104
- const script = document.createElement("script");
105
- script.src = "/vendor/mermaid.min.js";
106
- script.async = true;
107
- script.onload = () => {
108
- if (!window.mermaid) return reject(new Error("Mermaid failed to initialize"));
109
- window.mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "dark" });
110
- resolve(window.mermaid);
111
- };
112
- script.onerror = () => reject(new Error("Mermaid failed to load"));
113
- document.head.append(script);
114
- });
115
- }
116
- return mermaidLoading;
117
- }
118
-
119
- async function renderMermaidInto(container) {
120
- if (!container || !container.querySelector("pre.diagram")) return;
121
- try { await ensureMermaid(); } catch { return; }
122
- for (const pre of container.querySelectorAll("pre.diagram")) {
123
- if (pre.dataset.rendered === "1") continue;
124
- const code = pre.textContent || "";
125
- if (!code.trim()) continue;
126
- try {
127
- const { svg } = await window.mermaid.render("mmd" + (++mermaidSeq), code);
128
- pre.innerHTML = svg;
129
- pre.dataset.rendered = "1";
130
- pre.classList.add("rendered");
131
- } catch {
132
- pre.classList.add("mermaid-error");
133
- }
134
- }
135
- }
136
- let theoryNodesCache = null;
137
- let activeTheoryNode = "";
138
- let activeView = "study";
139
- let activeLayout = "triptych";
140
-
141
- function setConnection(kind) {
142
- el.connection.classList.remove("live", "down");
143
- if (kind === "live") el.connection.classList.add("live");
144
- if (kind === "down") el.connection.classList.add("down");
145
- el.connection.title = kind === "live" ? "live" : kind === "down" ? "reconnecting" : "connecting";
146
- }
147
-
148
- function showNotice(text) {
149
- if (!text) {
150
- el.notice.hidden = true;
151
- el.notice.textContent = "";
152
- return;
153
- }
154
- el.notice.hidden = false;
155
- el.notice.textContent = text;
156
- }
157
-
158
- function only(card) {
159
- for (const c of [el.loading, el.idle, el.question, el.feedback, el.teach, el.mock]) {
160
- if (c) c.hidden = c !== card;
161
- }
162
- }
163
-
164
- function setPhase(phase) {
165
- el.phasePill.textContent = phase || "idle";
166
- }
167
-
168
- function safeHttpUrl(value) {
169
- try {
170
- const u = new URL(value);
171
- return u.protocol === "http:" || u.protocol === "https:" ? u.href : "";
172
- } catch {
173
- return "";
174
- }
175
- }
176
-
177
- function imageSrc(im) {
178
- const src = String(im?.src || "");
179
- if (/^https?:\/\//i.test(src)) return "/api/asset?src=" + encodeURIComponent(src);
180
- if (src.startsWith("/assets/")) return src;
181
- return "";
182
- }
183
-
184
- function renderImages(images) {
185
- el.images.replaceChildren();
186
- for (const im of images || []) {
187
- const src = imageSrc(im);
188
- if (!src) continue;
189
- const fig = document.createElement("figure");
190
- fig.className = "exhibit";
191
- const img = document.createElement("img");
192
- img.src = src;
193
- img.alt = im.label || "exhibit";
194
- img.loading = "lazy";
195
- img.addEventListener("click", () => window.open(img.src, "_blank", "noopener"));
196
- const cap = document.createElement("figcaption");
197
- cap.textContent = im.label || "exhibit";
198
- fig.append(img, cap);
199
- el.images.append(fig);
200
- }
201
- el.images.hidden = !el.images.children.length;
202
- }
203
-
204
- function renderOptions(q, readOnly) {
205
- el.options.replaceChildren();
206
- for (const opt of q.options || []) {
207
- const b = document.createElement("button");
208
- b.type = "button";
209
- b.className = "option";
210
- b.dataset.value = opt.value;
211
- b.disabled = readOnly;
212
- b.setAttribute("aria-pressed", selected.has(opt.value) ? "true" : "false");
213
- if (selected.has(opt.value)) b.classList.add("selected");
214
- const key = document.createElement("span");
215
- key.className = "key";
216
- key.textContent = opt.value;
217
- const text = document.createElement("span");
218
- text.textContent = opt.text || "(image option)";
219
- b.append(key, text);
220
- b.addEventListener("click", () => toggleOption(opt.value));
221
- el.options.append(b);
222
- }
223
- }
224
-
225
- function toggleOption(value) {
226
- if (!state || state.phase !== "question" || !state.q) return;
227
- if (state.q.multi) {
228
- if (selected.has(value)) selected.delete(value);
229
- else selected.add(value);
230
- } else {
231
- selected = new Set([value]);
232
- }
233
- updateSelectionUI();
234
- }
235
-
236
- function updateSelectionUI() {
237
- const multi = state?.q?.multi === true;
238
- for (const b of el.options.querySelectorAll(".option")) {
239
- const on = selected.has(b.dataset.value);
240
- b.classList.toggle("selected", on);
241
- b.setAttribute("aria-pressed", on ? "true" : "false");
242
- }
243
- for (const b of el.confidenceButtons.querySelectorAll("button")) {
244
- b.classList.toggle("active", Number(b.dataset.confidence) === confidence);
245
- }
246
- el.submit.disabled = !(state?.phase === "question" && selected.size > 0);
247
- el.submit.textContent = multi ? "Submit selection" : "Submit answer";
248
- }
249
-
250
- function startTimer(key) {
251
- if (timerHandle) clearInterval(timerHandle);
252
- timerHandle = null;
253
- if (key !== questionKey) {
254
- questionKey = key;
255
- startedAt = Date.now();
256
- confidence = 2;
257
- selected = new Set();
258
- }
259
- const tick = () => {
260
- if (!state || state.phase !== "question") return;
261
- const deadline = Number(state.meta?.mock?.deadline) || 0;
262
- if (deadline) {
263
- const left = Math.max(0, Math.floor((deadline - Date.now()) / 1000));
264
- el.timer.textContent = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")} left`;
265
- return;
266
- }
267
- const s = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
268
- el.timer.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
269
- };
270
- tick();
271
- timerHandle = setInterval(tick, 1000);
272
- }
273
-
274
- function stopTimer() {
275
- if (timerHandle) clearInterval(timerHandle);
276
- timerHandle = null;
277
- if (state) el.timer.textContent = "";
278
- }
279
-
280
- function setStemSize(node, text) {
281
- const len = String(text || "").length;
282
- node.classList.remove("stem-long", "stem-xlong");
283
- if (len > 420) node.classList.add("stem-xlong");
284
- else if (len > 220) node.classList.add("stem-long");
285
- }
286
-
287
- function renderQuestion(s, readOnly) {
288
- only(el.question);
289
- const q = s.q || {};
290
- setPhase(s.phase);
291
- const mockMeta = s.meta?.mock ? `mock ${(s.meta.mock.answered || 0) + 1}/${s.meta.mock.total || "?"}` : "";
292
- const probeMeta = s.meta?.probe ? `probe ${s.meta.probe.answered || 1}/${s.meta.probe.total || 20}` : "";
293
- el.qmeta.textContent = [q.domain, q.node, q.index ? `#${q.index}` : "", mockMeta || probeMeta].filter(Boolean).join(" · ");
294
- el.stem.textContent = q.stem || "";
295
- setStemSize(el.stem, q.stem || "");
296
- el.questionNote.hidden = !readOnly;
297
- el.questionNote.textContent = readOnly ? "Submitted; grading..." : "";
298
- renderImages(q.images);
299
- renderOptions(q, readOnly);
300
- updateSelectionUI();
301
- if (!readOnly) startTimer(`${q.id}:${s.rev}`);
302
- else stopTimer();
303
- }
304
-
305
- function renderFeedback(s) {
306
- only(el.feedback);
307
- setPhase(s.phase);
308
- stopTimer();
309
- const f = s.feedback || {};
310
- el.verdict.textContent = f.correct ? "Correct" : "Incorrect";
311
- el.verdict.className = "verdict " + (f.correct ? "good" : "bad");
312
- el.feedbackMeta.textContent = [f.box != null ? `box ${f.box}` : "", f.due ? `due ${String(f.due).slice(0, 10)}` : "", s.meta?.readiness || ""].filter(Boolean).join(" · ");
313
- el.feedbackTitle.textContent = s.q?.stem || "Feedback";
314
- setStemSize(el.feedbackTitle, s.q?.stem || "Feedback");
315
- el.answerLine.textContent = `Answer: ${(f.answer || []).join(", ") || "?"} · you: ${(f.selected || []).join(", ") || "?"}`;
316
- el.explanation.textContent = f.explanation || "";
317
- const src = safeHttpUrl(f.source || "");
318
- el.source.hidden = !src;
319
- el.source.href = src || "#";
320
- el.source.textContent = src || "";
321
- }
322
-
323
- function renderTeach(s) {
324
- only(el.teach);
325
- setPhase(s.phase);
326
- stopTimer();
327
- const t = s.teach || {};
328
- el.teachMeta.textContent = [t.domain, t.reason].filter(Boolean).join(" · ");
329
- el.teachTitle.textContent = t.title || t.node || "Teach";
330
- el.teachSummary.textContent = t.summary || "";
331
- el.teachTitle.classList.remove("stem-long", "stem-xlong");
332
- el.teachSection.replaceChildren();
333
- renderMarkdownInto(el.teachSection, t.section || "");
334
- const doc = safeHttpUrl(t.docUrl || "");
335
- el.teachDoc.hidden = !doc;
336
- el.teachDoc.href = doc || "#";
337
- el.teachDoc.textContent = doc || "";
338
- }
339
-
340
- function renderMock(s) {
341
- only(el.mock);
342
- setPhase("mock_summary");
343
- stopTimer();
344
- const m = s.mock || {};
345
- el.mockMeta.textContent = `${m.correct || 0}/${m.completed ?? m.total ?? 0} correct · ${m.completed ?? m.total ?? 0}/${m.requested ?? m.total ?? 0} completed`;
346
- el.mockTitle.textContent = m.score != null ? `Mock score: ${Math.round(m.score * 100)}%` : "Mock complete";
347
- el.mockNote.textContent = `${m.timedOut ? "Time expired. " : ""}Requested ${m.requested ?? "?"} questions; completed ${m.completed ?? m.total ?? 0}. Feedback is shown now that the run is over. progress.json was not modified.`;
348
- el.mockResults.replaceChildren();
349
- for (const a of m.answers || []) {
350
- const row = document.createElement("div");
351
- row.className = "mock-row";
352
- const mark = document.createElement("span");
353
- mark.className = a.correct ? "ok" : "bad";
354
- mark.textContent = a.correct ? "OK" : "MISS";
355
- const q = document.createElement("span");
356
- q.className = "q";
357
- q.textContent = `${a.qid} · ${a.domain || ""}/${a.node || ""} · you ${(a.selected || []).join(",")} · answer ${(a.answer || []).join(",")}`;
358
- const secs = document.createElement("span");
359
- secs.className = "muted";
360
- secs.textContent = a.seconds != null ? `${a.seconds}s` : "";
361
- row.append(mark, q, secs);
362
- el.mockResults.append(row);
363
- }
364
- }
365
-
366
- function renderIdle(s) {
367
- only(el.idle);
368
- setPhase("idle");
369
- stopTimer();
370
- el.idleNote.textContent = s.meta?.note || "Start a practice run when you are ready.";
371
- }
372
-
373
- function render(s) {
374
- state = s;
375
- showNotice("");
376
- el.certLine.textContent = `${s.cert || "cert"} · ${s.mode || "practice"}`;
377
- el.readiness.textContent = s.meta?.readiness ? `readiness ${s.meta.readiness}` : "";
378
- const phase = s.phase || "idle";
379
- if (phase === "question") renderQuestion(s, false);
380
- else if (phase === "submitted") renderQuestion(s, true);
381
- else if (phase === "feedback") renderFeedback(s);
382
- else if (phase === "teach") renderTeach(s);
383
- else if (phase === "mock_summary") renderMock(s);
384
- else renderIdle(s);
385
- if (activeLayout === "command") void updateCommandStrip();
386
- }
387
-
388
- async function api(path, body) {
389
- const res = await fetch(path, {
390
- method: body === undefined ? "GET" : "POST",
391
- credentials: "same-origin",
392
- headers: body === undefined ? {} : { "content-type": "application/json" },
393
- body: body === undefined ? undefined : JSON.stringify(body),
394
- });
395
- let data = {};
396
- try { data = await res.json(); } catch { /* non-json */ }
397
- if (!res.ok) {
398
- if (res.status === 401) showNotice("Session expired. Reopen the token URL printed by the server.");
399
- if (res.status === 409) await refresh();
400
- throw new Error(data.error || `HTTP ${res.status}`);
401
- }
402
- return data;
403
- }
1
+ /**
2
+ * Study console wiring: boot, fetch, submit/next, event stream, and input
3
+ * binding. Presentation lives in the sibling spa-*.js modules; this file is the
4
+ * only place that talks to the server and the only place that wires listeners.
5
+ */
6
+ import { el } from "./spa-dom.js";
7
+ import { store } from "./spa-store.js";
8
+ import { api } from "./spa-api.js";
9
+ import { only, setConnection, showNotice, setLayout, appendStatus } from "./spa-ui.js";
10
+ import { renderState, toggleOption, updateSelectionUI } from "./spa-question.js";
11
+ import { setView, updateCommandStrip } from "./spa-views.js";
12
+ import { handleChatEvent, renderChatHistory, sendChat, abortChat, syncChatControls } from "./spa-chat.js";
404
13
 
405
14
  async function refresh() {
406
- const s = await api("/api/state");
407
- render(s);
408
- }
409
-
410
- async function submitAnswer() {
411
- if (!state || state.phase !== "question" || !state.q || !selected.size) return;
412
- el.submit.disabled = true;
413
- try {
414
- const seconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
415
- const data = await api("/api/answer", {
416
- runId: state.runId,
417
- rev: state.rev,
418
- qid: state.q.id,
419
- selected: [...selected],
420
- confidence,
421
- seconds,
422
- });
423
- render(data.state || await api("/api/state"));
424
- } catch (e) {
425
- showNotice(e.message);
426
- try { await refresh(); } catch { /* keep notice */ }
427
- }
428
- }
429
-
430
- async function nextQuestion() {
431
- if (!state || !["feedback", "teach"].includes(state.phase)) return;
432
- try {
433
- const data = await api("/api/next", { runId: state.runId, rev: state.rev });
434
- render(data.state || await api("/api/state"));
435
- } catch (e) {
436
- showNotice(e.message);
437
- try { await refresh(); } catch { /* keep notice */ }
438
- }
15
+ renderState(await api("/api/state"));
439
16
  }
440
17
 
18
+ /** Enter a study mode: practice, probe, or timed mock. */
441
19
  async function startPractice(mode = "practice") {
442
20
  el.start.disabled = true;
443
21
  try {
@@ -447,7 +25,7 @@ async function startPractice(mode = "practice") {
447
25
  ? { mode: "probe", probe_size: 20 }
448
26
  : { mode: "practice" };
449
27
  const data = await api("/api/start", body);
450
- render(data.state || await api("/api/state"));
28
+ renderState(data.state || await api("/api/state"));
451
29
  } catch (e) {
452
30
  showNotice(e.message);
453
31
  try { await refresh(); } catch { /* keep notice */ }
@@ -456,599 +34,74 @@ async function startPractice(mode = "practice") {
456
34
  }
457
35
  }
458
36
 
459
- function inlineNodes(text) {
460
- const frag = document.createDocumentFragment();
461
- const re = /(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\(https?:\/\/[^)\s]+\))/g;
462
- let last = 0;
463
- for (const m of text.matchAll(re)) {
464
- if (m.index > last) frag.append(document.createTextNode(text.slice(last, m.index)));
465
- const tok = m[0];
466
- if (tok.startsWith("`")) {
467
- const code = document.createElement("code");
468
- code.textContent = tok.slice(1, -1);
469
- frag.append(code);
470
- } else if (tok.startsWith("**")) {
471
- const b = document.createElement("strong");
472
- b.textContent = tok.slice(2, -2);
473
- frag.append(b);
474
- } else {
475
- const mm = tok.match(/^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)$/);
476
- const a = document.createElement("a");
477
- a.textContent = mm?.[1] || tok;
478
- a.href = safeHttpUrl(mm?.[2] || "");
479
- a.target = "_blank";
480
- a.rel = "noreferrer";
481
- frag.append(a);
482
- }
483
- last = m.index + tok.length;
484
- }
485
- if (last < text.length) frag.append(document.createTextNode(text.slice(last)));
486
- return frag;
487
- }
488
-
489
- function markdownFragment(text) {
490
- const frag = document.createDocumentFragment();
491
- const lines = String(text || "").split(/\r?\n/);
492
- let i = 0;
493
- let para = [];
494
- const flush = () => {
495
- if (!para.length) return;
496
- const p = document.createElement("p");
497
- p.append(inlineNodes(para.join(" ")));
498
- frag.append(p);
499
- para = [];
500
- };
501
- while (i < lines.length) {
502
- const line = lines[i];
503
- if (/^```/.test(line)) {
504
- flush();
505
- const lang = line.slice(3).trim();
506
- i++;
507
- const code = [];
508
- while (i < lines.length && !/^```/.test(lines[i])) code.push(lines[i++]);
509
- if (i < lines.length) i++;
510
- const pre = document.createElement("pre");
511
- if (lang === "mermaid") pre.className = "diagram";
512
- const c = document.createElement("code");
513
- c.textContent = code.join("\n");
514
- pre.append(c);
515
- frag.append(pre);
516
- continue;
517
- }
518
- const h = line.match(/^(#{1,3})\s+(.*)$/);
519
- if (h) {
520
- flush();
521
- const elh = document.createElement("h" + h[1].length);
522
- elh.append(inlineNodes(h[2]));
523
- frag.append(elh);
524
- i++;
525
- continue;
526
- }
527
- if (/^\s*[-*]\s+/.test(line)) {
528
- flush();
529
- const ul = document.createElement("ul");
530
- while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
531
- const li = document.createElement("li");
532
- li.append(inlineNodes(lines[i].replace(/^\s*[-*]\s+/, "")));
533
- ul.append(li);
534
- i++;
535
- }
536
- frag.append(ul);
537
- continue;
538
- }
539
- if (/^>\s?/.test(line)) {
540
- flush();
541
- const quote = document.createElement("blockquote");
542
- quote.append(inlineNodes(line.replace(/^>\s?/, "")));
543
- frag.append(quote);
544
- i++;
545
- continue;
546
- }
547
- if (!line.trim()) {
548
- flush();
549
- i++;
550
- continue;
551
- }
552
- para.push(line);
553
- i++;
554
- }
555
- flush();
556
- return frag;
557
- }
558
-
559
- function renderMarkdownInto(node, text) {
560
- node.replaceChildren(markdownFragment(text));
561
- void renderMermaidInto(node);
562
- }
563
-
564
- function chatScroll(force = false) {
565
- if (!el.chatLog) return;
566
- const near = el.chatLog.scrollHeight - el.chatLog.scrollTop - el.chatLog.clientHeight < 80;
567
- if (force || near) el.chatLog.scrollTop = el.chatLog.scrollHeight;
568
- }
569
-
570
- function appendChatMessage(role, text) {
571
- const div = document.createElement("div");
572
- div.className = "chat-msg " + (role === "user" ? "user" : "assistant");
573
- if (role === "assistant") renderMarkdownInto(div, text);
574
- else div.textContent = text;
575
- el.chatLog.append(div);
576
- chatScroll();
577
- return div;
578
- }
579
-
580
- function setChatStatus(kind, text) {
581
- el.chatStatus.className = "chat-status" + (kind === "live" ? " live" : kind === "busy" ? " busy" : "");
582
- el.chatStatus.textContent = text;
583
- }
584
-
585
- function renderChatHistory(messages) {
586
- el.chatLog.replaceChildren();
587
- for (const m of messages || []) {
588
- if (m.role === "user" || m.role === "assistant") appendChatMessage(m.role, m.text);
589
- }
590
- if (!el.chatLog.children.length) {
591
- const empty = document.createElement("div");
592
- empty.className = "chat-empty";
593
- empty.textContent = chatEnabled
594
- ? "Ask about the current question, a concept, or why an answer is right. The coach teaches; the quiz card grades."
595
- : (chatReason || "Teaching chat is disabled. Start the server with --pi <pi|path> or set PI_BIN.");
596
- el.chatLog.append(empty);
597
- }
598
- }
599
-
600
- function renderTool(event) {
601
- let card = toolCards.get(event.id);
602
- if (event.status === "start" || !card) {
603
- card = document.createElement("div");
604
- card.className = "chat-tool";
605
- const head = document.createElement("div");
606
- head.className = "tool-head";
607
- const name = document.createElement("span");
608
- name.textContent = event.name || "tool";
609
- const state = document.createElement("span");
610
- state.className = "muted";
611
- head.append(name, state);
612
- const body = document.createElement("pre");
613
- body.className = "tool-body";
614
- card.append(head, body);
615
- el.chatLog.append(card);
616
- toolCards.set(event.id, card);
617
- }
618
- const state = card.querySelector(".tool-head .muted");
619
- const body = card.querySelector(".tool-body");
620
- if (event.status === "end") {
621
- state.textContent = event.isError ? "error" : "done";
622
- if (event.isError) card.classList.add("error");
623
- body.textContent = (event.text || "").slice(0, 3000);
624
- } else {
625
- state.textContent = event.status === "start" ? "running" : "…";
626
- if (event.partial) body.textContent = JSON.stringify(event.partial).slice(0, 1000);
627
- else if (event.args) body.textContent = JSON.stringify(event.args).slice(0, 1000);
628
- }
629
- chatScroll();
630
- }
631
-
632
- function handleChatEvent(event) {
633
- switch (event.type) {
634
- case "chat.user": {
635
- if (pendingUser && pendingUser === event.text) pendingUser = "";
636
- else appendChatMessage("user", event.text);
637
- break;
638
- }
639
- case "chat.start": {
640
- chatBusy = true;
641
- el.chatStop.hidden = false;
642
- el.chatSend.disabled = true;
643
- setChatStatus("busy", "thinking");
644
- chatStream = { div: appendChatMessage("assistant", ""), text: "" };
645
- break;
646
- }
647
- case "chat.delta": {
648
- if (!chatStream) chatStream = { div: appendChatMessage("assistant", ""), text: "" };
649
- chatStream.text += event.text || "";
650
- chatStream.div.textContent = chatStream.text;
651
- chatScroll();
652
- break;
653
- }
654
- case "chat.message": {
655
- if (!chatStream) chatStream = { div: appendChatMessage("assistant", ""), text: "" };
656
- renderMarkdownInto(chatStream.div, event.text || "");
657
- chatStream = null;
658
- chatScroll();
659
- break;
660
- }
661
- case "chat.tool": {
662
- renderTool(event);
663
- break;
664
- }
665
- case "chat.done": {
666
- chatBusy = false;
667
- el.chatStop.hidden = true;
668
- el.chatSend.disabled = !chatEnabled;
669
- setChatStatus(chatEnabled ? "live" : "off", chatEnabled ? "ready" : "offline");
670
- el.chatStatus.title = chatEnabled ? "Teaching chat ready" : (chatReason || "Teaching chat disabled");
671
- if (!chatStream) break;
672
- chatStream = null;
673
- break;
674
- }
675
- case "chat.error": {
676
- chatBusy = false;
677
- el.chatStop.hidden = true;
678
- el.chatSend.disabled = !chatEnabled;
679
- setChatStatus("off", "error");
680
- showNotice(event.error || "Chat failed.");
681
- break;
682
- }
683
- default:
684
- break;
685
- }
686
- }
687
-
688
- async function sendChat() {
689
- const text = el.chatInput.value.trim();
690
- if (!text || !chatEnabled || chatBusy) return;
691
- el.chatInput.value = "";
692
- pendingUser = text;
693
- appendChatMessage("user", text);
694
- chatBusy = true;
695
- el.chatSend.disabled = true;
696
- el.chatStop.hidden = false;
697
- setChatStatus("busy", "thinking");
37
+ async function submitAnswer() {
38
+ if (!store.state || store.state.phase !== "question" || !store.state.q || !store.selected.size) return;
39
+ el.submit.disabled = true;
698
40
  try {
699
- const res = await api("/api/prompt", { text });
700
- if (res.disposition === "handled") {
701
- chatBusy = false;
702
- el.chatStop.hidden = true;
703
- el.chatSend.disabled = false;
704
- setChatStatus("live", "ready");
705
- }
41
+ const seconds = Math.max(1, Math.round((Date.now() - store.startedAt) / 1000));
42
+ const data = await api("/api/answer", {
43
+ runId: store.state.runId,
44
+ rev: store.state.rev,
45
+ qid: store.state.q.id,
46
+ selected: [...store.selected],
47
+ confidence: store.confidence,
48
+ seconds,
49
+ });
50
+ renderState(data.state || await api("/api/state"));
706
51
  } catch (e) {
707
- chatBusy = false;
708
- pendingUser = "";
709
- el.chatStop.hidden = true;
710
- el.chatSend.disabled = !chatEnabled;
711
- setChatStatus("off", "error");
712
52
  showNotice(e.message);
53
+ try { await refresh(); } catch { /* keep notice */ }
713
54
  }
714
55
  }
715
56
 
716
- async function abortChat() {
57
+ async function nextQuestion() {
58
+ if (!store.state || !["feedback", "teach"].includes(store.state.phase)) return;
717
59
  try {
718
- await api("/api/abort", {});
60
+ const data = await api("/api/next", { runId: store.state.runId, rev: store.state.rev });
61
+ renderState(data.state || await api("/api/state"));
719
62
  } catch (e) {
720
63
  showNotice(e.message);
64
+ try { await refresh(); } catch { /* keep notice */ }
721
65
  }
722
66
  }
723
67
 
724
- function setLayout(name) {
725
- activeLayout = ["triptych", "command"].includes(name) ? name : "triptych";
726
- el.app.dataset.layout = activeLayout;
727
- for (const b of el.layoutButtons) b.classList.toggle("active", b.dataset.appLayout === activeLayout);
728
- localStorage.setItem("certCoachLayout", activeLayout);
729
- if (activeLayout === "command") void updateCommandStrip();
730
- }
731
-
732
- async function updateCommandStrip() {
68
+ async function loadCertPicker(current) {
733
69
  try {
734
- const data = await api("/api/status");
735
- const d = data.dashboard || {};
736
- const p = data.plan || {};
737
- const r = d.readiness || {};
738
- const readiness = Math.round((r.value || 0) * 100);
739
- const coverage = Math.round((r.coverage || 0) * 100);
740
- el.cmdReadiness.textContent = `${readiness}%`;
741
- el.cmdCoverage.textContent = `${coverage}%`;
742
- el.cmdDue.textContent = String(d.due || 0);
743
- const weak = (p.weakNodes || [])[0];
744
- el.cmdWeak.textContent = weak?.domain || "—";
745
- el.cmdWeakNote.textContent = weak ? `${weak.title} · ${Math.round((weak.accuracy || 0) * 100)}%` : "No attempts yet";
746
- el.cmdReadinessBar.style.setProperty("--value", `${readiness}%`);
747
- el.cmdCoverageBar.style.setProperty("--value", `${coverage}%`);
748
- el.cmdDomains.replaceChildren();
749
- for (const dom of p.domains || []) {
750
- const row = document.createElement("div");
751
- row.className = "domain-row";
752
- const name = document.createElement("span");
753
- name.textContent = dom.name;
754
- const meter = document.createElement("div");
755
- meter.className = "domain-meter";
756
- const fill = document.createElement("i");
757
- fill.style.setProperty("--value", `${Math.round((dom.accuracy || 0) * 100)}%`);
758
- meter.append(fill);
759
- const val = document.createElement("b");
760
- val.textContent = `${Math.round((dom.accuracy || 0) * 100)}%`;
761
- row.append(name, meter, val);
762
- el.cmdDomains.append(row);
70
+ const data = await api("/api/certs");
71
+ el.certSelect.replaceChildren();
72
+ for (const c of data.certs || []) {
73
+ const opt = document.createElement("option");
74
+ opt.value = c.code;
75
+ opt.textContent = c.title ? `${c.code} — ${c.title}` : c.code;
76
+ if (c.code === current) opt.selected = true;
77
+ el.certSelect.append(opt);
763
78
  }
79
+ el.certSelect.disabled = (data.certs || []).length < 2;
764
80
  } catch {
765
- /* command strip is optional */
766
- }
767
- }
768
-
769
- function setView(name) {
770
- activeView = name;
771
- for (const b of document.querySelectorAll(".nav button, .side-nav button")) b.classList.toggle("active", b.dataset.tab === name);
772
- el.studyView.hidden = name !== "study";
773
- el.pathView.hidden = name !== "path";
774
- el.dashboardView.hidden = name !== "dashboard";
775
- el.theoryView.hidden = name !== "theory";
776
- el.logsView.hidden = name !== "logs";
777
- if (name === "path") loadPath();
778
- if (name === "dashboard") loadDashboard();
779
- if (name === "theory") loadTheory();
780
- if (name === "logs") loadLogs();
781
- }
782
-
783
- function stat(k, v) {
784
- const d = document.createElement("div");
785
- d.className = "stat";
786
- const kk = document.createElement("div");
787
- kk.className = "k";
788
- kk.textContent = k;
789
- const vv = document.createElement("div");
790
- vv.className = "v";
791
- vv.textContent = String(v);
792
- d.append(kk, vv);
793
- return d;
794
- }
795
-
796
- async function loadPath() {
797
- el.pathRoute.textContent = "Loading...";
798
- el.pathDetail.textContent = "";
799
- try {
800
- const data = await api("/api/status");
801
- const p = data.plan || {};
802
- el.pathMeta.textContent = p.ready
803
- ? "target met"
804
- : `probe ${Math.min(p.seenQuestions || 0, p.probeSize || 20)}/${p.probeSize || 20} · target ${Math.round((p.target?.readiness || 0.85) * 100)}% readiness / ${Math.round((p.target?.coverage || 0.8) * 100)}% coverage`;
805
- el.pathRoute.replaceChildren();
806
- const steps = document.createElement("div");
807
- steps.className = "route-steps";
808
- for (const step of p.steps || []) {
809
- const d = document.createElement("div");
810
- d.className = "route-step " + (step.state || "");
811
- d.textContent = `${step.name} · ${step.state || ""}`;
812
- steps.append(d);
813
- }
814
- el.pathRoute.append(steps);
815
- const callout = document.createElement("div");
816
- callout.className = "route-callout";
817
- const phase = document.createElement("h3");
818
- phase.textContent = p.ready ? "READY" : String(p.phase || "progress").toUpperCase();
819
- const why = document.createElement("p");
820
- why.textContent = p.why || "";
821
- const next = document.createElement("p");
822
- next.textContent = "Next: " + (p.next || "");
823
- const criteria = document.createElement("p");
824
- criteria.className = "muted";
825
- criteria.textContent = p.criteria || "";
826
- callout.append(phase, why, next, criteria);
827
- el.pathRoute.append(callout);
828
-
829
- el.pathDetail.replaceChildren();
830
- for (const d of p.domains || []) {
831
- const section = document.createElement("section");
832
- section.className = "path-domain";
833
- const h = document.createElement("h3");
834
- h.textContent = `${d.name} · ${Math.round((d.weight_min || 0) * 100)}–${Math.round((d.weight_max || 0) * 100)}% of exam`;
835
- const summary = document.createElement("p");
836
- summary.className = "muted";
837
- summary.textContent = `${d.seen}/${d.total} questions seen · ${d.accuracy == null ? "no accuracy yet" : `${Math.round(d.accuracy * 100)}% accuracy`} · coverage ${Math.round((d.coverage || 0) * 100)}%`;
838
- section.append(h, summary);
839
- if (d.skills?.length) {
840
- const skills = document.createElement("ul");
841
- skills.className = "path-skills";
842
- for (const s of d.skills) { const li = document.createElement("li"); li.textContent = s; skills.append(li); }
843
- section.append(skills);
844
- }
845
- const table = document.createElement("table");
846
- table.className = "node-table";
847
- const head = document.createElement("tr");
848
- for (const label of ["Node / subdomain", "Status", "Questions", "Accuracy", "Box"]) {
849
- const th = document.createElement("th"); th.textContent = label; head.append(th);
850
- }
851
- table.append(head);
852
- for (const n of d.nodes || []) {
853
- const tr = document.createElement("tr");
854
- const title = document.createElement("td");
855
- title.className = "path-node-title";
856
- title.textContent = n.title;
857
- const small = document.createElement("small");
858
- small.textContent = n.summary || n.id;
859
- title.append(small);
860
- const status = document.createElement("td");
861
- const tag = document.createElement("span");
862
- tag.className = "status-tag " + (n.status || "unseen");
863
- tag.textContent = n.status || "unseen";
864
- status.append(tag);
865
- const qs = document.createElement("td");
866
- qs.className = "num";
867
- qs.textContent = `${n.seen}/${n.total}`;
868
- const acc = document.createElement("td");
869
- acc.className = "num";
870
- acc.textContent = n.accuracy == null ? "—" : `${Math.round(n.accuracy * 100)}%`;
871
- const box = document.createElement("td");
872
- box.className = "num";
873
- box.textContent = String(n.box ?? 0);
874
- tr.append(title, status, qs, acc, box);
875
- table.append(tr);
876
- }
877
- section.append(table);
878
- el.pathDetail.append(section);
879
- }
880
- } catch (e) {
881
- el.pathRoute.textContent = e.message;
882
- }
883
- }
884
-
885
- async function loadDashboard() {
886
- el.dashboardBody.textContent = "Loading...";
887
- try {
888
- const data = await api("/api/status");
889
- const d = data.dashboard || {};
890
- const r = d.readiness || {};
891
- el.dashboardMeta.textContent = `${d.title || ""} · ${d.verified || 0}/${d.total || 0} verified`;
892
- el.dashboardBody.replaceChildren();
893
- const grid = document.createElement("div");
894
- grid.className = "dash-grid";
895
- grid.append(
896
- stat("Readiness", `${((r.value || 0) * 100).toFixed(1)}%`),
897
- stat("Coverage", `${((r.coverage || 0) * 100).toFixed(0)}%`),
898
- stat("Due now", d.due || 0),
899
- stat("Gaps / review", `${d.boxes?.[1] || 0} / ${d.boxes?.[2] || 0}`),
900
- stat("Mastered", d.boxes?.[3] || 0),
901
- stat("Sessions", d.sessions || 0)
902
- );
903
- el.dashboardBody.append(grid);
904
-
905
- const domains = document.createElement("div");
906
- for (const [id, v] of Object.entries(r.by_domain || {})) {
907
- const row = document.createElement("div");
908
- row.className = "domain-row";
909
- const name = document.createElement("span");
910
- name.textContent = id;
911
- const bar = document.createElement("div");
912
- bar.className = "bar";
913
- const fill = document.createElement("span");
914
- fill.style.width = `${Math.round((v.accuracy || 0) * 100)}%`;
915
- bar.append(fill);
916
- const num = document.createElement("span");
917
- num.className = "muted";
918
- num.textContent = `${v.seen}/${v.total}${v.accuracy == null ? "" : ` · ${Math.round(v.accuracy * 100)}%`}`;
919
- row.append(name, bar, num);
920
- domains.append(row);
921
- }
922
- el.dashboardBody.append(domains);
923
- if (d.weak?.length) {
924
- const weak = document.createElement("div");
925
- weak.className = "weak-list";
926
- weak.textContent = "Weakest domains: " + d.weak.map((x) => `${x.domain} ${Math.round((x.accuracy || 0) * 100)}%`).join(" · ");
927
- el.dashboardBody.append(weak);
928
- }
929
- } catch (e) {
930
- el.dashboardBody.textContent = e.message;
931
- }
932
- }
933
-
934
- async function loadTheory() {
935
- try {
936
- if (!theoryNodesCache) {
937
- const data = await api("/api/theory");
938
- theoryNodesCache = data.nodes || [];
939
- el.theoryNodes.replaceChildren();
940
- for (const n of theoryNodesCache) {
941
- const b = document.createElement("button");
942
- b.type = "button";
943
- b.textContent = n.title || n.id;
944
- b.dataset.node = n.id;
945
- b.addEventListener("click", () => selectTheory(n.id));
946
- el.theoryNodes.append(b);
947
- }
948
- }
949
- const preferred = state?.q?.node || state?.teach?.node || theoryNodesCache[0]?.id;
950
- if (preferred) await selectTheory(preferred);
951
- } catch (e) {
952
- el.theoryBody.textContent = e.message;
953
- }
954
- }
955
-
956
- async function selectTheory(node) {
957
- activeTheoryNode = node;
958
- for (const b of el.theoryNodes.querySelectorAll("button")) b.classList.toggle("active", b.dataset.node === node);
959
- el.theoryBody.textContent = "Loading...";
960
- try {
961
- const t = await api("/api/theory?node=" + encodeURIComponent(node));
962
- el.theoryBody.replaceChildren();
963
- const h = document.createElement("h2");
964
- h.textContent = t.title || node;
965
- const meta = document.createElement("p");
966
- meta.className = "muted";
967
- meta.textContent = [t.domain, t.summary].filter(Boolean).join(" · ");
968
- el.theoryBody.append(h, meta);
969
- const body = document.createElement("div");
970
- renderMarkdownInto(body, t.section || "No theory section yet.");
971
- el.theoryBody.append(body);
972
- const doc = safeHttpUrl(t.docUrl || "");
973
- if (doc) {
974
- const p = document.createElement("p");
975
- const a = document.createElement("a");
976
- a.href = doc;
977
- a.target = "_blank";
978
- a.rel = "noreferrer";
979
- a.textContent = doc;
980
- p.append(a);
981
- el.theoryBody.append(p);
982
- }
983
- } catch (e) {
984
- el.theoryBody.textContent = e.message;
985
- }
986
- }
987
-
988
- async function loadLogs() {
989
- el.logsBody.textContent = "Loading...";
990
- try {
991
- const data = await api("/api/log");
992
- el.logsBody.textContent = data.text || "No study log yet.";
993
- } catch (e) {
994
- el.logsBody.textContent = e.message;
995
- }
996
- }
997
-
998
- function appendStatus(lines) {
999
- for (const item of lines || []) {
1000
- const div = document.createElement("div");
1001
- div.className = "status-line";
1002
- const t = item?.at ? new Date(item.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "";
1003
- const strong = document.createElement("strong");
1004
- strong.textContent = t ? `${t} ` : "";
1005
- const span = document.createElement("span");
1006
- span.textContent = item?.line || "";
1007
- div.append(strong, span);
1008
- el.statusLog.prepend(div);
81
+ el.certSelect.hidden = true;
1009
82
  }
1010
- while (el.statusLog.children.length > 80) el.statusLog.lastChild.remove();
1011
- // Newest just under the "Coach log" title; older entries stay below.
1012
- el.statusLog.parentElement.scrollTop = 0;
1013
83
  }
1014
84
 
1015
85
  function connectEvents() {
1016
- if (eventSource) eventSource.close();
1017
- eventSource = new EventSource("/api/events");
1018
- eventSource.onopen = () => setConnection("live");
1019
- eventSource.onerror = () => setConnection("down");
1020
- eventSource.addEventListener("state", (event) => {
86
+ if (store.eventSource) store.eventSource.close();
87
+ store.eventSource = new EventSource("/api/events");
88
+ store.eventSource.onopen = () => setConnection("live");
89
+ store.eventSource.onerror = () => setConnection("down");
90
+ store.eventSource.addEventListener("state", (event) => {
1021
91
  try {
1022
92
  const data = JSON.parse(event.data);
1023
- if (data.state) render(data.state);
93
+ if (data.state) renderState(data.state);
1024
94
  if (data.status?.length) appendStatus(data.status);
1025
95
  setConnection("live");
1026
96
  } catch {
1027
97
  /* ignore malformed event */
1028
98
  }
1029
99
  });
1030
- eventSource.addEventListener("chat", (event) => {
100
+ store.eventSource.addEventListener("chat", (event) => {
1031
101
  try { handleChatEvent(JSON.parse(event.data)); } catch { /* ignore malformed event */ }
1032
102
  });
1033
103
  }
1034
104
 
1035
- async function loadCertPicker(current) {
1036
- try {
1037
- const data = await api("/api/certs");
1038
- el.certSelect.replaceChildren();
1039
- for (const c of data.certs || []) {
1040
- const opt = document.createElement("option");
1041
- opt.value = c.code;
1042
- opt.textContent = c.title ? `${c.code} — ${c.title}` : c.code;
1043
- if (c.code === current) opt.selected = true;
1044
- el.certSelect.append(opt);
1045
- }
1046
- el.certSelect.disabled = (data.certs || []).length < 2;
1047
- } catch {
1048
- el.certSelect.hidden = true;
1049
- }
1050
- }
1051
-
1052
105
  async function bootstrap() {
1053
106
  try {
1054
107
  // Strip the token from the address bar; the server exchanged it for a cookie.
@@ -1060,15 +113,12 @@ async function bootstrap() {
1060
113
  const data = await api("/api/bootstrap");
1061
114
  el.certLine.textContent = `${data.cert || "cert"} · ${data.title || ""}`;
1062
115
  setLayout(localStorage.getItem("certCoachLayout") || "triptych");
1063
- chatEnabled = Boolean(data.chat?.enabled);
1064
- chatReason = data.chat?.status?.error || "";
1065
- el.chatInput.disabled = !chatEnabled;
1066
- el.chatSend.disabled = !chatEnabled;
1067
- el.chatSend.textContent = chatEnabled ? "Send" : "Chat off";
1068
- setChatStatus(chatEnabled ? "live" : "off", chatEnabled ? "ready" : "offline");
116
+ store.chat.enabled = Boolean(data.chat?.enabled);
117
+ store.chat.reason = data.chat?.status?.error || "";
118
+ syncChatControls();
1069
119
  renderChatHistory(data.chat?.messages || []);
1070
120
  await loadCertPicker(data.cert);
1071
- render(data.state);
121
+ renderState(data.state);
1072
122
  setView("study");
1073
123
  connectEvents();
1074
124
  } catch (e) {
@@ -1078,9 +128,11 @@ async function bootstrap() {
1078
128
  }
1079
129
  }
1080
130
 
131
+ // ── listeners ────────────────────────────────────────────────────────────────
1081
132
  el.submit.addEventListener("click", submitAnswer);
1082
133
  el.next.addEventListener("click", nextQuestion);
1083
134
  el.teachNext.addEventListener("click", nextQuestion);
135
+
1084
136
  el.certSelect.addEventListener("change", async () => {
1085
137
  const code = el.certSelect.value;
1086
138
  try {
@@ -1092,6 +144,7 @@ el.certSelect.addEventListener("change", async () => {
1092
144
  el.certSelect.disabled = false;
1093
145
  }
1094
146
  });
147
+
1095
148
  el.certNew.addEventListener("click", async () => {
1096
149
  const raw = window.prompt("New certification code (e.g. AZ-104, DP-600, AWS-SAA):");
1097
150
  if (!raw) return;
@@ -1102,18 +155,27 @@ el.certNew.addEventListener("click", async () => {
1102
155
  showNotice(e.message);
1103
156
  }
1104
157
  });
158
+
1105
159
  el.start.addEventListener("click", () => startPractice("practice"));
1106
160
  el.startProbe.addEventListener("click", () => startPractice("probe"));
1107
161
  el.startMock.addEventListener("click", () => startPractice("mock"));
1108
162
  el.mockNew.addEventListener("click", () => startPractice("mock"));
1109
163
  el.mockPractice.addEventListener("click", () => startPractice("practice"));
164
+
1110
165
  for (const nav of [el.nav, el.sideNav]) {
1111
166
  nav?.addEventListener("click", (e) => {
1112
167
  const b = e.target.closest("button[data-tab]");
1113
168
  if (b) setView(b.dataset.tab);
1114
169
  });
1115
170
  }
1116
- for (const b of el.layoutButtons) b.addEventListener("click", () => setLayout(b.dataset.appLayout));
171
+
172
+ for (const b of el.layoutButtons) {
173
+ b.addEventListener("click", () => {
174
+ setLayout(b.dataset.appLayout);
175
+ if (store.activeLayout === "command") void updateCommandStrip();
176
+ });
177
+ }
178
+
1117
179
  el.chatForm.addEventListener("submit", (e) => { e.preventDefault(); sendChat(); });
1118
180
  el.chatStop.addEventListener("click", abortChat);
1119
181
  el.chatInput.addEventListener("keydown", (e) => {
@@ -1122,31 +184,33 @@ el.chatInput.addEventListener("keydown", (e) => {
1122
184
  sendChat();
1123
185
  }
1124
186
  });
187
+
1125
188
  for (const b of el.confidenceButtons.querySelectorAll("button")) {
1126
189
  b.addEventListener("click", () => {
1127
- confidence = Number(b.dataset.confidence);
190
+ store.confidence = Number(b.dataset.confidence);
1128
191
  updateSelectionUI();
1129
192
  });
1130
193
  }
1131
194
 
195
+ // Keyboard: letter/number picks an option, Enter submits or advances.
1132
196
  document.addEventListener("keydown", (e) => {
1133
- if (!state) return;
197
+ if (!store.state) return;
1134
198
  const tag = (e.target?.tagName || "").toLowerCase();
1135
199
  if (["input", "textarea", "select"].includes(tag)) return;
1136
- if (state.phase === "question" && state.q) {
200
+ if (store.state.phase === "question" && store.state.q) {
1137
201
  const key = e.key.toUpperCase();
1138
202
  if (/^[A-H]$/.test(key) || /^[1-8]$/.test(key)) {
1139
203
  const idx = /^[1-8]$/.test(key) ? Number(key) - 1 : key.charCodeAt(0) - 65;
1140
- const opt = state.q.options?.[idx];
204
+ const opt = store.state.q.options?.[idx];
1141
205
  if (opt) {
1142
206
  e.preventDefault();
1143
207
  toggleOption(opt.value);
1144
208
  }
1145
- } else if (e.key === "Enter" && selected.size) {
209
+ } else if (e.key === "Enter" && store.selected.size) {
1146
210
  e.preventDefault();
1147
211
  submitAnswer();
1148
212
  }
1149
- } else if (["feedback", "teach"].includes(state.phase) && e.key === "Enter") {
213
+ } else if (["feedback", "teach"].includes(store.state.phase) && e.key === "Enter") {
1150
214
  e.preventDefault();
1151
215
  nextQuestion();
1152
216
  }