pi-interview 0.3.0

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/form/script.js ADDED
@@ -0,0 +1,2213 @@
1
+ (() => {
2
+ const data = window.__INTERVIEW_DATA__ || {};
3
+ const questions = Array.isArray(data.questions) ? data.questions : [];
4
+ const sessionToken = data.sessionToken || "";
5
+ const sessionId = data.sessionId || "";
6
+ const cwd = data.cwd || "";
7
+ const gitBranch = data.gitBranch || "";
8
+ const startedAt = data.startedAt || Date.now();
9
+ const timeout = typeof data.timeout === "number" ? data.timeout : 0;
10
+
11
+ const titleEl = document.getElementById("form-title");
12
+ const descriptionEl = document.getElementById("form-description");
13
+ const containerEl = document.getElementById("questions-container");
14
+ const formEl = document.getElementById("interview-form");
15
+
16
+ const submitBtn = document.getElementById("submit-btn");
17
+ const errorContainer = document.getElementById("error-container");
18
+ const successOverlay = document.getElementById("success-overlay");
19
+ const expiredOverlay = document.getElementById("expired-overlay");
20
+ const closeTabBtn = document.getElementById("close-tab-btn");
21
+ const countdownBadge = document.getElementById("countdown-badge");
22
+ const countdownValue = countdownBadge?.querySelector(".countdown-value");
23
+ const countdownRingProgress = countdownBadge?.querySelector(".countdown-ring-progress");
24
+ const closeCountdown = document.getElementById("close-countdown");
25
+ const stayBtn = document.getElementById("stay-btn");
26
+ const queueToast = document.getElementById("queue-toast");
27
+ const queueToastTitle = queueToast?.querySelector(".queue-toast-header span");
28
+ const queueToastClose = queueToast?.querySelector(".queue-toast-close");
29
+ const queueSessionSelect = document.getElementById("queue-session-select");
30
+ const queueOpenBtn = document.getElementById("queue-open-btn");
31
+
32
+ const MAX_SIZE = 5 * 1024 * 1024;
33
+ const MAX_DIMENSION = 4096;
34
+ const MAX_IMAGES = 12;
35
+ const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
36
+
37
+ const imageState = new Map();
38
+ const imagePathState = new Map();
39
+ const attachState = new Map();
40
+ const attachPathState = new Map();
41
+ const nav = {
42
+ questionIndex: 0,
43
+ optionIndex: 0,
44
+ inSubmitArea: false,
45
+ cards: [],
46
+ };
47
+
48
+ const session = {
49
+ storageKey: null,
50
+ expired: false,
51
+ countdownEndTime: 0,
52
+ tickLoopRunning: false,
53
+ ended: false,
54
+ cancelSent: false,
55
+ reloadIntent: false,
56
+ };
57
+ const timers = {
58
+ save: null,
59
+ countdown: null,
60
+ expiration: null,
61
+ heartbeat: null,
62
+ queuePoll: null,
63
+ };
64
+ let filePickerOpen = false;
65
+ const CLOSE_DELAY = 10;
66
+ const RING_CIRCUMFERENCE = 100.53;
67
+ const RELOAD_INTENT_KEY = "pi-interview-reload-intent";
68
+ const queueState = {
69
+ dismissed: false,
70
+ knownIds: new Set(),
71
+ };
72
+
73
+ function updateCountdownBadge(secondsLeft, totalSeconds) {
74
+ if (!countdownBadge || !countdownValue || !countdownRingProgress) return;
75
+
76
+ countdownValue.textContent = formatTime(secondsLeft);
77
+ const progress = (totalSeconds - secondsLeft) / totalSeconds;
78
+ countdownRingProgress.style.strokeDashoffset = RING_CIRCUMFERENCE * progress;
79
+ }
80
+
81
+ function formatTime(seconds) {
82
+ const mins = Math.floor(seconds / 60);
83
+ const secs = seconds % 60;
84
+ if (mins > 0) {
85
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
86
+ }
87
+ return String(secs);
88
+ }
89
+
90
+ function startCountdownDisplay() {
91
+ if (!countdownBadge || timeout <= 0) return;
92
+
93
+ const expandThreshold = 120;
94
+ const urgentThreshold = 30;
95
+ session.countdownEndTime = Date.now() + (timeout * 1000);
96
+
97
+ countdownBadge.classList.remove("hidden");
98
+ countdownBadge.classList.add("minimal");
99
+
100
+ if (session.tickLoopRunning) return;
101
+ session.tickLoopRunning = true;
102
+
103
+ const tick = () => {
104
+ const now = Date.now();
105
+ const remaining = Math.max(0, Math.ceil((session.countdownEndTime - now) / 1000));
106
+
107
+ updateCountdownBadge(remaining, timeout);
108
+
109
+ if (remaining <= expandThreshold) {
110
+ countdownBadge.classList.remove("minimal");
111
+ }
112
+
113
+ if (remaining <= urgentThreshold) {
114
+ countdownBadge.classList.add("urgent");
115
+ } else {
116
+ countdownBadge.classList.remove("urgent");
117
+ }
118
+
119
+ if (remaining > 0 && !session.expired) {
120
+ requestAnimationFrame(tick);
121
+ } else {
122
+ session.tickLoopRunning = false;
123
+ }
124
+ };
125
+
126
+ requestAnimationFrame(tick);
127
+ }
128
+
129
+ function refreshCountdown() {
130
+ if (session.expired || timeout <= 0) return;
131
+ session.countdownEndTime = Date.now() + (timeout * 1000);
132
+ countdownBadge?.classList.add("minimal");
133
+ countdownBadge?.classList.remove("urgent");
134
+
135
+ if (timers.expiration) {
136
+ clearTimeout(timers.expiration);
137
+ }
138
+ timers.expiration = setTimeout(() => {
139
+ showSessionExpired();
140
+ }, timeout * 1000);
141
+ }
142
+
143
+ function showSessionExpired() {
144
+ if (session.expired) return;
145
+ session.expired = true;
146
+ session.tickLoopRunning = false;
147
+
148
+ submitBtn.disabled = true;
149
+ countdownBadge?.classList.add("hidden");
150
+
151
+ expiredOverlay.classList.remove("hidden");
152
+ requestAnimationFrame(() => {
153
+ expiredOverlay.classList.add("visible");
154
+ stayBtn.focus();
155
+ });
156
+
157
+ let closeIn = CLOSE_DELAY;
158
+ if (closeCountdown) closeCountdown.textContent = closeIn;
159
+
160
+ timers.countdown = setInterval(() => {
161
+ closeIn--;
162
+ if (closeCountdown) closeCountdown.textContent = closeIn;
163
+
164
+ if (closeIn <= 0) {
165
+ clearInterval(timers.countdown);
166
+ cancelInterview("timeout").finally(() => window.close());
167
+ }
168
+ }, 1000);
169
+ }
170
+
171
+ function startHeartbeat() {
172
+ if (timers.heartbeat) return;
173
+ timers.heartbeat = setInterval(() => {
174
+ fetch("/heartbeat", {
175
+ method: "POST",
176
+ headers: { "Content-Type": "application/json" },
177
+ body: JSON.stringify({ token: sessionToken }),
178
+ }).catch(() => {});
179
+ }, 5000);
180
+ }
181
+
182
+ function stopHeartbeat() {
183
+ if (timers.heartbeat) {
184
+ clearInterval(timers.heartbeat);
185
+ timers.heartbeat = null;
186
+ }
187
+ }
188
+
189
+ function stopQueuePolling() {
190
+ if (timers.queuePoll) {
191
+ clearInterval(timers.queuePoll);
192
+ timers.queuePoll = null;
193
+ }
194
+ }
195
+
196
+ function formatRelativeTime(timestamp) {
197
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
198
+ if (seconds < 0) return "just now";
199
+ if (seconds < 60) return `${seconds}s ago`;
200
+ const minutes = Math.floor(seconds / 60);
201
+ if (minutes < 60) return `${minutes}m ago`;
202
+ const hours = Math.floor(minutes / 60);
203
+ return `${hours}h ago`;
204
+ }
205
+
206
+ function truncateText(text, maxLength) {
207
+ if (!text || text.length <= maxLength) return text;
208
+ const head = Math.ceil((maxLength - 3) * 0.6);
209
+ const tail = Math.floor((maxLength - 3) * 0.4);
210
+ return `${text.slice(0, head)}...${text.slice(-tail)}`;
211
+ }
212
+
213
+ function formatSessionLabel(session) {
214
+ const status = session.status === "active" ? "Active" : "Waiting";
215
+ const branch = session.gitBranch ? ` (${session.gitBranch})` : "";
216
+ const project = session.cwd ? truncateText(session.cwd + branch, 36) : "Unknown";
217
+ const title = truncateText(session.title || "Interview", 32);
218
+ const timeAgo = formatRelativeTime(session.startedAt);
219
+ return `${status}: ${title} — ${project} · ${timeAgo}`;
220
+ }
221
+
222
+ function updateQueueToast(sessions) {
223
+ if (!queueToast || !queueSessionSelect || !queueOpenBtn) return;
224
+ const others = sessions.filter((s) => s.id !== sessionId);
225
+ if (others.length === 0) {
226
+ queueToast.classList.add("hidden");
227
+ queueState.dismissed = false;
228
+ queueState.knownIds.clear();
229
+ return;
230
+ }
231
+
232
+ const newIds = others.filter((s) => !queueState.knownIds.has(s.id));
233
+ others.forEach((s) => queueState.knownIds.add(s.id));
234
+ if (newIds.length > 0) {
235
+ queueState.dismissed = false;
236
+ }
237
+
238
+ if (queueState.dismissed) return;
239
+
240
+ const currentSession = sessions.find((s) => s.id === sessionId);
241
+ const sortedOthers = others.slice().sort((a, b) => b.startedAt - a.startedAt);
242
+ const sorted = currentSession ? [currentSession, ...sortedOthers] : sortedOthers;
243
+ const currentValue = queueSessionSelect.value;
244
+ queueSessionSelect.innerHTML = "";
245
+ sorted.forEach((session) => {
246
+ const option = document.createElement("option");
247
+ option.value = session.url;
248
+ if (session.id === sessionId) {
249
+ const branch = session.gitBranch ? ` (${session.gitBranch})` : "";
250
+ const project = session.cwd ? truncateText(session.cwd + branch, 36) : "Unknown";
251
+ const title = truncateText(session.title || "Interview", 32);
252
+ const timeAgo = formatRelativeTime(session.startedAt);
253
+ option.textContent = `Active (this tab): ${title} — ${project} · ${timeAgo}`;
254
+ option.disabled = true;
255
+ } else {
256
+ option.textContent = formatSessionLabel(session);
257
+ }
258
+ queueSessionSelect.appendChild(option);
259
+ });
260
+ const selectedSession =
261
+ (currentValue && sorted.find((s) => s.url === currentValue && s.id !== sessionId)) ||
262
+ sorted.find((s) => s.id !== sessionId);
263
+ if (selectedSession) {
264
+ queueSessionSelect.value = selectedSession.url;
265
+ }
266
+
267
+ if (queueToastTitle) {
268
+ queueToastTitle.textContent =
269
+ others.length === 1 ? "Another interview started" : `${others.length} interviews waiting`;
270
+ }
271
+
272
+ const selectedOption = queueSessionSelect.options[queueSessionSelect.selectedIndex];
273
+ queueOpenBtn.disabled = !queueSessionSelect.value || selectedOption?.disabled;
274
+ queueToast.classList.remove("hidden");
275
+ }
276
+
277
+ async function pollQueueSessions() {
278
+ try {
279
+ const response = await fetch(`/sessions?session=${encodeURIComponent(sessionToken)}`, {
280
+ method: "GET",
281
+ headers: { "Accept": "application/json" },
282
+ cache: "no-store",
283
+ });
284
+ if (!response.ok) return;
285
+ const data = await response.json();
286
+ if (!data || !data.ok || !Array.isArray(data.sessions)) return;
287
+ updateQueueToast(data.sessions);
288
+ } catch (_err) {}
289
+ }
290
+
291
+ function startQueuePolling() {
292
+ if (!queueToast || timers.queuePoll) return;
293
+ pollQueueSessions();
294
+ timers.queuePoll = setInterval(pollQueueSessions, 6000);
295
+ }
296
+
297
+ function markReloadIntent() {
298
+ session.reloadIntent = true;
299
+ try {
300
+ sessionStorage.setItem(RELOAD_INTENT_KEY, "1");
301
+ setTimeout(() => {
302
+ sessionStorage.removeItem(RELOAD_INTENT_KEY);
303
+ }, 2000);
304
+ } catch (_err) {}
305
+ }
306
+
307
+ function clearReloadIntent() {
308
+ session.reloadIntent = false;
309
+ try {
310
+ sessionStorage.removeItem(RELOAD_INTENT_KEY);
311
+ } catch (_err) {}
312
+ }
313
+
314
+ function hasReloadIntent() {
315
+ if (session.reloadIntent) return true;
316
+ try {
317
+ return sessionStorage.getItem(RELOAD_INTENT_KEY) === "1";
318
+ } catch (_err) {
319
+ return false;
320
+ }
321
+ }
322
+
323
+ function sendCancelBeacon(reason) {
324
+ if (session.cancelSent || session.ended) return;
325
+ session.cancelSent = true;
326
+ const payload = JSON.stringify({ token: sessionToken, reason });
327
+ if (navigator.sendBeacon) {
328
+ const blob = new Blob([payload], { type: "application/json" });
329
+ navigator.sendBeacon("/cancel", blob);
330
+ return;
331
+ }
332
+ fetch("/cancel", {
333
+ method: "POST",
334
+ headers: { "Content-Type": "application/json" },
335
+ body: payload,
336
+ keepalive: true,
337
+ }).catch(() => {});
338
+ }
339
+
340
+ async function cancelInterview(reason) {
341
+ if (session.ended) return;
342
+ session.ended = true;
343
+ session.cancelSent = true;
344
+ stopHeartbeat();
345
+ stopQueuePolling();
346
+ try {
347
+ await fetch("/cancel", {
348
+ method: "POST",
349
+ headers: { "Content-Type": "application/json" },
350
+ body: JSON.stringify({ token: sessionToken, reason }),
351
+ });
352
+ } catch (_err) {}
353
+ }
354
+
355
+ function isNetworkError(err) {
356
+ if (err instanceof TypeError) return true;
357
+ if (err.name === "TypeError") return true;
358
+ const msg = String(err.message || "").toLowerCase();
359
+ return msg.includes("fetch") || msg.includes("network") || msg.includes("failed to fetch");
360
+ }
361
+
362
+ function escapeSelector(value) {
363
+ if (window.CSS && typeof CSS.escape === "function") {
364
+ return CSS.escape(value);
365
+ }
366
+ return String(value).replace(/["\\]/g, "\\$&");
367
+ }
368
+
369
+ function setText(el, text) {
370
+ if (!el) return;
371
+ el.textContent = text || "";
372
+ }
373
+
374
+ function renderLightMarkdown(text) {
375
+ if (!text) return "";
376
+ let html = text
377
+ .replace(/&/g, "&amp;")
378
+ .replace(/</g, "&lt;")
379
+ .replace(/>/g, "&gt;");
380
+ html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
381
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
382
+ html = html.replace(/\n/g, "<br>");
383
+ html = html.replace(/\s(\d+\.)\s/g, "<br>$1 ");
384
+ return html;
385
+ }
386
+
387
+ function getOptionLabel(option) {
388
+ return typeof option === "string" ? option : option.label;
389
+ }
390
+
391
+ function isRichOption(option) {
392
+ return typeof option === "object" && option !== null && "label" in option;
393
+ }
394
+
395
+ function renderCodeBlock(block) {
396
+ if (!block || !block.code) return null;
397
+
398
+ const container = document.createElement("div");
399
+ container.className = "code-block";
400
+
401
+ const showLineNumbers = !!block.file || !!block.lines;
402
+ const isDiff = block.lang === "diff";
403
+ const lines = block.code.split("\n");
404
+ const highlights = new Set(block.highlights || []);
405
+
406
+ // Parse starting line number from lines prop (e.g., "10-16" -> 10, "42" -> 42)
407
+ let startLineNum = 1;
408
+ if (block.lines) {
409
+ const match = block.lines.match(/^(\d+)/);
410
+ if (match) startLineNum = parseInt(match[1], 10);
411
+ }
412
+
413
+ if (block.file || block.lines || block.lang || block.title) {
414
+ const header = document.createElement("div");
415
+ header.className = "code-block-header";
416
+
417
+ if (block.title) {
418
+ const titleEl = document.createElement("span");
419
+ titleEl.className = "code-block-title";
420
+ titleEl.textContent = block.title;
421
+ header.appendChild(titleEl);
422
+ }
423
+
424
+ if (block.file) {
425
+ const fileEl = document.createElement("span");
426
+ fileEl.className = "code-block-file";
427
+ fileEl.textContent = block.file;
428
+ header.appendChild(fileEl);
429
+ }
430
+
431
+ if (block.lines) {
432
+ const linesEl = document.createElement("span");
433
+ linesEl.className = "code-block-lines";
434
+ linesEl.textContent = `L${block.lines}`;
435
+ header.appendChild(linesEl);
436
+ }
437
+
438
+ if (block.lang && block.lang !== "diff") {
439
+ const langEl = document.createElement("span");
440
+ langEl.className = "code-block-lang";
441
+ langEl.textContent = block.lang;
442
+ header.appendChild(langEl);
443
+ }
444
+
445
+ container.appendChild(header);
446
+ }
447
+
448
+ const pre = document.createElement("pre");
449
+ const code = document.createElement("code");
450
+
451
+ if (showLineNumbers || isDiff || highlights.size > 0) {
452
+ const linesContainer = document.createElement("div");
453
+ linesContainer.className = "code-block-lines-container";
454
+
455
+ lines.forEach((lineText, i) => {
456
+ const lineNum = startLineNum + i;
457
+ const lineEl = document.createElement("div");
458
+ lineEl.className = "code-block-line";
459
+
460
+ if (highlights.has(i + 1)) {
461
+ lineEl.classList.add("highlighted");
462
+ }
463
+
464
+ if (isDiff) {
465
+ if (lineText.startsWith("+") && !lineText.startsWith("+++")) {
466
+ lineEl.classList.add("diff-add");
467
+ } else if (lineText.startsWith("-") && !lineText.startsWith("---")) {
468
+ lineEl.classList.add("diff-remove");
469
+ } else if (lineText.startsWith("@@") || lineText.startsWith("---") || lineText.startsWith("+++")) {
470
+ lineEl.classList.add("diff-header");
471
+ }
472
+ }
473
+
474
+ if (showLineNumbers) {
475
+ const numEl = document.createElement("span");
476
+ numEl.className = "code-block-line-number";
477
+ numEl.textContent = String(lineNum);
478
+ lineEl.appendChild(numEl);
479
+ }
480
+
481
+ const contentEl = document.createElement("span");
482
+ contentEl.className = "code-block-line-content";
483
+ contentEl.textContent = lineText;
484
+ lineEl.appendChild(contentEl);
485
+
486
+ linesContainer.appendChild(lineEl);
487
+ });
488
+
489
+ code.appendChild(linesContainer);
490
+ } else {
491
+ code.textContent = block.code;
492
+ }
493
+
494
+ pre.appendChild(code);
495
+ container.appendChild(pre);
496
+
497
+ return container;
498
+ }
499
+
500
+ function isPrintableKey(event) {
501
+ if (event.metaKey || event.ctrlKey || event.altKey) return false;
502
+ return event.key.length === 1;
503
+ }
504
+
505
+ function maybeStartOtherInput(event) {
506
+ const active = document.activeElement;
507
+ if (!(active instanceof HTMLInputElement)) return false;
508
+ if ((active.type !== "radio" && active.type !== "checkbox") || active.value !== "__other__") return false;
509
+ if (!isPrintableKey(event)) return false;
510
+ const card = active.closest(".question-card");
511
+ const otherInput = card?.querySelector(".other-input");
512
+ if (!otherInput) return false;
513
+
514
+ event.preventDefault();
515
+ if (!active.checked) {
516
+ active.checked = true;
517
+ const question = questions.find((q) => q.id === active.name);
518
+ if (question?.type === "multi") updateDoneState(active.name);
519
+ debounceSave();
520
+ }
521
+ otherInput.focus();
522
+ otherInput.value += event.key;
523
+ otherInput.dispatchEvent(new Event("input", { bubbles: true }));
524
+ return true;
525
+ }
526
+
527
+ const themeConfig = data.theme || {};
528
+ const themeMode = themeConfig.mode || "dark";
529
+ const themeToggleHotkey =
530
+ typeof themeConfig.toggleHotkey === "string" ? themeConfig.toggleHotkey : "";
531
+ const themeLinkLight = document.querySelector('link[data-theme-link="light"]');
532
+ const themeLinkDark = document.querySelector('link[data-theme-link="dark"]');
533
+ const THEME_OVERRIDE_KEY = "pi-interview-theme-override";
534
+
535
+ function getSystemTheme() {
536
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
537
+ }
538
+
539
+ function getStoredThemeOverride() {
540
+ const value = localStorage.getItem(THEME_OVERRIDE_KEY);
541
+ return value === "light" || value === "dark" ? value : null;
542
+ }
543
+
544
+ function setStoredThemeOverride(value) {
545
+ if (!value) {
546
+ localStorage.removeItem(THEME_OVERRIDE_KEY);
547
+ return;
548
+ }
549
+ localStorage.setItem(THEME_OVERRIDE_KEY, value);
550
+ }
551
+
552
+ function setThemeLinkEnabled(link, enabled) {
553
+ if (!link) return;
554
+ link.disabled = !enabled;
555
+ link.media = enabled ? "all" : "not all";
556
+ }
557
+
558
+ function applyTheme(mode) {
559
+ document.documentElement.dataset.theme = mode;
560
+ setThemeLinkEnabled(themeLinkLight, mode === "light");
561
+ setThemeLinkEnabled(themeLinkDark, mode === "dark");
562
+ }
563
+
564
+ function getEffectiveThemeMode() {
565
+ const override = getStoredThemeOverride();
566
+ if (override) return override;
567
+ if (themeMode === "auto") return getSystemTheme();
568
+ return themeMode;
569
+ }
570
+
571
+ function parseHotkey(value) {
572
+ if (!value) return null;
573
+ const parts = value.toLowerCase().split("+").map(part => part.trim()).filter(Boolean);
574
+ if (parts.length === 0) return null;
575
+ const key = parts[parts.length - 1];
576
+ const mods = parts.slice(0, -1);
577
+ const hotkey = { key, mod: false, shift: false, alt: false };
578
+
579
+ mods.forEach((mod) => {
580
+ if (mod === "mod" || mod === "cmd" || mod === "meta" || mod === "ctrl" || mod === "control") {
581
+ hotkey.mod = true;
582
+ } else if (mod === "shift") {
583
+ hotkey.shift = true;
584
+ } else if (mod === "alt" || mod === "option") {
585
+ hotkey.alt = true;
586
+ }
587
+ });
588
+
589
+ return key ? hotkey : null;
590
+ }
591
+
592
+ function updateThemeShortcutDisplay(hotkey) {
593
+ const shortcut = document.querySelector("[data-theme-shortcut]");
594
+ if (!shortcut) return;
595
+ if (!hotkey) {
596
+ shortcut.classList.add("hidden");
597
+ return;
598
+ }
599
+
600
+ const keysEl = shortcut.querySelector("[data-theme-keys]");
601
+ if (!keysEl) return;
602
+ keysEl.innerHTML = "";
603
+
604
+ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
605
+ const parts = [];
606
+ if (hotkey.mod) parts.push(isMac ? "⌘" : "Ctrl");
607
+ if (hotkey.shift) parts.push("Shift");
608
+ if (hotkey.alt) parts.push(isMac ? "Option" : "Alt");
609
+ parts.push(hotkey.key.length === 1 ? hotkey.key.toUpperCase() : hotkey.key.toUpperCase());
610
+
611
+ parts.forEach((part) => {
612
+ const kbd = document.createElement("kbd");
613
+ kbd.textContent = part;
614
+ keysEl.appendChild(kbd);
615
+ });
616
+
617
+ shortcut.classList.remove("hidden");
618
+ }
619
+
620
+ function matchesHotkey(event, hotkey) {
621
+ const key = event.key.toLowerCase();
622
+ if (key !== hotkey.key) return false;
623
+ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
624
+ const modPressed = isMac ? event.metaKey : event.ctrlKey;
625
+ if (hotkey.mod !== modPressed) return false;
626
+ if (hotkey.shift !== event.shiftKey) return false;
627
+ if (hotkey.alt !== event.altKey) return false;
628
+ if (!hotkey.mod && (event.metaKey || event.ctrlKey)) return false;
629
+ if (!hotkey.shift && event.shiftKey) return false;
630
+ if (!hotkey.alt && event.altKey) return false;
631
+ return true;
632
+ }
633
+
634
+ function toggleTheme() {
635
+ const current = getEffectiveThemeMode();
636
+ const next = current === "dark" ? "light" : "dark";
637
+ if (themeMode === "auto") {
638
+ const system = getSystemTheme();
639
+ if (next === system) {
640
+ setStoredThemeOverride(null);
641
+ } else {
642
+ setStoredThemeOverride(next);
643
+ }
644
+ } else {
645
+ setStoredThemeOverride(next);
646
+ }
647
+ applyTheme(next);
648
+ }
649
+
650
+ function initTheme() {
651
+ applyTheme(getEffectiveThemeMode());
652
+
653
+ if (themeMode === "auto") {
654
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
655
+ media.addEventListener("change", () => {
656
+ if (!getStoredThemeOverride()) {
657
+ applyTheme(getSystemTheme());
658
+ }
659
+ });
660
+ }
661
+
662
+ const hotkey = parseHotkey(themeToggleHotkey);
663
+ updateThemeShortcutDisplay(hotkey);
664
+ if (hotkey) {
665
+ document.addEventListener("keydown", (event) => {
666
+ if (matchesHotkey(event, hotkey)) {
667
+ event.preventDefault();
668
+ toggleTheme();
669
+ }
670
+ });
671
+ }
672
+ }
673
+
674
+ function normalizePath(path) {
675
+ let normalized = path.replace(/\\ /g, " "); // Shell escape: backslash-space to space
676
+ // macOS screenshots use narrow no-break space (\u202f) before AM/PM in "Screenshot YYYY-MM-DD at H.MM.SS AM/PM.png"
677
+ normalized = normalized.replace(/(\d{1,2}\.\d{2}\.\d{2}) (AM|PM)(\.\w+)?$/i, "$1\u202f$2$3");
678
+ return normalized;
679
+ }
680
+
681
+ function debounceSave() {
682
+ if (timers.save) {
683
+ window.clearTimeout(timers.save);
684
+ }
685
+ timers.save = window.setTimeout(() => {
686
+ saveProgress();
687
+ }, 500);
688
+ }
689
+
690
+ function createImageManager(options) {
691
+ const {
692
+ fileState,
693
+ pathState,
694
+ containerSelector,
695
+ onUpdate,
696
+ onRenderComplete,
697
+ removeLabel = "×",
698
+ } = options;
699
+
700
+ const manager = {
701
+ render(questionId) {
702
+ const container = document.querySelector(containerSelector(questionId));
703
+ if (!container) return;
704
+ container.innerHTML = "";
705
+
706
+ const entry = fileState.get(questionId);
707
+ if (entry) {
708
+ const item = document.createElement("div");
709
+ item.className = "selected-item selected-image";
710
+
711
+ const img = document.createElement("img");
712
+ const url = URL.createObjectURL(entry.file);
713
+ img.src = url;
714
+ img.onload = () => URL.revokeObjectURL(url);
715
+
716
+ const name = document.createElement("span");
717
+ name.className = "selected-item-name";
718
+ name.textContent = entry.file.name;
719
+
720
+ const removeBtn = document.createElement("button");
721
+ removeBtn.type = "button";
722
+ removeBtn.className = "selected-item-remove";
723
+ removeBtn.textContent = removeLabel;
724
+ removeBtn.addEventListener("click", () => {
725
+ fileState.delete(questionId);
726
+ manager.render(questionId);
727
+ onUpdate();
728
+ });
729
+
730
+ item.appendChild(img);
731
+ item.appendChild(name);
732
+ item.appendChild(removeBtn);
733
+ container.appendChild(item);
734
+ }
735
+
736
+ const paths = pathState.get(questionId) || [];
737
+ paths.forEach(path => {
738
+ const item = document.createElement("div");
739
+ item.className = "selected-item selected-path";
740
+
741
+ const pathText = document.createElement("span");
742
+ pathText.className = "selected-item-path";
743
+ pathText.textContent = path;
744
+
745
+ const removeBtn = document.createElement("button");
746
+ removeBtn.type = "button";
747
+ removeBtn.className = "selected-item-remove";
748
+ removeBtn.textContent = removeLabel;
749
+ removeBtn.addEventListener("click", () => {
750
+ const arr = pathState.get(questionId) || [];
751
+ const idx = arr.indexOf(path);
752
+ if (idx > -1) arr.splice(idx, 1);
753
+ manager.render(questionId);
754
+ onUpdate();
755
+ });
756
+
757
+ item.appendChild(pathText);
758
+ item.appendChild(removeBtn);
759
+ container.appendChild(item);
760
+ });
761
+
762
+ if (onRenderComplete) onRenderComplete(questionId, manager);
763
+ },
764
+
765
+ addFile(questionId, file) {
766
+ fileState.set(questionId, { file });
767
+ manager.render(questionId);
768
+ onUpdate();
769
+ },
770
+
771
+ removeFile(questionId) {
772
+ fileState.delete(questionId);
773
+ manager.render(questionId);
774
+ onUpdate();
775
+ },
776
+
777
+ addPath(questionId, path) {
778
+ const paths = pathState.get(questionId) || [];
779
+ if (!paths.includes(path)) {
780
+ paths.push(path);
781
+ pathState.set(questionId, paths);
782
+ manager.render(questionId);
783
+ onUpdate();
784
+ }
785
+ },
786
+
787
+ removePath(questionId, path) {
788
+ const paths = pathState.get(questionId) || [];
789
+ const index = paths.indexOf(path);
790
+ if (index > -1) {
791
+ paths.splice(index, 1);
792
+ pathState.set(questionId, paths);
793
+ manager.render(questionId);
794
+ onUpdate();
795
+ }
796
+ },
797
+
798
+ getFile(questionId) {
799
+ return fileState.get(questionId);
800
+ },
801
+
802
+ getPaths(questionId) {
803
+ return pathState.get(questionId) || [];
804
+ },
805
+
806
+ hasContent(questionId) {
807
+ return fileState.has(questionId) || (pathState.get(questionId) || []).length > 0;
808
+ },
809
+
810
+ countFiles() {
811
+ return fileState.size;
812
+ },
813
+ };
814
+
815
+ return manager;
816
+ }
817
+
818
+ const questionImages = createImageManager({
819
+ fileState: imageState,
820
+ pathState: imagePathState,
821
+ containerSelector: (id) => `[data-selected-for="${escapeSelector(id)}"]`,
822
+ onUpdate: debounceSave,
823
+ });
824
+
825
+ const attachments = createImageManager({
826
+ fileState: attachState,
827
+ pathState: attachPathState,
828
+ containerSelector: (id) => `[data-attach-items-for="${escapeSelector(id)}"]`,
829
+ onUpdate: debounceSave,
830
+ removeLabel: "x",
831
+ onRenderComplete: (questionId, manager) => {
832
+ const btn = document.querySelector(
833
+ `.attach-btn[data-question-id="${escapeSelector(questionId)}"]`
834
+ );
835
+ const panel = document.querySelector(
836
+ `[data-attach-inline-for="${escapeSelector(questionId)}"]`
837
+ );
838
+ const hasContent = manager.hasContent(questionId);
839
+ if (btn) btn.classList.toggle("has-attachment", hasContent);
840
+ if (panel && hasContent) panel.classList.remove("hidden");
841
+ },
842
+ });
843
+
844
+ function updateDoneState(questionId) {
845
+ const doneItem = document.querySelector(`[data-done-for="${escapeSelector(questionId)}"]`);
846
+ if (!doneItem) return;
847
+ const hasSelection = document.querySelectorAll(`input[name="${escapeSelector(questionId)}"]:checked`).length > 0;
848
+ doneItem.classList.toggle("disabled", !hasSelection);
849
+ }
850
+
851
+ function clearGlobalError() {
852
+ if (!errorContainer) return;
853
+ errorContainer.textContent = "";
854
+ errorContainer.classList.add("hidden");
855
+ }
856
+
857
+ function showGlobalError(message) {
858
+ if (!errorContainer) return;
859
+ errorContainer.textContent = message;
860
+ errorContainer.classList.remove("hidden");
861
+ }
862
+
863
+ function setFieldError(id, message) {
864
+ const field = document.querySelector(`[data-error-for="${escapeSelector(id)}"]`);
865
+ if (!field) return;
866
+ field.textContent = message || "";
867
+ }
868
+
869
+ function clearFieldErrors() {
870
+ const fields = document.querySelectorAll(".field-error");
871
+ fields.forEach((el) => {
872
+ el.textContent = "";
873
+ });
874
+ }
875
+
876
+ const formFooter = document.querySelector('.form-footer');
877
+
878
+ function getOptionsForCard(card) {
879
+ const inputs = Array.from(card.querySelectorAll('input[type="radio"], input[type="checkbox"]'));
880
+ const dropzone = card.querySelector('.file-dropzone');
881
+ const pathInput = card.querySelector('.image-path-input');
882
+ const doneItem = card.querySelector('.done-item');
883
+
884
+ const items = [...inputs];
885
+ if (dropzone) items.push(dropzone);
886
+ if (pathInput) items.push(pathInput);
887
+ if (doneItem) items.push(doneItem);
888
+
889
+ return items;
890
+ }
891
+
892
+ function isPathInput(el) {
893
+ return el && (el.classList.contains('image-path-input') || el.classList.contains('attach-inline-path') || el.classList.contains('other-input'));
894
+ }
895
+
896
+ function isDropzone(el) {
897
+ return el && el.classList.contains('file-dropzone');
898
+ }
899
+
900
+ function isOptionInput(el) {
901
+ return el && (el.type === 'radio' || el.type === 'checkbox');
902
+ }
903
+
904
+ function isDoneItem(el) {
905
+ return el && el.classList.contains('done-item');
906
+ }
907
+
908
+ function setupDropzone(dropzone, fileInput) {
909
+ dropzone.addEventListener("dragover", (e) => {
910
+ e.preventDefault();
911
+ e.stopPropagation();
912
+ dropzone.classList.add("dragover");
913
+ });
914
+ dropzone.addEventListener("dragleave", () => {
915
+ dropzone.classList.remove("dragover");
916
+ });
917
+ dropzone.addEventListener("drop", (e) => {
918
+ e.preventDefault();
919
+ e.stopPropagation();
920
+ dropzone.classList.remove("dragover");
921
+ const files = e.dataTransfer?.files;
922
+ if (files && files.length > 0) {
923
+ const dt = new DataTransfer();
924
+ dt.items.add(files[0]);
925
+ fileInput.files = dt.files;
926
+ fileInput.dispatchEvent(new Event("change"));
927
+ }
928
+ });
929
+ }
930
+
931
+ function setupEdgeNavigation(element) {
932
+ element.addEventListener("keydown", (e) => {
933
+ if (e.key === "ArrowRight" && element.selectionStart === element.value.length) {
934
+ e.preventDefault();
935
+ e.stopPropagation();
936
+ nextQuestion();
937
+ }
938
+ if (e.key === "ArrowLeft" && element.selectionStart === 0) {
939
+ e.preventDefault();
940
+ e.stopPropagation();
941
+ prevQuestion();
942
+ }
943
+ });
944
+ }
945
+
946
+ function highlightOption(card, optionIndex, isKeyboard = true) {
947
+ const options = getOptionsForCard(card);
948
+ options.forEach((opt, i) => {
949
+ const item = isOptionInput(opt) ? opt.closest('.option-item') : opt;
950
+ item?.classList.toggle('focused', i === optionIndex);
951
+ });
952
+ const current = options[optionIndex];
953
+ if (current) {
954
+ current.focus();
955
+ }
956
+ if (isKeyboard) {
957
+ card.classList.add('keyboard-nav');
958
+ }
959
+ }
960
+
961
+ function clearOptionHighlight(card) {
962
+ card.querySelectorAll('.option-item, .done-item, .file-dropzone, .image-path-input').forEach(item => {
963
+ item.classList.remove('focused');
964
+ });
965
+ }
966
+
967
+ function ensureElementVisible(el) {
968
+ const rect = el.getBoundingClientRect();
969
+ const margin = 80;
970
+ if (rect.top < margin || rect.bottom > window.innerHeight - margin) {
971
+ el.scrollIntoView({ behavior: 'auto', block: 'nearest' });
972
+ }
973
+ }
974
+
975
+ function focusQuestion(index, fromDirection = 'next') {
976
+ if (index < 0 || index >= nav.cards.length) return;
977
+
978
+ deactivateSubmitArea();
979
+
980
+ const prevCard = nav.cards[nav.questionIndex];
981
+ if (prevCard) {
982
+ prevCard.classList.remove('active', 'keyboard-nav');
983
+ clearOptionHighlight(prevCard);
984
+ }
985
+
986
+ nav.questionIndex = index;
987
+ const card = nav.cards[index];
988
+ card.classList.add('active');
989
+ ensureElementVisible(card);
990
+
991
+ const options = getOptionsForCard(card);
992
+ const dropzone = card.querySelector('.file-dropzone');
993
+ const textarea = card.querySelector('textarea');
994
+
995
+ if (dropzone) {
996
+ nav.optionIndex = 0;
997
+ highlightOption(card, nav.optionIndex);
998
+ } else if (options.length > 0) {
999
+ nav.optionIndex = fromDirection === 'prev' ? options.length - 1 : 0;
1000
+ highlightOption(card, nav.optionIndex);
1001
+ } else if (textarea) {
1002
+ textarea.focus();
1003
+ if (fromDirection === 'prev') {
1004
+ textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
1005
+ }
1006
+ }
1007
+
1008
+ }
1009
+
1010
+ function nextQuestion() {
1011
+ if (nav.questionIndex < nav.cards.length - 1) {
1012
+ focusQuestion(nav.questionIndex + 1, 'next');
1013
+ } else {
1014
+ activateSubmitArea();
1015
+ }
1016
+ }
1017
+
1018
+ function activateSubmitArea() {
1019
+ const prevCard = nav.cards[nav.questionIndex];
1020
+ if (prevCard) {
1021
+ prevCard.classList.remove('active', 'keyboard-nav');
1022
+ clearOptionHighlight(prevCard);
1023
+ }
1024
+ nav.inSubmitArea = true;
1025
+ formFooter?.classList.add('active');
1026
+ submitBtn.focus();
1027
+ if (formFooter) ensureElementVisible(formFooter);
1028
+ }
1029
+
1030
+ function deactivateSubmitArea() {
1031
+ nav.inSubmitArea = false;
1032
+ formFooter?.classList.remove('active');
1033
+ }
1034
+
1035
+ function prevQuestion() {
1036
+ if (nav.questionIndex > 0) {
1037
+ focusQuestion(nav.questionIndex - 1, 'prev');
1038
+ }
1039
+ }
1040
+
1041
+ function handleQuestionKeydown(event) {
1042
+ if (event.key === 'Escape') {
1043
+ if (!expiredOverlay.classList.contains('hidden')) {
1044
+ if (timers.countdown) clearInterval(timers.countdown);
1045
+ cancelInterview("user").finally(() => window.close());
1046
+ return;
1047
+ }
1048
+ showSessionExpired();
1049
+ return;
1050
+ }
1051
+
1052
+ const isMeta = event.metaKey || event.ctrlKey;
1053
+ if (event.key === 'Enter' && isMeta) {
1054
+ event.preventDefault();
1055
+ formEl.requestSubmit();
1056
+ return;
1057
+ }
1058
+
1059
+ if (maybeStartOtherInput(event)) return;
1060
+
1061
+ if (nav.inSubmitArea) return;
1062
+
1063
+ const card = nav.cards[nav.questionIndex];
1064
+ if (!card) return;
1065
+
1066
+ const options = getOptionsForCard(card);
1067
+ const textarea = card.querySelector('textarea');
1068
+ const isTextFocused = document.activeElement === textarea;
1069
+
1070
+ if (event.key === 'Tab') {
1071
+ const inAttachArea = document.activeElement?.closest('.attach-inline');
1072
+ if (inAttachArea) return;
1073
+
1074
+ event.preventDefault();
1075
+
1076
+ if (options.length > 0) {
1077
+ if (event.shiftKey) {
1078
+ nav.optionIndex = (nav.optionIndex - 1 + options.length) % options.length;
1079
+ } else {
1080
+ nav.optionIndex = (nav.optionIndex + 1) % options.length;
1081
+ }
1082
+ highlightOption(card, nav.optionIndex);
1083
+ }
1084
+ return;
1085
+ }
1086
+
1087
+ if (event.key === 'ArrowLeft') {
1088
+ if (isTextFocused || isPathInput(document.activeElement)) {
1089
+ return;
1090
+ }
1091
+ event.preventDefault();
1092
+ prevQuestion();
1093
+ return;
1094
+ }
1095
+
1096
+ if (event.key === 'ArrowRight') {
1097
+ if (isTextFocused || isPathInput(document.activeElement)) {
1098
+ return;
1099
+ }
1100
+ event.preventDefault();
1101
+ nextQuestion();
1102
+ return;
1103
+ }
1104
+
1105
+ if (options.length > 0) {
1106
+ if (event.key === 'ArrowDown') {
1107
+ event.preventDefault();
1108
+ nav.optionIndex = (nav.optionIndex + 1) % options.length;
1109
+ highlightOption(card, nav.optionIndex);
1110
+ return;
1111
+ }
1112
+
1113
+ if (event.key === 'ArrowUp') {
1114
+ event.preventDefault();
1115
+ nav.optionIndex = (nav.optionIndex - 1 + options.length) % options.length;
1116
+ highlightOption(card, nav.optionIndex);
1117
+ return;
1118
+ }
1119
+
1120
+ if (event.key === 'Enter' || event.key === ' ') {
1121
+ if (isPathInput(document.activeElement)) {
1122
+ return;
1123
+ }
1124
+ if (document.activeElement?.closest('.attach-inline')) {
1125
+ return;
1126
+ }
1127
+ event.preventDefault();
1128
+ const option = options[nav.optionIndex];
1129
+ if (option) {
1130
+ if (isDoneItem(option)) {
1131
+ if (!option.classList.contains('disabled')) {
1132
+ nextQuestion();
1133
+ }
1134
+ } else if (isDropzone(option)) {
1135
+ if (!filePickerOpen) {
1136
+ filePickerOpen = true;
1137
+ const fileInput = card.querySelector('input[type="file"]');
1138
+ if (fileInput) fileInput.click();
1139
+ }
1140
+ } else if (option.type === 'radio') {
1141
+ option.checked = true;
1142
+ debounceSave();
1143
+ if (option.value === '__other__') {
1144
+ const otherInput = card.querySelector('.other-input');
1145
+ if (otherInput) otherInput.focus();
1146
+ } else {
1147
+ nextQuestion();
1148
+ }
1149
+ } else if (option.type === 'checkbox') {
1150
+ option.checked = !option.checked;
1151
+ debounceSave();
1152
+ const questionId = option.name;
1153
+ updateDoneState(questionId);
1154
+ if (option.value === '__other__' && option.checked) {
1155
+ const otherInput = card.querySelector('.other-input');
1156
+ if (otherInput) otherInput.focus();
1157
+ }
1158
+ }
1159
+ }
1160
+ return;
1161
+ }
1162
+ }
1163
+
1164
+ if (textarea && !isTextFocused) {
1165
+ if (event.key === 'Enter') {
1166
+ event.preventDefault();
1167
+ textarea.focus();
1168
+ return;
1169
+ }
1170
+ }
1171
+
1172
+ if (isTextFocused && event.key === 'Enter' && !event.shiftKey) {
1173
+ event.preventDefault();
1174
+ nextQuestion();
1175
+ return;
1176
+ }
1177
+
1178
+ if (document.activeElement?.type === 'file') {
1179
+ if (event.key === 'Enter' || event.key === ' ') {
1180
+ return;
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ function initQuestionNavigation() {
1186
+ nav.cards = Array.from(containerEl.querySelectorAll('.question-card'));
1187
+
1188
+ nav.cards.forEach((card, index) => {
1189
+ card.setAttribute('tabindex', '0');
1190
+ card.addEventListener('focus', () => {
1191
+ if (nav.questionIndex !== index) {
1192
+ focusQuestion(index);
1193
+ }
1194
+ });
1195
+ card.addEventListener('click', (e) => {
1196
+ card.classList.remove('keyboard-nav');
1197
+ if (nav.questionIndex !== index) {
1198
+ if (e.target.closest('.option-item')) {
1199
+ nav.questionIndex = index;
1200
+ const prevCard = nav.cards.find(c => c.classList.contains('active'));
1201
+ if (prevCard && prevCard !== card) {
1202
+ prevCard.classList.remove('active', 'keyboard-nav');
1203
+ clearOptionHighlight(prevCard);
1204
+ }
1205
+ card.classList.add('active');
1206
+ } else {
1207
+ focusQuestion(index);
1208
+ }
1209
+ }
1210
+ });
1211
+ });
1212
+
1213
+ containerEl.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach(input => {
1214
+ input.setAttribute('tabindex', '-1');
1215
+ });
1216
+
1217
+ document.addEventListener('keydown', handleQuestionKeydown);
1218
+
1219
+ if (nav.cards.length > 0) {
1220
+ setTimeout(() => focusQuestion(0), 100);
1221
+ }
1222
+ }
1223
+
1224
+ function createQuestionCard(question, index) {
1225
+ const card = document.createElement("section");
1226
+ card.className = "question-card";
1227
+ card.setAttribute("role", "listitem");
1228
+ card.dataset.questionId = question.id;
1229
+
1230
+ const title = document.createElement("h2");
1231
+ title.className = "question-title";
1232
+ title.id = `q-${question.id}-title`;
1233
+ title.innerHTML = `${index + 1}. ${renderLightMarkdown(question.question)}`;
1234
+ card.appendChild(title);
1235
+
1236
+ if (question.context) {
1237
+ const context = document.createElement("p");
1238
+ context.className = "question-context";
1239
+ context.innerHTML = renderLightMarkdown(question.context);
1240
+ card.appendChild(context);
1241
+ }
1242
+
1243
+ if (question.codeBlock) {
1244
+ const codeBlockEl = renderCodeBlock(question.codeBlock);
1245
+ if (codeBlockEl) {
1246
+ codeBlockEl.classList.add("question-code-block");
1247
+ card.appendChild(codeBlockEl);
1248
+ }
1249
+ }
1250
+
1251
+ if (question.type === "single" || question.type === "multi") {
1252
+ const list = document.createElement("div");
1253
+ list.className = "option-list";
1254
+ list.setAttribute("role", question.type === "single" ? "radiogroup" : "group");
1255
+ list.setAttribute("aria-labelledby", title.id);
1256
+
1257
+ const recommended = question.recommended;
1258
+ const recommendedList = Array.isArray(recommended)
1259
+ ? recommended
1260
+ : recommended
1261
+ ? [recommended]
1262
+ : [];
1263
+
1264
+ question.options.forEach((option, optionIndex) => {
1265
+ const optionLabel = getOptionLabel(option);
1266
+ const optionCode = isRichOption(option) ? option.code : null;
1267
+
1268
+ const label = document.createElement("label");
1269
+ label.className = "option-item";
1270
+ if (optionCode) {
1271
+ label.classList.add("has-code");
1272
+ }
1273
+
1274
+ const input = document.createElement("input");
1275
+ input.type = question.type === "single" ? "radio" : "checkbox";
1276
+ input.name = question.id;
1277
+ input.value = optionLabel;
1278
+ input.id = `q-${question.id}-${optionIndex}`;
1279
+
1280
+ input.addEventListener("change", () => {
1281
+ debounceSave();
1282
+ if (question.type === "multi") {
1283
+ updateDoneState(question.id);
1284
+ }
1285
+ });
1286
+
1287
+ const text = document.createElement("span");
1288
+ text.textContent = optionLabel;
1289
+
1290
+ if (recommendedList.includes(optionLabel)) {
1291
+ const star = document.createElement("span");
1292
+ star.className = "recommended-star";
1293
+ star.textContent = "*";
1294
+ text.appendChild(star);
1295
+ }
1296
+
1297
+ label.appendChild(input);
1298
+ label.appendChild(text);
1299
+
1300
+ if (optionCode) {
1301
+ const codeBlockEl = renderCodeBlock(optionCode);
1302
+ if (codeBlockEl) {
1303
+ label.appendChild(codeBlockEl);
1304
+ }
1305
+ }
1306
+
1307
+ list.appendChild(label);
1308
+ });
1309
+
1310
+ const otherLabel = document.createElement("label");
1311
+ otherLabel.className = "option-item option-other";
1312
+ const otherCheck = document.createElement("input");
1313
+ otherCheck.type = question.type === "single" ? "radio" : "checkbox";
1314
+ otherCheck.name = question.id;
1315
+ otherCheck.value = "__other__";
1316
+ otherCheck.id = `q-${question.id}-other`;
1317
+ const otherInput = document.createElement("input");
1318
+ otherInput.type = "text";
1319
+ otherInput.className = "other-input";
1320
+ otherInput.placeholder = "Other...";
1321
+ otherInput.dataset.questionId = question.id;
1322
+ otherInput.addEventListener("input", () => {
1323
+ if (otherInput.value && !otherCheck.checked) {
1324
+ otherCheck.checked = true;
1325
+ if (question.type === "multi") updateDoneState(question.id);
1326
+ }
1327
+ debounceSave();
1328
+ });
1329
+ otherInput.addEventListener("focus", () => {
1330
+ if (!otherCheck.checked) {
1331
+ otherCheck.checked = true;
1332
+ if (question.type === "multi") updateDoneState(question.id);
1333
+ debounceSave();
1334
+ }
1335
+ });
1336
+ otherCheck.addEventListener("change", () => {
1337
+ debounceSave();
1338
+ if (question.type === "multi") updateDoneState(question.id);
1339
+ if (otherCheck.checked) otherInput.focus();
1340
+ });
1341
+ setupEdgeNavigation(otherInput);
1342
+ otherLabel.appendChild(otherCheck);
1343
+ otherLabel.appendChild(otherInput);
1344
+ list.appendChild(otherLabel);
1345
+
1346
+ if (question.type === "multi") {
1347
+ const doneItem = document.createElement("div");
1348
+ doneItem.className = "option-item done-item disabled";
1349
+ doneItem.setAttribute("tabindex", "0");
1350
+ doneItem.dataset.doneFor = question.id;
1351
+ doneItem.innerHTML = '<span class="done-check">✓</span><span>Done</span>';
1352
+ doneItem.addEventListener("click", () => {
1353
+ if (!doneItem.classList.contains("disabled")) {
1354
+ nextQuestion();
1355
+ }
1356
+ });
1357
+ doneItem.addEventListener("keydown", (e) => {
1358
+ if ((e.key === "Enter" || e.key === " ") && !doneItem.classList.contains("disabled")) {
1359
+ e.preventDefault();
1360
+ e.stopPropagation();
1361
+ nextQuestion();
1362
+ }
1363
+ });
1364
+ list.appendChild(doneItem);
1365
+ }
1366
+
1367
+ card.appendChild(list);
1368
+ }
1369
+
1370
+ if (question.type === "text") {
1371
+ const textarea = document.createElement("textarea");
1372
+ textarea.dataset.questionId = question.id;
1373
+ textarea.addEventListener("input", () => {
1374
+ debounceSave();
1375
+ });
1376
+ setupEdgeNavigation(textarea);
1377
+ card.appendChild(textarea);
1378
+ }
1379
+
1380
+ if (question.type === "image") {
1381
+ imagePathState.set(question.id, []);
1382
+
1383
+ const wrapper = document.createElement("div");
1384
+ wrapper.className = "file-input";
1385
+
1386
+ const input = document.createElement("input");
1387
+ input.type = "file";
1388
+ input.accept = "image/png,image/jpeg,image/gif,image/webp";
1389
+ input.dataset.questionId = question.id;
1390
+
1391
+ input.addEventListener("change", () => {
1392
+ setTimeout(() => { filePickerOpen = false; }, 200);
1393
+ clearGlobalError();
1394
+ handleFileChange(question.id, input, questionImages, {
1395
+ onEmpty: () => clearImage(question.id),
1396
+ });
1397
+ });
1398
+ input.addEventListener("cancel", () => {
1399
+ setTimeout(() => { filePickerOpen = false; }, 200);
1400
+ });
1401
+ input.addEventListener("blur", () => {
1402
+ setTimeout(() => { filePickerOpen = false; }, 500);
1403
+ });
1404
+
1405
+ const dropzone = document.createElement("div");
1406
+ dropzone.className = "file-dropzone";
1407
+ dropzone.setAttribute("tabindex", "0");
1408
+ dropzone.innerHTML = `
1409
+ <span class="file-dropzone-icon">+</span>
1410
+ <span class="file-dropzone-text">Click to upload</span>
1411
+ <span class="file-dropzone-hint">PNG, JPG, GIF, WebP (max 5MB)</span>
1412
+ `;
1413
+
1414
+ const pathInput = document.createElement("input");
1415
+ pathInput.type = "text";
1416
+ pathInput.className = "image-path-input";
1417
+ pathInput.placeholder = "Or paste image path/URL and press Enter...";
1418
+ pathInput.dataset.questionId = question.id;
1419
+ pathInput.addEventListener("keydown", (e) => {
1420
+ if (e.key === "Enter" && pathInput.value.trim()) {
1421
+ e.preventDefault();
1422
+ e.stopPropagation();
1423
+ questionImages.addPath(question.id, normalizePath(pathInput.value.trim()));
1424
+ pathInput.value = "";
1425
+ }
1426
+ });
1427
+ setupEdgeNavigation(pathInput);
1428
+
1429
+ const selectedItems = document.createElement("div");
1430
+ selectedItems.className = "image-selected-items";
1431
+ selectedItems.dataset.selectedFor = question.id;
1432
+ dropzone.addEventListener("click", () => {
1433
+ if (!filePickerOpen) {
1434
+ filePickerOpen = true;
1435
+ input.click();
1436
+ }
1437
+ });
1438
+ dropzone.addEventListener("keydown", (e) => {
1439
+ if (e.key === "Enter" || e.key === " ") {
1440
+ e.preventDefault();
1441
+ e.stopPropagation();
1442
+ if (!filePickerOpen) {
1443
+ filePickerOpen = true;
1444
+ input.click();
1445
+ }
1446
+ }
1447
+ if (e.key === "ArrowRight") {
1448
+ e.preventDefault();
1449
+ e.stopPropagation();
1450
+ nextQuestion();
1451
+ }
1452
+ if (e.key === "ArrowLeft") {
1453
+ e.preventDefault();
1454
+ e.stopPropagation();
1455
+ prevQuestion();
1456
+ }
1457
+ });
1458
+
1459
+ setupDropzone(dropzone, input);
1460
+
1461
+ wrapper.appendChild(input);
1462
+ wrapper.appendChild(dropzone);
1463
+ wrapper.appendChild(pathInput);
1464
+ wrapper.appendChild(selectedItems);
1465
+ card.appendChild(wrapper);
1466
+ }
1467
+
1468
+ if (question.type !== "image") {
1469
+ attachPathState.set(question.id, []);
1470
+
1471
+ const attachHint = document.createElement("div");
1472
+ attachHint.className = "attach-hint";
1473
+
1474
+ const attachBtn = document.createElement("button");
1475
+ attachBtn.type = "button";
1476
+ attachBtn.className = "attach-btn";
1477
+ attachBtn.innerHTML = '<span>+</span> attach';
1478
+ attachBtn.dataset.questionId = question.id;
1479
+
1480
+ const attachInline = document.createElement("div");
1481
+ attachInline.className = "attach-inline hidden";
1482
+ attachInline.dataset.attachInlineFor = question.id;
1483
+
1484
+ const attachFileInput = document.createElement("input");
1485
+ attachFileInput.type = "file";
1486
+ attachFileInput.accept = "image/png,image/jpeg,image/gif,image/webp";
1487
+ attachFileInput.style.cssText = "position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;";
1488
+
1489
+ const attachDrop = document.createElement("div");
1490
+ attachDrop.className = "attach-inline-drop";
1491
+ attachDrop.setAttribute("tabindex", "0");
1492
+ attachDrop.textContent = "Drop image or click";
1493
+
1494
+ const attachPath = document.createElement("input");
1495
+ attachPath.type = "text";
1496
+ attachPath.className = "attach-inline-path";
1497
+ attachPath.placeholder = "Or paste path/URL and press Enter";
1498
+
1499
+ const attachItems = document.createElement("div");
1500
+ attachItems.className = "attach-inline-items";
1501
+ attachItems.dataset.attachItemsFor = question.id;
1502
+
1503
+ attachBtn.addEventListener("click", () => {
1504
+ const isHidden = attachInline.classList.contains("hidden");
1505
+ attachInline.classList.toggle("hidden", !isHidden);
1506
+ if (isHidden) attachDrop.focus();
1507
+ });
1508
+
1509
+ attachFileInput.addEventListener("change", () => {
1510
+ setTimeout(() => { filePickerOpen = false; }, 200);
1511
+ handleFileChange(question.id, attachFileInput, attachments);
1512
+ });
1513
+
1514
+ attachDrop.addEventListener("click", () => {
1515
+ if (!filePickerOpen) {
1516
+ filePickerOpen = true;
1517
+ attachFileInput.click();
1518
+ }
1519
+ });
1520
+ attachDrop.addEventListener("keydown", (e) => {
1521
+ if (e.key === "Enter" || e.key === " ") {
1522
+ e.preventDefault();
1523
+ if (!filePickerOpen) {
1524
+ filePickerOpen = true;
1525
+ attachFileInput.click();
1526
+ }
1527
+ }
1528
+ if (e.key === "Tab") {
1529
+ e.preventDefault();
1530
+ if (e.shiftKey) {
1531
+ attachBtn.focus();
1532
+ } else {
1533
+ attachPath.focus();
1534
+ }
1535
+ }
1536
+ if (e.key === "Escape") {
1537
+ attachBtn.click();
1538
+ attachBtn.focus();
1539
+ }
1540
+ });
1541
+ setupDropzone(attachDrop, attachFileInput);
1542
+
1543
+ attachPath.addEventListener("keydown", (e) => {
1544
+ if (e.key === "Enter" && attachPath.value.trim()) {
1545
+ e.preventDefault();
1546
+ attachments.addPath(question.id, normalizePath(attachPath.value.trim()));
1547
+ attachPath.value = "";
1548
+ }
1549
+ if (e.key === "Tab") {
1550
+ e.preventDefault();
1551
+ if (e.shiftKey) {
1552
+ attachDrop.focus();
1553
+ } else {
1554
+ attachBtn.click();
1555
+ attachBtn.focus();
1556
+ }
1557
+ }
1558
+ if (e.key === "Escape") {
1559
+ attachBtn.click();
1560
+ attachBtn.focus();
1561
+ }
1562
+ });
1563
+ setupEdgeNavigation(attachPath);
1564
+
1565
+ attachInline.appendChild(attachFileInput);
1566
+ attachInline.appendChild(attachDrop);
1567
+ attachInline.appendChild(attachPath);
1568
+ attachInline.appendChild(attachItems);
1569
+
1570
+ attachHint.appendChild(attachBtn);
1571
+ card.appendChild(attachHint);
1572
+ card.appendChild(attachInline);
1573
+ }
1574
+
1575
+ const error = document.createElement("div");
1576
+ error.className = "field-error";
1577
+ error.dataset.errorFor = question.id;
1578
+ error.setAttribute("aria-live", "polite");
1579
+ card.appendChild(error);
1580
+
1581
+ card.addEventListener("dragover", (e) => {
1582
+ e.preventDefault();
1583
+ card.classList.add("dragover");
1584
+ });
1585
+ card.addEventListener("dragleave", (e) => {
1586
+ if (!card.contains(e.relatedTarget)) {
1587
+ card.classList.remove("dragover");
1588
+ }
1589
+ });
1590
+ card.addEventListener("drop", (e) => {
1591
+ e.preventDefault();
1592
+ card.classList.remove("dragover");
1593
+ const files = e.dataTransfer?.files;
1594
+ if (files && files.length > 0) {
1595
+ const file = files[0];
1596
+ if (!file.type.startsWith("image/")) return;
1597
+ if (question.type === "image") {
1598
+ const input = card.querySelector('input[type="file"]');
1599
+ if (input) {
1600
+ const dt = new DataTransfer();
1601
+ dt.items.add(file);
1602
+ input.files = dt.files;
1603
+ input.dispatchEvent(new Event("change"));
1604
+ }
1605
+ } else {
1606
+ void addPastedImage(question, file);
1607
+ }
1608
+ }
1609
+ });
1610
+
1611
+ return card;
1612
+ }
1613
+
1614
+ function loadImage(file) {
1615
+ return new Promise((resolve, reject) => {
1616
+ const img = new Image();
1617
+ const url = URL.createObjectURL(file);
1618
+ img.onload = () => resolve(img);
1619
+ img.onerror = () => {
1620
+ URL.revokeObjectURL(url);
1621
+ reject(new Error("Failed to load image"));
1622
+ };
1623
+ img.src = url;
1624
+ });
1625
+ }
1626
+
1627
+ async function validateImage(file) {
1628
+ if (!ALLOWED_TYPES.includes(file.type)) {
1629
+ return { valid: false, error: "Invalid file type. Use PNG, JPG, GIF, or WebP." };
1630
+ }
1631
+ if (file.size > MAX_SIZE) {
1632
+ return { valid: false, error: "Image exceeds 5MB limit." };
1633
+ }
1634
+
1635
+ const img = await loadImage(file);
1636
+ if (img.src) URL.revokeObjectURL(img.src);
1637
+ if (img.width > MAX_DIMENSION || img.height > MAX_DIMENSION) {
1638
+ return { valid: false, error: `Image exceeds ${MAX_DIMENSION}x${MAX_DIMENSION} limit.` };
1639
+ }
1640
+ return { valid: true };
1641
+ }
1642
+
1643
+ function clearImage(id) {
1644
+ const input = document.querySelector(
1645
+ `input[type="file"][data-question-id="${escapeSelector(id)}"]`
1646
+ );
1647
+ if (input) input.value = "";
1648
+ questionImages.removeFile(id);
1649
+ setFieldError(id, "");
1650
+ }
1651
+
1652
+ async function handleFileChange(questionId, input, manager, options = {}) {
1653
+ const { onEmpty } = options;
1654
+ setFieldError(questionId, "");
1655
+
1656
+ const file = input.files && input.files[0];
1657
+ if (!file) {
1658
+ if (onEmpty) onEmpty();
1659
+ else manager.removeFile(questionId);
1660
+ return;
1661
+ }
1662
+
1663
+ if (countUploadedFiles(questionId) + 1 > MAX_IMAGES) {
1664
+ setFieldError(questionId, `Only ${MAX_IMAGES} images allowed.`);
1665
+ input.value = "";
1666
+ return;
1667
+ }
1668
+
1669
+ try {
1670
+ const validation = await validateImage(file);
1671
+ if (!validation.valid) {
1672
+ setFieldError(questionId, validation.error);
1673
+ input.value = "";
1674
+ return;
1675
+ }
1676
+ } catch (_err) {
1677
+ setFieldError(questionId, "Failed to validate image.");
1678
+ input.value = "";
1679
+ return;
1680
+ }
1681
+
1682
+ manager.addFile(questionId, file);
1683
+ }
1684
+
1685
+ function resolveQuestionContext(target) {
1686
+ const element = target && target.closest ? target : null;
1687
+ let card = element ? element.closest(".question-card") : null;
1688
+
1689
+ if (!card) {
1690
+ card = document.querySelector(".question-card.active");
1691
+ }
1692
+
1693
+ if (card?.dataset?.questionId) {
1694
+ const question = questions.find((q) => q.id === card.dataset.questionId);
1695
+ if (question) {
1696
+ return { question, card };
1697
+ }
1698
+ }
1699
+
1700
+ const question = questions[nav.questionIndex];
1701
+ const fallbackCard = nav.cards[nav.questionIndex];
1702
+ if (!question || !fallbackCard) return null;
1703
+ return { question, card: fallbackCard };
1704
+ }
1705
+
1706
+ function revealAttachmentArea(questionId) {
1707
+ const attachInline = document.querySelector(
1708
+ `[data-attach-inline-for="${escapeSelector(questionId)}"]`
1709
+ );
1710
+ if (attachInline?.classList.contains("hidden")) {
1711
+ attachInline.classList.remove("hidden");
1712
+ }
1713
+ }
1714
+
1715
+ async function addPastedImage(question, file) {
1716
+ if (countUploadedFiles(question.id) + 1 > MAX_IMAGES) {
1717
+ setFieldError(question.id, `Only ${MAX_IMAGES} images allowed.`);
1718
+ return;
1719
+ }
1720
+
1721
+ try {
1722
+ const validation = await validateImage(file);
1723
+ if (!validation.valid) {
1724
+ setFieldError(question.id, validation.error);
1725
+ return;
1726
+ }
1727
+ } catch (_err) {
1728
+ setFieldError(question.id, "Failed to validate image.");
1729
+ return;
1730
+ }
1731
+
1732
+ setFieldError(question.id, "");
1733
+ if (question.type === "image") {
1734
+ questionImages.addFile(question.id, file);
1735
+ } else {
1736
+ revealAttachmentArea(question.id);
1737
+ attachments.addFile(question.id, file);
1738
+ }
1739
+ }
1740
+
1741
+ function handlePaste(event) {
1742
+ if (nav.inSubmitArea || session.expired) return;
1743
+ const clipboard = event.clipboardData;
1744
+ if (!clipboard) return;
1745
+
1746
+ const context = resolveQuestionContext(event.target);
1747
+ if (!context) return;
1748
+
1749
+ const items = Array.from(clipboard.items || []);
1750
+ const imageItem = items.find((item) => item.type && item.type.startsWith("image/"));
1751
+
1752
+ if (imageItem) {
1753
+ const file = imageItem.getAsFile();
1754
+ if (!file) return;
1755
+ event.preventDefault();
1756
+ void addPastedImage(context.question, file);
1757
+ return;
1758
+ }
1759
+
1760
+ const text = clipboard.getData("text/plain")?.trim();
1761
+ const isPathLike = text && (text.startsWith("/") || text.startsWith("~") || text.match(/^[a-zA-Z]:\\/));
1762
+ const hasImageExtension = text && /\.(png|jpe?g|gif|webp)$/i.test(text);
1763
+
1764
+ if (isPathLike && hasImageExtension) {
1765
+ event.preventDefault();
1766
+ const normalizedPath = normalizePath(text);
1767
+ if (context.question.type === "image") {
1768
+ questionImages.addPath(context.question.id, normalizedPath);
1769
+ } else {
1770
+ revealAttachmentArea(context.question.id);
1771
+ attachments.addPath(context.question.id, normalizedPath);
1772
+ }
1773
+ }
1774
+ }
1775
+
1776
+ function countUploadedFiles(excludingId) {
1777
+ let count = 0;
1778
+ imageState.forEach((_value, key) => {
1779
+ if (key !== excludingId) count += 1;
1780
+ });
1781
+ attachState.forEach((_value, key) => {
1782
+ if (key !== excludingId) count += 1;
1783
+ });
1784
+ return count;
1785
+ }
1786
+
1787
+ function getOtherValue(questionId) {
1788
+ const otherInput = formEl.querySelector(`.other-input[data-question-id="${escapeSelector(questionId)}"]`);
1789
+ return otherInput ? otherInput.value : "";
1790
+ }
1791
+
1792
+ function getQuestionValue(question) {
1793
+ const id = question.id;
1794
+ if (question.type === "single") {
1795
+ const selected = formEl.querySelector(`input[name="${escapeSelector(id)}"]:checked`);
1796
+ if (!selected) return "";
1797
+ if (selected.value === "__other__") return getOtherValue(id);
1798
+ return selected.value;
1799
+ }
1800
+ if (question.type === "multi") {
1801
+ return Array.from(
1802
+ formEl.querySelectorAll(`input[name="${escapeSelector(id)}"]:checked`)
1803
+ ).map((input) => input.value === "__other__" ? getOtherValue(id) : input.value).filter(v => v);
1804
+ }
1805
+ if (question.type === "text") {
1806
+ const textarea = formEl.querySelector(`textarea[data-question-id="${escapeSelector(id)}"]`);
1807
+ return textarea ? textarea.value : "";
1808
+ }
1809
+ if (question.type === "image") {
1810
+ return questionImages.getPaths(id);
1811
+ }
1812
+ return "";
1813
+ }
1814
+
1815
+ function collectResponses() {
1816
+ return questions.map((question) => {
1817
+ const resp = { id: question.id, value: getQuestionValue(question) };
1818
+ if (question.type === "image") resp.type = "paths";
1819
+ if (question.type !== "image") {
1820
+ const attachPaths = attachments.getPaths(question.id);
1821
+ if (attachPaths.length > 0) resp.attachments = attachPaths;
1822
+ }
1823
+ return resp;
1824
+ });
1825
+ }
1826
+
1827
+ function collectPersistedData() {
1828
+ const data = {};
1829
+ questions.forEach((question) => {
1830
+ if (question.type !== "image") {
1831
+ data[question.id] = getQuestionValue(question);
1832
+ }
1833
+ });
1834
+ return data;
1835
+ }
1836
+
1837
+ function populateForm(saved) {
1838
+ if (!saved) return;
1839
+ questions.forEach((question) => {
1840
+ const value = saved[question.id];
1841
+ if (question.type === "single" && typeof value === "string") {
1842
+ const radios = formEl.querySelectorAll(
1843
+ `input[name="${escapeSelector(question.id)}"]`
1844
+ );
1845
+ radios.forEach((radio) => {
1846
+ radio.checked = false;
1847
+ });
1848
+ if (value !== "") {
1849
+ const input = formEl.querySelector(
1850
+ `input[name="${escapeSelector(question.id)}"][value="${escapeSelector(value)}"]`
1851
+ );
1852
+ if (input) {
1853
+ input.checked = true;
1854
+ } else {
1855
+ const otherCheck = formEl.querySelector(
1856
+ `input[name="${escapeSelector(question.id)}"][value="__other__"]`
1857
+ );
1858
+ const otherInput = formEl.querySelector(
1859
+ `.other-input[data-question-id="${escapeSelector(question.id)}"]`
1860
+ );
1861
+ if (otherCheck && otherInput) {
1862
+ otherCheck.checked = true;
1863
+ otherInput.value = value;
1864
+ }
1865
+ }
1866
+ }
1867
+ }
1868
+ if (question.type === "multi" && Array.isArray(value)) {
1869
+ const checkboxes = formEl.querySelectorAll(
1870
+ `input[name="${escapeSelector(question.id)}"]`
1871
+ );
1872
+ checkboxes.forEach((checkbox) => {
1873
+ checkbox.checked = false;
1874
+ });
1875
+ let otherValue = "";
1876
+ value.forEach((val) => {
1877
+ const input = formEl.querySelector(
1878
+ `input[name="${escapeSelector(question.id)}"][value="${escapeSelector(val)}"]`
1879
+ );
1880
+ if (input) {
1881
+ input.checked = true;
1882
+ } else if (val) {
1883
+ otherValue = val;
1884
+ }
1885
+ });
1886
+ if (otherValue) {
1887
+ const otherCheck = formEl.querySelector(
1888
+ `input[name="${escapeSelector(question.id)}"][value="__other__"]`
1889
+ );
1890
+ const otherInput = formEl.querySelector(
1891
+ `.other-input[data-question-id="${escapeSelector(question.id)}"]`
1892
+ );
1893
+ if (otherCheck && otherInput) {
1894
+ otherCheck.checked = true;
1895
+ otherInput.value = otherValue;
1896
+ }
1897
+ }
1898
+ }
1899
+ if (question.type === "text" && typeof value === "string") {
1900
+ const textarea = formEl.querySelector(
1901
+ `textarea[data-question-id="${escapeSelector(question.id)}"]`
1902
+ );
1903
+ if (textarea) textarea.value = value;
1904
+ }
1905
+ });
1906
+ }
1907
+
1908
+ function saveProgress() {
1909
+ if (!session.storageKey) return;
1910
+ const data = collectPersistedData();
1911
+ try {
1912
+ localStorage.setItem(session.storageKey, JSON.stringify(data));
1913
+ } catch (_err) {
1914
+ // ignore storage errors
1915
+ }
1916
+ }
1917
+
1918
+ function loadProgress() {
1919
+ if (!session.storageKey) return;
1920
+ try {
1921
+ const saved = localStorage.getItem(session.storageKey);
1922
+ if (saved) {
1923
+ populateForm(JSON.parse(saved));
1924
+ questions.forEach((q) => {
1925
+ if (q.type === "multi") {
1926
+ updateDoneState(q.id);
1927
+ }
1928
+ });
1929
+ }
1930
+ } catch (_err) {
1931
+ // ignore storage errors
1932
+ }
1933
+ }
1934
+
1935
+ function clearProgress() {
1936
+ if (!session.storageKey) return;
1937
+ try {
1938
+ localStorage.removeItem(session.storageKey);
1939
+ } catch (_err) {
1940
+ // ignore storage errors
1941
+ }
1942
+ }
1943
+
1944
+ async function hashQuestions() {
1945
+ const json = JSON.stringify(questions);
1946
+ const encoder = new TextEncoder();
1947
+ const data = encoder.encode(json);
1948
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1949
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
1950
+ const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
1951
+ return hashHex.slice(0, 8);
1952
+ }
1953
+
1954
+ async function initStorage() {
1955
+ try {
1956
+ const hash = await hashQuestions();
1957
+ session.storageKey = `pi-interview-${hash}`;
1958
+ loadProgress();
1959
+ } catch (_err) {
1960
+ session.storageKey = null;
1961
+ }
1962
+ }
1963
+
1964
+ function readFileBase64(file) {
1965
+ return new Promise((resolve, reject) => {
1966
+ const reader = new FileReader();
1967
+ reader.onload = () => {
1968
+ if (typeof reader.result !== "string") {
1969
+ reject(new Error("Failed to read file"));
1970
+ return;
1971
+ }
1972
+ const parts = reader.result.split(",");
1973
+ resolve(parts[1] || "");
1974
+ };
1975
+ reader.onerror = () => reject(new Error("Failed to read file"));
1976
+ reader.readAsDataURL(file);
1977
+ });
1978
+ }
1979
+
1980
+ async function buildPayload() {
1981
+ const responses = collectResponses();
1982
+ const images = [];
1983
+
1984
+ for (const question of questions) {
1985
+ const imageEntry = questionImages.getFile(question.id);
1986
+ if (imageEntry) {
1987
+ const file = imageEntry.file;
1988
+ const data = await readFileBase64(file);
1989
+ images.push({
1990
+ id: question.id,
1991
+ filename: file.name,
1992
+ mimeType: file.type,
1993
+ data,
1994
+ });
1995
+ }
1996
+
1997
+ if (question.type !== "image") {
1998
+ const attachEntry = attachments.getFile(question.id);
1999
+ if (attachEntry) {
2000
+ const file = attachEntry.file;
2001
+ const data = await readFileBase64(file);
2002
+ images.push({
2003
+ id: question.id,
2004
+ filename: file.name,
2005
+ mimeType: file.type,
2006
+ data,
2007
+ isAttachment: true,
2008
+ });
2009
+ }
2010
+ }
2011
+ }
2012
+
2013
+ return { responses, images };
2014
+ }
2015
+
2016
+ async function submitForm(event) {
2017
+ event.preventDefault();
2018
+ clearGlobalError();
2019
+ clearFieldErrors();
2020
+
2021
+ submitBtn.disabled = true;
2022
+
2023
+ try {
2024
+ const payload = await buildPayload();
2025
+ const response = await fetch("/submit", {
2026
+ method: "POST",
2027
+ headers: { "Content-Type": "application/json" },
2028
+ body: JSON.stringify({ token: sessionToken, ...payload }),
2029
+ });
2030
+
2031
+ const data = await response.json().catch(() => ({ ok: false, error: "Invalid server response" }));
2032
+
2033
+ if (!response.ok || !data.ok) {
2034
+ if (data.field) {
2035
+ setFieldError(data.field, data.error || "Invalid input");
2036
+ } else {
2037
+ showGlobalError(data.error || "Submission failed.");
2038
+ }
2039
+ submitBtn.disabled = false;
2040
+ return;
2041
+ }
2042
+
2043
+ clearProgress();
2044
+ stopHeartbeat();
2045
+ stopQueuePolling();
2046
+ session.ended = true;
2047
+ successOverlay.classList.remove("hidden");
2048
+ setTimeout(() => {
2049
+ window.close();
2050
+ }, 800);
2051
+ } catch (err) {
2052
+ if (isNetworkError(err)) {
2053
+ showSessionExpired();
2054
+ } else {
2055
+ showGlobalError("Failed to submit responses.");
2056
+ submitBtn.disabled = false;
2057
+ }
2058
+ }
2059
+ }
2060
+
2061
+ function init() {
2062
+ initTheme();
2063
+ clearReloadIntent();
2064
+
2065
+ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
2066
+ const modKey = document.querySelector(".mod-key");
2067
+ if (modKey) {
2068
+ modKey.textContent = isMac ? "⌘" : "Ctrl";
2069
+ }
2070
+
2071
+ setText(titleEl, data.title || "Interview");
2072
+ setText(descriptionEl, data.description || "");
2073
+
2074
+ const sessionProjectEl = document.getElementById("session-project");
2075
+ const sessionIdEl = document.getElementById("session-id");
2076
+ if (sessionProjectEl && cwd) {
2077
+ const pathDisplay = cwd.length > 40 ? "..." + cwd.slice(-37) : cwd;
2078
+ const branchSuffix = gitBranch ? ` (${gitBranch})` : "";
2079
+ sessionProjectEl.textContent = pathDisplay + branchSuffix;
2080
+ }
2081
+ if (sessionIdEl && sessionId) {
2082
+ sessionIdEl.textContent = sessionId.slice(0, 8);
2083
+ }
2084
+ const projectName = cwd.split("/").filter(Boolean).pop() || "interview";
2085
+ const shortId = sessionId.slice(0, 8);
2086
+ document.title = `${projectName}${gitBranch ? ` (${gitBranch})` : ""} | ${shortId}`;
2087
+
2088
+ questions.forEach((question, index) => {
2089
+ containerEl.appendChild(createQuestionCard(question, index));
2090
+ });
2091
+
2092
+ initStorage();
2093
+ startHeartbeat();
2094
+ startQueuePolling();
2095
+
2096
+ formEl.addEventListener("submit", submitForm);
2097
+ if (queueToastClose) {
2098
+ queueToastClose.addEventListener("click", () => {
2099
+ queueState.dismissed = true;
2100
+ queueToast?.classList.add("hidden");
2101
+ });
2102
+ }
2103
+
2104
+ if (queueSessionSelect && queueOpenBtn) {
2105
+ queueSessionSelect.addEventListener("change", () => {
2106
+ const selectedOption = queueSessionSelect.options[queueSessionSelect.selectedIndex];
2107
+ queueOpenBtn.disabled = !queueSessionSelect.value || selectedOption?.disabled;
2108
+ });
2109
+ queueOpenBtn.addEventListener("click", () => {
2110
+ const url = queueSessionSelect.value;
2111
+ if (!url) return;
2112
+ const selectedOption = queueSessionSelect.options[queueSessionSelect.selectedIndex];
2113
+ if (selectedOption?.disabled) return;
2114
+ window.open(url, "_blank", "noopener");
2115
+ });
2116
+ }
2117
+ window.addEventListener("pagehide", (event) => {
2118
+ if (session.ended) return;
2119
+ if (event.persisted) return;
2120
+ if (hasReloadIntent()) return;
2121
+ sendCancelBeacon("user");
2122
+ });
2123
+
2124
+ window.addEventListener(
2125
+ "keydown",
2126
+ (event) => {
2127
+ const key = event.key.toLowerCase();
2128
+ if ((event.metaKey || event.ctrlKey) && key === "r") {
2129
+ markReloadIntent();
2130
+ } else if (event.key === "F5") {
2131
+ markReloadIntent();
2132
+ }
2133
+ },
2134
+ true
2135
+ );
2136
+ submitBtn.addEventListener("keydown", (e) => {
2137
+ if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
2138
+ e.preventDefault();
2139
+ e.stopImmediatePropagation();
2140
+ focusQuestion(nav.cards.length - 1, 'prev');
2141
+ }
2142
+ });
2143
+
2144
+ closeTabBtn.addEventListener("click", async () => {
2145
+ if (timers.countdown) clearInterval(timers.countdown);
2146
+ await cancelInterview("user");
2147
+ window.close();
2148
+ });
2149
+
2150
+ stayBtn.addEventListener("click", () => {
2151
+ if (timers.countdown) clearInterval(timers.countdown);
2152
+ expiredOverlay.classList.remove("visible");
2153
+ expiredOverlay.classList.add("hidden");
2154
+
2155
+ session.expired = false;
2156
+ submitBtn.disabled = false;
2157
+
2158
+ if (timeout > 0) {
2159
+ startCountdownDisplay();
2160
+ timers.expiration = setTimeout(() => {
2161
+ showSessionExpired();
2162
+ }, timeout * 1000);
2163
+ }
2164
+ });
2165
+
2166
+ document.addEventListener("keydown", (e) => {
2167
+ if (expiredOverlay.classList.contains("visible")) {
2168
+ if (e.key === "Tab") {
2169
+ e.preventDefault();
2170
+ e.stopPropagation();
2171
+ if (document.activeElement === stayBtn) {
2172
+ closeTabBtn.focus();
2173
+ } else {
2174
+ stayBtn.focus();
2175
+ }
2176
+ }
2177
+ }
2178
+ }, true);
2179
+ document.addEventListener("paste", handlePaste);
2180
+
2181
+ if (timeout > 0) {
2182
+ startCountdownDisplay();
2183
+ timers.expiration = setTimeout(() => {
2184
+ showSessionExpired();
2185
+ }, timeout * 1000);
2186
+
2187
+ ["click", "keydown", "input", "change"].forEach(event => {
2188
+ formEl.addEventListener(event, refreshCountdown, { passive: true });
2189
+ });
2190
+ document.addEventListener("mousemove", refreshCountdown, { passive: true });
2191
+ }
2192
+
2193
+ initQuestionNavigation();
2194
+ }
2195
+
2196
+ window.__INTERVIEW_API__ = {
2197
+ questions,
2198
+ nav,
2199
+ formEl,
2200
+ sessionToken,
2201
+ data,
2202
+ focusQuestion,
2203
+ getQuestionValue,
2204
+ debounceSave,
2205
+ escapeSelector,
2206
+ populateForm,
2207
+ updateDoneState,
2208
+ setFieldError,
2209
+ clearFieldErrors,
2210
+ };
2211
+
2212
+ init();
2213
+ })();