pi-interview 0.6.1 → 0.8.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 CHANGED
@@ -6,6 +6,10 @@
6
6
  const cwd = data.cwd || "";
7
7
  const gitBranch = data.gitBranch || "";
8
8
  const timeout = typeof data.timeout === "number" ? data.timeout : 0;
9
+ const askModels = Array.isArray(data.askModels)
10
+ ? data.askModels.filter((model) => model && typeof model.value === "string" && typeof model.provider === "string")
11
+ : [];
12
+ const defaultAskModel = typeof data.defaultAskModel === "string" ? data.defaultAskModel : null;
9
13
 
10
14
  const titleEl = document.getElementById("form-title");
11
15
  const descriptionEl = document.getElementById("form-description");
@@ -37,6 +41,12 @@
37
41
  const imagePathState = new Map();
38
42
  const attachState = new Map();
39
43
  const attachPathState = new Map();
44
+ const optionKeyState = new Map();
45
+ const choiceNoteState = new Map();
46
+ const optionInsightState = {
47
+ active: null,
48
+ pinned: new Map(),
49
+ };
40
50
  const nav = {
41
51
  questionIndex: 0,
42
52
  optionIndex: 0,
@@ -76,6 +86,12 @@
76
86
  dismissed: false,
77
87
  knownIds: new Set(),
78
88
  };
89
+ const ASK_PROMPT_CHIPS = [
90
+ { key: "explain", label: "Explain this", prompt: "Explain this better." },
91
+ { key: "why", label: "Why this option?", prompt: "Why is this option like that?" },
92
+ { key: "tradeoffs", label: "Tradeoffs", prompt: "What are the tradeoffs of this option?" },
93
+ { key: "fail", label: "When would this fail?", prompt: "When would this option fail or be the wrong choice?" },
94
+ ];
79
95
 
80
96
  function updateCountdownBadge(secondsLeft, totalSeconds) {
81
97
  if (!countdownBadge || !countdownValue || !countdownRingProgress) return;
@@ -375,77 +391,1206 @@
375
391
  return String(value).replace(/["\\]/g, "\\$&");
376
392
  }
377
393
 
378
- function setText(el, text) {
379
- if (!el) return;
380
- el.textContent = text || "";
381
- }
394
+ function setText(el, text) {
395
+ if (!el) return;
396
+ el.textContent = text || "";
397
+ }
398
+
399
+ function escapeHtml(text) {
400
+ return String(text || "")
401
+ .replace(/&/g, "&")
402
+ .replace(/</g, "&lt;")
403
+ .replace(/>/g, "&gt;");
404
+ }
405
+
406
+ function renderLightMarkdown(text) {
407
+ if (!text) return "";
408
+ let html = escapeHtml(text);
409
+ html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
410
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
411
+ html = html.replace(/\n/g, "<br>");
412
+ html = html.replace(/\s(\d+\.)\s/g, "<br>$1 ");
413
+ return html;
414
+ }
415
+
416
+ function isMarkdownLang(lang) {
417
+ if (typeof lang !== "string") return false;
418
+ const normalized = lang.trim().toLowerCase();
419
+ return normalized === "md" || normalized === "markdown";
420
+ }
421
+
422
+ function renderMarkdownPreviewFallback(markdown) {
423
+ const lines = String(markdown || "").replace(/\r\n?/g, "\n").split("\n");
424
+ const html = [];
425
+ const paragraph = [];
426
+ let listType = null;
427
+ let inFence = false;
428
+ let fenceLang = "";
429
+ let fenceLines = [];
430
+
431
+ const flushParagraph = () => {
432
+ if (paragraph.length === 0) return;
433
+ html.push(`<p>${renderLightMarkdown(paragraph.join(" "))}</p>`);
434
+ paragraph.length = 0;
435
+ };
436
+
437
+ const closeList = () => {
438
+ if (!listType) return;
439
+ html.push(listType === "ol" ? "</ol>" : "</ul>");
440
+ listType = null;
441
+ };
442
+
443
+ for (const rawLine of lines) {
444
+ const line = rawLine ?? "";
445
+
446
+ if (inFence) {
447
+ if (/^```/.test(line.trim())) {
448
+ html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
449
+ inFence = false;
450
+ fenceLang = "";
451
+ fenceLines = [];
452
+ } else {
453
+ fenceLines.push(line);
454
+ }
455
+ continue;
456
+ }
457
+
458
+ const fenceStart = line.match(/^```\s*([^\s`]*)\s*$/);
459
+ if (fenceStart) {
460
+ flushParagraph();
461
+ closeList();
462
+ inFence = true;
463
+ fenceLang = fenceStart[1] || "";
464
+ fenceLines = [];
465
+ continue;
466
+ }
467
+
468
+ if (!line.trim()) {
469
+ flushParagraph();
470
+ closeList();
471
+ continue;
472
+ }
473
+
474
+ const headingMatch = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/);
475
+ if (headingMatch) {
476
+ flushParagraph();
477
+ closeList();
478
+ const level = headingMatch[1].length;
479
+ html.push(`<h${level}>${renderLightMarkdown(headingMatch[2].trim())}</h${level}>`);
480
+ continue;
481
+ }
482
+
483
+ const quoteMatch = line.match(/^>\s?(.*)$/);
484
+ if (quoteMatch) {
485
+ flushParagraph();
486
+ closeList();
487
+ html.push(`<blockquote><p>${renderLightMarkdown(quoteMatch[1])}</p></blockquote>`);
488
+ continue;
489
+ }
490
+
491
+ const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
492
+ if (orderedMatch) {
493
+ flushParagraph();
494
+ if (listType !== "ol") {
495
+ closeList();
496
+ html.push("<ol>");
497
+ listType = "ol";
498
+ }
499
+ html.push(`<li>${renderLightMarkdown(orderedMatch[1])}</li>`);
500
+ continue;
501
+ }
502
+
503
+ const unorderedMatch = line.match(/^\s*[-*]\s+(.+)$/);
504
+ if (unorderedMatch) {
505
+ flushParagraph();
506
+ if (listType !== "ul") {
507
+ closeList();
508
+ html.push("<ul>");
509
+ listType = "ul";
510
+ }
511
+ html.push(`<li>${renderLightMarkdown(unorderedMatch[1])}</li>`);
512
+ continue;
513
+ }
514
+
515
+ closeList();
516
+ paragraph.push(line.trim());
517
+ }
518
+
519
+ if (inFence) {
520
+ html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
521
+ }
522
+
523
+ flushParagraph();
524
+ closeList();
525
+ return html.join("\n");
526
+ }
527
+
528
+ function getOptionLabel(option) {
529
+ return typeof option === "string" ? option : option.label;
530
+ }
531
+
532
+ function questionCanClarifyOption(question) {
533
+ return (question.type === "single" || question.type === "multi")
534
+ && Array.isArray(question.options)
535
+ && question.options.length > 0
536
+ && question.options.every((option) => typeof option === "string");
537
+ }
538
+
539
+ function isChoiceResponseValue(value) {
540
+ return value && typeof value === "object" && !Array.isArray(value) && typeof value.option === "string";
541
+ }
542
+
543
+ function normalizeChoiceResponseValue(value) {
544
+ if (!isChoiceResponseValue(value)) return null;
545
+ const option = value.option.trim();
546
+ if (!option) return null;
547
+ const note = typeof value.note === "string" ? value.note.trim() : "";
548
+ return note ? { option, note } : { option };
549
+ }
550
+
551
+ function renameChoiceAnswerValue(question, value, previousOption, nextOption) {
552
+ if (!nextOption || (question.type !== "single" && question.type !== "multi")) {
553
+ return value;
554
+ }
555
+
556
+ if (question.type === "single") {
557
+ const choiceValue = normalizeChoiceResponseValue(value);
558
+ if (!choiceValue || choiceValue.option !== previousOption) return value;
559
+ return choiceValue.note ? { option: nextOption, note: choiceValue.note } : { option: nextOption };
560
+ }
561
+
562
+ if (!Array.isArray(value)) return value;
563
+ return value.map((item) => {
564
+ const choiceValue = normalizeChoiceResponseValue(item);
565
+ if (!choiceValue || choiceValue.option !== previousOption) return item;
566
+ return choiceValue.note ? { option: nextOption, note: choiceValue.note } : { option: nextOption };
567
+ });
568
+ }
569
+
570
+ function preserveChoiceAnswerValue(question, value, validLabels) {
571
+ if (question.type === "single") {
572
+ const choiceValue = normalizeChoiceResponseValue(value);
573
+ if (!choiceValue || !validLabels.has(choiceValue.option)) return "";
574
+ return choiceValue;
575
+ }
576
+
577
+ if (question.type === "multi") {
578
+ if (!Array.isArray(value)) return [];
579
+ return value
580
+ .map((item) => normalizeChoiceResponseValue(item))
581
+ .filter((item) => item && validLabels.has(item.option));
582
+ }
583
+
584
+ return value;
585
+ }
586
+
587
+ function getChoiceNotes(questionId) {
588
+ return choiceNoteState.get(questionId) || new Map();
589
+ }
590
+
591
+ function getChoiceNote(questionId, optionLabel) {
592
+ return getChoiceNotes(questionId).get(optionLabel) || "";
593
+ }
594
+
595
+ function setChoiceNote(questionId, optionLabel, note) {
596
+ const normalizedNote = typeof note === "string" ? note.trim() : "";
597
+ const existing = choiceNoteState.get(questionId) || new Map();
598
+ if (!normalizedNote) {
599
+ existing.delete(optionLabel);
600
+ if (existing.size === 0) {
601
+ choiceNoteState.delete(questionId);
602
+ return;
603
+ }
604
+ choiceNoteState.set(questionId, existing);
605
+ return;
606
+ }
607
+ existing.set(optionLabel, normalizedNote);
608
+ choiceNoteState.set(questionId, existing);
609
+ }
610
+
611
+ function clearChoiceNotes(questionId) {
612
+ choiceNoteState.delete(questionId);
613
+ }
614
+
615
+ function getSelectedOptionLabels(questionId) {
616
+ return Array.from(
617
+ formEl.querySelectorAll(`input[name="${escapeSelector(questionId)}"]:checked`)
618
+ )
619
+ .map((input) => input.value)
620
+ .filter((value) => value && value !== "__other__");
621
+ }
622
+
623
+ function syncChoiceNotesWithSelection(question) {
624
+ if (!questionCanClarifyOption(question)) return;
625
+ }
626
+
627
+ function isRichOption(option) {
628
+ return typeof option === "object" && option !== null && "label" in option;
629
+ }
630
+
631
+ function syncRecommendations(question, options) {
632
+ const optionLabels = options.map((option) => getOptionLabel(option));
633
+ if (!question.recommended) return;
634
+
635
+ if (question.type === "single") {
636
+ if (typeof question.recommended === "string" && optionLabels.includes(question.recommended)) {
637
+ return;
638
+ }
639
+ delete question.recommended;
640
+ delete question.conviction;
641
+ return;
642
+ }
643
+
644
+ if (question.type !== "multi") {
645
+ delete question.recommended;
646
+ delete question.conviction;
647
+ return;
648
+ }
649
+
650
+ const nextRecommended = (Array.isArray(question.recommended)
651
+ ? question.recommended
652
+ : [question.recommended]).filter((option) => optionLabels.includes(option));
653
+ if (nextRecommended.length === 0) {
654
+ delete question.recommended;
655
+ delete question.conviction;
656
+ return;
657
+ }
658
+ question.recommended = nextRecommended;
659
+ }
660
+
661
+ function makeClientId(prefix = "id") {
662
+ if (window.crypto && typeof window.crypto.randomUUID === "function") {
663
+ return `${prefix}-${window.crypto.randomUUID()}`;
664
+ }
665
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
666
+ }
667
+
668
+ function questionSupportsOptionInsights(question) {
669
+ return (question.type === "single" || question.type === "multi") &&
670
+ Array.isArray(question.options) &&
671
+ question.options.length > 0;
672
+ }
673
+
674
+ function questionCanAskAboutOption(question) {
675
+ return !!data.canGenerate && questionSupportsOptionInsights(question);
676
+ }
677
+
678
+ function normalizeOptionKeysFromData() {
679
+ const raw = data.optionKeysByQuestion && typeof data.optionKeysByQuestion === "object"
680
+ ? data.optionKeysByQuestion
681
+ : {};
682
+
683
+ questions.forEach((question) => {
684
+ if (!questionSupportsOptionInsights(question)) return;
685
+ const rawKeys = Array.isArray(raw[question.id]) ? raw[question.id] : [];
686
+ const keys = rawKeys.length === question.options.length && rawKeys.every((key) => typeof key === "string" && key)
687
+ ? [...rawKeys]
688
+ : question.options.map(() => makeClientId(`opt-${question.id}`));
689
+ optionKeyState.set(question.id, keys);
690
+ });
691
+ }
692
+
693
+ function getOptionKeys(questionId) {
694
+ return optionKeyState.get(questionId) || [];
695
+ }
696
+
697
+ function setOptionKeys(questionId, keys) {
698
+ optionKeyState.set(questionId, Array.isArray(keys) ? [...keys] : []);
699
+ }
700
+
701
+ function getOptionIndexByKey(questionId, optionKey) {
702
+ return getOptionKeys(questionId).indexOf(optionKey);
703
+ }
704
+
705
+ function getOptionTextByKey(questionId, optionKey) {
706
+ const question = questions.find((q) => q.id === questionId);
707
+ if (!question || !Array.isArray(question.options)) return "";
708
+ const index = getOptionIndexByKey(questionId, optionKey);
709
+ if (index < 0 || index >= question.options.length) return "";
710
+ return getOptionLabel(question.options[index]);
711
+ }
712
+
713
+ function providerLabel(provider) {
714
+ if (!provider) return "";
715
+ if (provider === "openai") return "OpenAI";
716
+ if (provider === "google") return "Google";
717
+ if (provider === "anthropic") return "Anthropic";
718
+ return provider.charAt(0).toUpperCase() + provider.slice(1);
719
+ }
720
+
721
+ function parseModelValue(modelValue) {
722
+ if (typeof modelValue !== "string") return { provider: "", model: "" };
723
+ const slashIndex = modelValue.indexOf("/");
724
+ if (slashIndex <= 0 || slashIndex === modelValue.length - 1) {
725
+ return { provider: "", model: modelValue };
726
+ }
727
+ return {
728
+ provider: modelValue.slice(0, slashIndex),
729
+ model: modelValue.slice(slashIndex + 1),
730
+ };
731
+ }
732
+
733
+ function getModelsForProvider(provider) {
734
+ return askModels.filter((model) => model.provider === provider);
735
+ }
736
+
737
+ function getFirstProvider() {
738
+ return askModels[0]?.provider || "";
739
+ }
740
+
741
+ function createDefaultActiveInsight(questionId, optionKey) {
742
+ const selectedModel = askModels.some((model) => model.value === defaultAskModel)
743
+ ? defaultAskModel
744
+ : (askModels[0]?.value || null);
745
+ const parsed = parseModelValue(selectedModel);
746
+ return {
747
+ questionId,
748
+ optionKey,
749
+ prompt: "",
750
+ selectedChip: null,
751
+ loading: false,
752
+ error: "",
753
+ result: null,
754
+ advancedOpen: false,
755
+ selectedProvider: parsed.provider || getFirstProvider(),
756
+ selectedModel,
757
+ abortController: null,
758
+ };
759
+ }
760
+
761
+ function getActiveInsight(questionId, optionKey) {
762
+ const active = optionInsightState.active;
763
+ return active && active.questionId === questionId && active.optionKey === optionKey
764
+ ? active
765
+ : null;
766
+ }
767
+
768
+ function getPinnedInsights(questionId, optionKey) {
769
+ const questionInsights = optionInsightState.pinned.get(questionId) || [];
770
+ return questionInsights.filter((insight) => insight.optionKey === optionKey);
771
+ }
772
+
773
+ function normalizeSavedOptionInsights(input) {
774
+ if (!Array.isArray(input)) return [];
775
+ return input
776
+ .filter((item) => item && typeof item === "object")
777
+ .map((item) => ({
778
+ id: typeof item.id === "string" ? item.id : makeClientId("insight"),
779
+ questionId: typeof item.questionId === "string" ? item.questionId : "",
780
+ optionKey: typeof item.optionKey === "string" ? item.optionKey : "",
781
+ optionText: typeof item.optionText === "string" ? item.optionText : "",
782
+ prompt: typeof item.prompt === "string" ? item.prompt : "",
783
+ summary: typeof item.summary === "string" ? item.summary : "",
784
+ bullets: Array.isArray(item.bullets)
785
+ ? item.bullets.filter((bullet) => typeof bullet === "string" && bullet.trim()).map((bullet) => bullet.trim())
786
+ : [],
787
+ suggestedText: typeof item.suggestedText === "string" ? item.suggestedText : undefined,
788
+ modelUsed: typeof item.modelUsed === "string" ? item.modelUsed : item.modelUsed === null ? null : undefined,
789
+ createdAt: typeof item.createdAt === "string" ? item.createdAt : new Date().toISOString(),
790
+ }))
791
+ .filter((item) => item.questionId && item.optionKey && item.summary);
792
+ }
793
+
794
+ function restoreSavedOptionInsights(input) {
795
+ optionInsightState.pinned.clear();
796
+ normalizeSavedOptionInsights(input).forEach((insight) => {
797
+ const existing = optionInsightState.pinned.get(insight.questionId) || [];
798
+ existing.push(insight);
799
+ optionInsightState.pinned.set(insight.questionId, existing);
800
+ });
801
+ }
802
+
803
+ function serializeSavedOptionInsights() {
804
+ return Array.from(optionInsightState.pinned.values())
805
+ .flat()
806
+ .map((insight) => ({
807
+ id: insight.id,
808
+ questionId: insight.questionId,
809
+ optionKey: insight.optionKey,
810
+ optionText: insight.optionText,
811
+ prompt: insight.prompt,
812
+ summary: insight.summary,
813
+ bullets: Array.isArray(insight.bullets) ? [...insight.bullets] : [],
814
+ suggestedText: insight.suggestedText,
815
+ modelUsed: insight.modelUsed ?? null,
816
+ createdAt: insight.createdAt,
817
+ }));
818
+ }
819
+
820
+ function removePinnedInsight(questionId, insightId) {
821
+ const existing = optionInsightState.pinned.get(questionId) || [];
822
+ const next = existing.filter((insight) => insight.id !== insightId);
823
+ if (next.length > 0) {
824
+ optionInsightState.pinned.set(questionId, next);
825
+ } else {
826
+ optionInsightState.pinned.delete(questionId);
827
+ }
828
+ }
829
+
830
+ function updatePinnedInsightOptionText(questionId, optionKey, optionText) {
831
+ const existing = optionInsightState.pinned.get(questionId) || [];
832
+ existing.forEach((insight) => {
833
+ if (insight.optionKey === optionKey) {
834
+ insight.optionText = optionText;
835
+ }
836
+ });
837
+ }
838
+
839
+ function pruneQuestionOptionInsights(questionId) {
840
+ const validKeys = new Set(getOptionKeys(questionId));
841
+ const existing = optionInsightState.pinned.get(questionId) || [];
842
+ const next = existing.filter((insight) => validKeys.has(insight.optionKey));
843
+ if (next.length > 0) {
844
+ optionInsightState.pinned.set(questionId, next);
845
+ } else {
846
+ optionInsightState.pinned.delete(questionId);
847
+ }
848
+
849
+ const active = optionInsightState.active;
850
+ if (!active || active.questionId !== questionId || validKeys.has(active.optionKey)) {
851
+ return;
852
+ }
853
+ if (active.abortController) {
854
+ active.abortController.abort();
855
+ }
856
+ optionInsightState.active = null;
857
+ }
858
+
859
+ function closeOptionInsightPanel(questionId, optionKey) {
860
+ const active = optionInsightState.active;
861
+ if (!active) return;
862
+ if (questionId && active.questionId !== questionId) return;
863
+ if (optionKey && active.optionKey !== optionKey) return;
864
+ if (active.abortController) {
865
+ active.abortController.abort();
866
+ }
867
+ optionInsightState.active = null;
868
+ }
869
+
870
+ function openOptionInsightPanel(question, optionKey) {
871
+ if (!questionCanAskAboutOption(question)) return;
872
+ const currentValue = getQuestionValue(question);
873
+ const active = getActiveInsight(question.id, optionKey);
874
+ if (active) {
875
+ closeOptionInsightPanel(question.id, optionKey);
876
+ replaceQuestionOptionList(question, currentValue, optionKey);
877
+ return;
878
+ }
879
+
880
+ const previousActive = optionInsightState.active;
881
+ if (previousActive?.abortController) {
882
+ previousActive.abortController.abort();
883
+ }
884
+ if (previousActive && (previousActive.questionId !== question.id || previousActive.optionKey !== optionKey)) {
885
+ const previousQuestion = questions.find((item) => item.id === previousActive.questionId);
886
+ if (previousQuestion) {
887
+ const previousValue = getQuestionValue(previousQuestion);
888
+ optionInsightState.active = null;
889
+ replaceQuestionOptionList(previousQuestion, previousValue, previousActive.optionKey);
890
+ }
891
+ }
892
+
893
+ optionInsightState.active = createDefaultActiveInsight(question.id, optionKey);
894
+ replaceQuestionOptionList(question, currentValue, optionKey, { focusComposer: true });
895
+ }
896
+
897
+ function getSelectedInsightModel(activeInsight) {
898
+ if (!activeInsight) return null;
899
+ return typeof activeInsight.selectedModel === "string" && activeInsight.selectedModel
900
+ ? activeInsight.selectedModel
901
+ : defaultAskModel;
902
+ }
903
+
904
+ function getInsightModelLabel(activeInsight) {
905
+ const selectedModel = getSelectedInsightModel(activeInsight);
906
+ if (!selectedModel) return "No model selected";
907
+ const parsed = parseModelValue(selectedModel);
908
+ return `${providerLabel(parsed.provider)} / ${parsed.model}`;
909
+ }
910
+
911
+ function applyQuestionValue(question, value) {
912
+ populateForm({ [question.id]: value }, { preserveChoiceNotes: true });
913
+ if (question.type === "multi") {
914
+ updateDoneState(question.id);
915
+ }
916
+ }
917
+
918
+ function replaceQuestionOptionList(question, preserveValue, focusOptionKey, options = {}) {
919
+ const card = containerEl.querySelector(`.question-card[data-question-id="${escapeSelector(question.id)}"]`);
920
+ const currentList = card?.querySelector('.option-list');
921
+ const title = card?.querySelector('.question-title');
922
+ if (!card || !currentList || !title) return;
923
+
924
+ const nextList = createChoiceQuestionList(question, title, options);
925
+ currentList.replaceWith(nextList);
926
+ applyQuestionValue(question, preserveValue);
927
+
928
+ if (nav.cards[nav.questionIndex] === card && !nav.inSubmitArea && focusOptionKey) {
929
+ const optionIndex = getOptionIndexByKey(question.id, focusOptionKey);
930
+ if (optionIndex >= 0) {
931
+ nav.optionIndex = optionIndex;
932
+ highlightOption(card, optionIndex, false);
933
+ }
934
+ }
935
+
936
+ if (options.focusComposer) {
937
+ requestAnimationFrame(() => {
938
+ const composer = card.querySelector(`.option-insight-input[data-question-id="${escapeSelector(question.id)}"][data-option-key="${escapeSelector(focusOptionKey || "")}"]`);
939
+ composer?.focus();
940
+ });
941
+ }
942
+ }
943
+
944
+ async function runOptionAction(question, optionKey, action, text) {
945
+ const preservedValue = getQuestionValue(question);
946
+ const previousText = getOptionTextByKey(question.id, optionKey);
947
+ try {
948
+ const response = await fetch("/option-action", {
949
+ method: "POST",
950
+ headers: { "Content-Type": "application/json" },
951
+ body: JSON.stringify({ token: sessionToken, questionId: question.id, optionKey, action, text }),
952
+ });
953
+ const result = await response.json();
954
+ if (!result.ok) throw new Error(result.error || "Option action failed");
955
+
956
+ if (result.question && Array.isArray(result.question.options)) {
957
+ question.options = result.question.options;
958
+ question.recommended = result.question.recommended;
959
+ question.conviction = result.question.conviction;
960
+ }
961
+ if (Array.isArray(result.optionKeys)) {
962
+ setOptionKeys(question.id, result.optionKeys);
963
+ pruneQuestionOptionInsights(question.id);
964
+ }
965
+
966
+ if (action === "replace-text") {
967
+ const nextText = getOptionTextByKey(question.id, optionKey);
968
+ updatePinnedInsightOptionText(question.id, optionKey, nextText);
969
+ if (optionInsightState.active && optionInsightState.active.questionId === question.id && optionInsightState.active.optionKey === optionKey && optionInsightState.active.result) {
970
+ optionInsightState.active.result.suggestedText = nextText;
971
+ }
972
+ }
973
+
974
+ let nextValue = preservedValue;
975
+ if (action === "replace-text" && text) {
976
+ nextValue = renameChoiceAnswerValue(question, preservedValue, previousText, text);
977
+ }
978
+
979
+ replaceQuestionOptionList(question, nextValue, optionKey);
980
+ debounceSave();
981
+ refreshCountdown();
982
+ return true;
983
+ } catch (err) {
984
+ const active = getActiveInsight(question.id, optionKey);
985
+ if (active) {
986
+ active.error = err instanceof Error ? err.message : "Option action failed";
987
+ replaceQuestionOptionList(question, preservedValue, optionKey);
988
+ }
989
+ return false;
990
+ }
991
+ }
992
+
993
+ async function submitOptionInsight(question, optionKey) {
994
+ const active = getActiveInsight(question.id, optionKey);
995
+ if (!active) return;
996
+ const prompt = active.prompt.trim();
997
+ if (!prompt) {
998
+ active.error = "Prompt is required";
999
+ replaceQuestionOptionList(question, getQuestionValue(question), optionKey, { focusComposer: true });
1000
+ return;
1001
+ }
1002
+
1003
+ if (active.loading) {
1004
+ active.abortController?.abort();
1005
+ return;
1006
+ }
1007
+
1008
+ active.loading = true;
1009
+ active.error = "";
1010
+ active.abortController = new AbortController();
1011
+ replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1012
+
1013
+ try {
1014
+ const modelOverride = getSelectedInsightModel(active);
1015
+ const response = await fetch("/option-insight", {
1016
+ method: "POST",
1017
+ headers: { "Content-Type": "application/json" },
1018
+ body: JSON.stringify({
1019
+ token: sessionToken,
1020
+ questionId: question.id,
1021
+ optionKey,
1022
+ prompt,
1023
+ model: modelOverride && modelOverride !== defaultAskModel ? modelOverride : null,
1024
+ }),
1025
+ signal: active.abortController.signal,
1026
+ });
1027
+ const result = await response.json();
1028
+ if (!result.ok) throw new Error(result.error || "Option insight failed");
1029
+ active.result = {
1030
+ summary: result.summary,
1031
+ bullets: Array.isArray(result.bullets) ? result.bullets : [],
1032
+ suggestedText: typeof result.suggestedText === "string" ? result.suggestedText : undefined,
1033
+ modelUsed: typeof result.modelUsed === "string" ? result.modelUsed : null,
1034
+ };
1035
+ active.error = "";
1036
+ const optionText = typeof result.optionText === "string" ? result.optionText : getOptionTextByKey(question.id, optionKey);
1037
+ updatePinnedInsightOptionText(question.id, optionKey, optionText);
1038
+ refreshCountdown();
1039
+ } catch (err) {
1040
+ if (!(err instanceof Error && err.name === "AbortError")) {
1041
+ active.error = err instanceof Error ? err.message : "Option insight failed";
1042
+ }
1043
+ } finally {
1044
+ if (optionInsightState.active && optionInsightState.active.questionId === question.id && optionInsightState.active.optionKey === optionKey) {
1045
+ optionInsightState.active.loading = false;
1046
+ optionInsightState.active.abortController = null;
1047
+ }
1048
+ replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1049
+ }
1050
+ }
1051
+
1052
+ function pinActiveInsight(question, optionKey) {
1053
+ const active = getActiveInsight(question.id, optionKey);
1054
+ if (!active || !active.result) return;
1055
+ const optionText = getOptionTextByKey(question.id, optionKey);
1056
+ const questionInsights = optionInsightState.pinned.get(question.id) || [];
1057
+ questionInsights.push({
1058
+ id: makeClientId("insight"),
1059
+ questionId: question.id,
1060
+ optionKey,
1061
+ optionText,
1062
+ prompt: active.prompt.trim(),
1063
+ summary: active.result.summary,
1064
+ bullets: Array.isArray(active.result.bullets) ? [...active.result.bullets] : [],
1065
+ suggestedText: active.result.suggestedText,
1066
+ modelUsed: active.result.modelUsed ?? null,
1067
+ createdAt: new Date().toISOString(),
1068
+ });
1069
+ optionInsightState.pinned.set(question.id, questionInsights);
1070
+ debounceSave();
1071
+ replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1072
+ }
1073
+
1074
+ function createPinnedInsightCard(question, optionKey, insight) {
1075
+ const card = document.createElement("div");
1076
+ card.className = "option-insight-pinned";
1077
+
1078
+ const head = document.createElement("div");
1079
+ head.className = "option-insight-pinned-head";
1080
+
1081
+ const prompt = document.createElement("div");
1082
+ prompt.className = "option-insight-pinned-prompt";
1083
+ prompt.textContent = insight.prompt;
1084
+ head.appendChild(prompt);
1085
+
1086
+ const unpin = document.createElement("button");
1087
+ unpin.type = "button";
1088
+ unpin.className = "option-insight-unpin";
1089
+ unpin.textContent = "Unpin";
1090
+ unpin.addEventListener("click", (event) => {
1091
+ event.preventDefault();
1092
+ event.stopPropagation();
1093
+ removePinnedInsight(question.id, insight.id);
1094
+ debounceSave();
1095
+ replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1096
+ });
1097
+ head.appendChild(unpin);
1098
+ card.appendChild(head);
1099
+
1100
+ const summary = document.createElement("p");
1101
+ summary.className = "option-insight-summary pinned";
1102
+ summary.textContent = insight.summary;
1103
+ card.appendChild(summary);
1104
+
1105
+ if (Array.isArray(insight.bullets) && insight.bullets.length > 0) {
1106
+ const list = document.createElement("ul");
1107
+ list.className = "option-insight-bullets";
1108
+ insight.bullets.forEach((bullet) => {
1109
+ const item = document.createElement("li");
1110
+ item.textContent = bullet;
1111
+ list.appendChild(item);
1112
+ });
1113
+ card.appendChild(list);
1114
+ }
1115
+
1116
+ if (insight.suggestedText) {
1117
+ const suggestion = document.createElement("code");
1118
+ suggestion.className = "option-insight-suggested-text compact";
1119
+ suggestion.textContent = insight.suggestedText;
1120
+ card.appendChild(suggestion);
1121
+ }
1122
+
1123
+ if (insight.modelUsed) {
1124
+ const meta = document.createElement("div");
1125
+ meta.className = "option-insight-meta";
1126
+ meta.textContent = insight.modelUsed;
1127
+ card.appendChild(meta);
1128
+ }
1129
+
1130
+ return card;
1131
+ }
1132
+
1133
+ function createOptionInsightPanel(question, optionKey) {
1134
+ const active = getActiveInsight(question.id, optionKey);
1135
+ if (!active) return null;
1136
+
1137
+ const panel = document.createElement("div");
1138
+ panel.className = "option-insight-panel";
1139
+ panel.dataset.optionInsightFor = optionKey;
1140
+
1141
+ const chips = document.createElement("div");
1142
+ chips.className = "option-insight-chips";
1143
+ ASK_PROMPT_CHIPS.forEach((chip) => {
1144
+ const btn = document.createElement("button");
1145
+ btn.type = "button";
1146
+ btn.className = "option-insight-chip" + (active.selectedChip === chip.key ? " active" : "");
1147
+ btn.textContent = chip.label;
1148
+ btn.addEventListener("click", (event) => {
1149
+ event.preventDefault();
1150
+ event.stopPropagation();
1151
+ active.selectedChip = chip.key;
1152
+ active.prompt = chip.prompt;
1153
+ active.error = "";
1154
+ replaceQuestionOptionList(question, getQuestionValue(question), optionKey, { focusComposer: true });
1155
+ });
1156
+ chips.appendChild(btn);
1157
+ });
1158
+ panel.appendChild(chips);
1159
+
1160
+ const input = document.createElement("textarea");
1161
+ input.className = "option-insight-input";
1162
+ input.rows = 2;
1163
+ input.placeholder = "Ask why it works, where it fails, or how to rewrite it...";
1164
+ input.dataset.questionId = question.id;
1165
+ input.dataset.optionKey = optionKey;
1166
+ input.value = active.prompt;
1167
+ input.addEventListener("input", () => {
1168
+ active.prompt = input.value;
1169
+ active.selectedChip = null;
1170
+ active.error = "";
1171
+ });
1172
+ input.addEventListener("keydown", (event) => {
1173
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
1174
+ event.preventDefault();
1175
+ submitOptionInsight(question, optionKey);
1176
+ }
1177
+ });
1178
+ panel.appendChild(input);
1179
+
1180
+ const metaRow = document.createElement("div");
1181
+ metaRow.className = "option-insight-meta-row";
1182
+
1183
+ const model = document.createElement("div");
1184
+ model.className = "option-insight-model";
1185
+ model.textContent = getInsightModelLabel(active);
1186
+ metaRow.appendChild(model);
1187
+
1188
+ const advancedToggle = document.createElement("button");
1189
+ advancedToggle.type = "button";
1190
+ advancedToggle.className = "option-insight-advanced-toggle";
1191
+ advancedToggle.textContent = active.advancedOpen ? "Advanced model settings ▾" : "Advanced model settings ▸";
1192
+ advancedToggle.addEventListener("click", (event) => {
1193
+ event.preventDefault();
1194
+ event.stopPropagation();
1195
+ active.advancedOpen = !active.advancedOpen;
1196
+ replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1197
+ });
1198
+ metaRow.appendChild(advancedToggle);
1199
+
1200
+ panel.appendChild(metaRow);
1201
+
1202
+ if (active.advancedOpen) {
1203
+ const advanced = document.createElement("div");
1204
+ advanced.className = "option-insight-advanced";
1205
+
1206
+ const providerSelect = document.createElement("select");
1207
+ providerSelect.className = "option-insight-select";
1208
+ const providers = [...new Set(askModels.map((model) => model.provider))];
1209
+ providers.forEach((provider) => {
1210
+ const option = document.createElement("option");
1211
+ option.value = provider;
1212
+ option.textContent = providerLabel(provider);
1213
+ providerSelect.appendChild(option);
1214
+ });
1215
+ providerSelect.value = active.selectedProvider || providers[0] || "";
1216
+ providerSelect.addEventListener("change", () => {
1217
+ active.selectedProvider = providerSelect.value;
1218
+ const providerModels = getModelsForProvider(active.selectedProvider);
1219
+ active.selectedModel = providerModels[0]?.value || null;
1220
+ replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1221
+ });
1222
+ advanced.appendChild(providerSelect);
1223
+
1224
+ const modelSelect = document.createElement("select");
1225
+ modelSelect.className = "option-insight-select";
1226
+ const providerModels = getModelsForProvider(active.selectedProvider);
1227
+ providerModels.forEach((modelOption) => {
1228
+ const option = document.createElement("option");
1229
+ option.value = modelOption.value;
1230
+ option.textContent = modelOption.label;
1231
+ modelSelect.appendChild(option);
1232
+ });
1233
+ modelSelect.value = active.selectedModel || providerModels[0]?.value || "";
1234
+ modelSelect.addEventListener("change", () => {
1235
+ active.selectedModel = modelSelect.value;
1236
+ });
1237
+ advanced.appendChild(modelSelect);
1238
+
1239
+ panel.appendChild(advanced);
1240
+ }
1241
+
1242
+ const actions = document.createElement("div");
1243
+ actions.className = "option-insight-actions";
1244
+
1245
+ const askButton = document.createElement("button");
1246
+ askButton.type = "button";
1247
+ askButton.className = "option-insight-submit" + (active.loading ? " loading" : "");
1248
+ askButton.textContent = active.loading ? "Cancel" : "Ask";
1249
+ askButton.addEventListener("click", (event) => {
1250
+ event.preventDefault();
1251
+ event.stopPropagation();
1252
+ submitOptionInsight(question, optionKey);
1253
+ });
1254
+ actions.appendChild(askButton);
1255
+
1256
+ panel.appendChild(actions);
1257
+
1258
+ if (active.error) {
1259
+ const error = document.createElement("div");
1260
+ error.className = "option-insight-error";
1261
+ error.textContent = active.error;
1262
+ panel.appendChild(error);
1263
+ }
1264
+
1265
+ if (active.result) {
1266
+ const result = document.createElement("div");
1267
+ result.className = "option-insight-result";
1268
+
1269
+ const summary = document.createElement("p");
1270
+ summary.className = "option-insight-summary";
1271
+ summary.textContent = active.result.summary;
1272
+ result.appendChild(summary);
1273
+
1274
+ if (Array.isArray(active.result.bullets) && active.result.bullets.length > 0) {
1275
+ const list = document.createElement("ul");
1276
+ list.className = "option-insight-bullets";
1277
+ active.result.bullets.forEach((bullet) => {
1278
+ const item = document.createElement("li");
1279
+ item.textContent = bullet;
1280
+ list.appendChild(item);
1281
+ });
1282
+ result.appendChild(list);
1283
+ }
1284
+
1285
+ if (active.result.suggestedText) {
1286
+ const suggestionLabel = document.createElement("div");
1287
+ suggestionLabel.className = "option-insight-suggestion-label";
1288
+ suggestionLabel.textContent = "Suggested rewrite";
1289
+ result.appendChild(suggestionLabel);
1290
+
1291
+ const suggestion = document.createElement("code");
1292
+ suggestion.className = "option-insight-suggested-text";
1293
+ suggestion.textContent = active.result.suggestedText;
1294
+ result.appendChild(suggestion);
1295
+ }
1296
+
1297
+ if (active.result.modelUsed) {
1298
+ const meta = document.createElement("div");
1299
+ meta.className = "option-insight-meta";
1300
+ meta.textContent = active.result.modelUsed;
1301
+ result.appendChild(meta);
1302
+ }
1303
+
1304
+ const resultActions = document.createElement("div");
1305
+ resultActions.className = "option-insight-result-actions";
1306
+
1307
+ const pinBtn = document.createElement("button");
1308
+ pinBtn.type = "button";
1309
+ pinBtn.className = "option-insight-secondary";
1310
+ pinBtn.textContent = "Pin";
1311
+ pinBtn.addEventListener("click", (event) => {
1312
+ event.preventDefault();
1313
+ event.stopPropagation();
1314
+ pinActiveInsight(question, optionKey);
1315
+ });
1316
+ resultActions.appendChild(pinBtn);
1317
+
1318
+ const moveUpBtn = document.createElement("button");
1319
+ moveUpBtn.type = "button";
1320
+ moveUpBtn.className = "option-insight-secondary";
1321
+ moveUpBtn.textContent = "Move up";
1322
+ moveUpBtn.disabled = getOptionIndexByKey(question.id, optionKey) <= 0;
1323
+ moveUpBtn.addEventListener("click", (event) => {
1324
+ event.preventDefault();
1325
+ event.stopPropagation();
1326
+ runOptionAction(question, optionKey, "move-up");
1327
+ });
1328
+ resultActions.appendChild(moveUpBtn);
1329
+
1330
+ if (active.result.suggestedText) {
1331
+ const replaceBtn = document.createElement("button");
1332
+ replaceBtn.type = "button";
1333
+ replaceBtn.className = "option-insight-primary";
1334
+ replaceBtn.textContent = "Use rewrite";
1335
+ replaceBtn.addEventListener("click", (event) => {
1336
+ event.preventDefault();
1337
+ event.stopPropagation();
1338
+ runOptionAction(question, optionKey, "replace-text", active.result.suggestedText);
1339
+ });
1340
+ resultActions.appendChild(replaceBtn);
1341
+
1342
+ const addBtn = document.createElement("button");
1343
+ addBtn.type = "button";
1344
+ addBtn.className = "option-insight-secondary";
1345
+ addBtn.textContent = "Add rewrite as option";
1346
+ addBtn.addEventListener("click", (event) => {
1347
+ event.preventDefault();
1348
+ event.stopPropagation();
1349
+ runOptionAction(question, optionKey, "add-option", active.result.suggestedText);
1350
+ });
1351
+ resultActions.appendChild(addBtn);
1352
+ }
1353
+
1354
+ result.appendChild(resultActions);
1355
+ panel.appendChild(result);
1356
+ }
1357
+
1358
+ return panel;
1359
+ }
1360
+
1361
+ function createOptionNoteInput(question, optionLabel, isSelected) {
1362
+ if (!questionCanClarifyOption(question) || !isSelected) return null;
1363
+
1364
+ const wrap = document.createElement("div");
1365
+ wrap.className = "option-note-wrap";
1366
+
1367
+ const input = document.createElement("input");
1368
+ input.type = "text";
1369
+ input.className = "option-note-input";
1370
+ input.placeholder = "Optional clarification...";
1371
+ input.dataset.questionId = question.id;
1372
+ input.dataset.optionLabel = optionLabel;
1373
+ input.value = getChoiceNote(question.id, optionLabel);
1374
+ input.addEventListener("input", () => {
1375
+ setChoiceNote(question.id, optionLabel, input.value);
1376
+ debounceSave();
1377
+ });
1378
+ setupEdgeNavigation(input);
1379
+ wrap.appendChild(input);
1380
+
1381
+ return wrap;
1382
+ }
1383
+
1384
+ function createChoiceOptionRow(question, option, optionIndex, options = {}) {
1385
+ const optionLabel = getOptionLabel(option);
1386
+ const optionContent = isRichOption(option) ? option.content : null;
1387
+ const optionKey = getOptionKeys(question.id)[optionIndex] || null;
1388
+ const generatedSet = options.generatedKeys || new Set();
1389
+ const insightable = questionSupportsOptionInsights(question) && !!optionKey;
1390
+ const askable = questionCanAskAboutOption(question) && !!optionKey;
1391
+ const activeInsight = optionKey ? getActiveInsight(question.id, optionKey) : null;
1392
+
1393
+ const row = document.createElement("div");
1394
+ row.className = "option-row";
1395
+ if (generatedSet.has(optionKey)) {
1396
+ row.classList.add("generated");
1397
+ }
1398
+ if (activeInsight) {
1399
+ row.classList.add("ask-open");
1400
+ }
1401
+
1402
+ const main = document.createElement("div");
1403
+ main.className = "option-row-main";
1404
+
1405
+ const label = document.createElement("label");
1406
+ label.className = "option-item";
1407
+ if (optionContent) {
1408
+ label.classList.add("has-code");
1409
+ }
1410
+ const input = document.createElement("input");
1411
+ input.type = question.type === "single" ? "radio" : "checkbox";
1412
+ input.name = question.id;
1413
+ input.value = optionLabel;
1414
+ input.id = `q-${question.id}-${optionIndex}`;
1415
+
1416
+ input.addEventListener("change", () => {
1417
+ syncChoiceNotesWithSelection(question);
1418
+ debounceSave();
1419
+ if (question.type === "multi") {
1420
+ updateDoneState(question.id);
1421
+ }
1422
+ replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1423
+ });
1424
+
1425
+ const body = document.createElement("div");
1426
+ body.className = "option-item-body";
1427
+
1428
+ const text = document.createElement("span");
1429
+ text.className = "option-item-label";
1430
+ text.textContent = optionLabel;
1431
+
1432
+ const recommended = question.recommended;
1433
+ const recommendedList = Array.isArray(recommended)
1434
+ ? recommended
1435
+ : recommended
1436
+ ? [recommended]
1437
+ : [];
1438
+ const shouldPreselect = recommendedList.length > 0 && question.conviction !== "slight";
1439
+
1440
+ if (recommendedList.includes(optionLabel)) {
1441
+ const pill = document.createElement("span");
1442
+ pill.className = "recommended-pill";
1443
+ pill.textContent = "Recommended";
1444
+ text.appendChild(pill);
1445
+ if (shouldPreselect) {
1446
+ input.checked = true;
1447
+ }
1448
+ }
1449
+
1450
+ body.appendChild(text);
1451
+
1452
+ if (optionContent) {
1453
+ const contentBlockEl = renderContentBlock(optionContent);
1454
+ if (contentBlockEl) {
1455
+ body.appendChild(contentBlockEl);
1456
+ }
1457
+ }
1458
+
1459
+ label.appendChild(input);
1460
+ label.appendChild(body);
1461
+
1462
+ main.appendChild(label);
1463
+ const selectedLabels = new Set(getSelectedOptionLabels(question.id));
1464
+ const noteInput = createOptionNoteInput(question, optionLabel, input.checked || selectedLabels.has(optionLabel));
1465
+
1466
+ if (insightable && optionKey) {
1467
+ if (askable) {
1468
+ const askButton = document.createElement("button");
1469
+ askButton.type = "button";
1470
+ askButton.className = "option-ask-btn";
1471
+ askButton.textContent = activeInsight ? "Hide" : "Ask";
1472
+ askButton.addEventListener("click", (event) => {
1473
+ event.preventDefault();
1474
+ event.stopPropagation();
1475
+ openOptionInsightPanel(question, optionKey);
1476
+ });
1477
+ main.appendChild(askButton);
1478
+
1479
+ const panel = createOptionInsightPanel(question, optionKey);
1480
+ row.appendChild(main);
1481
+ if (noteInput) row.appendChild(noteInput);
1482
+ if (panel) row.appendChild(panel);
1483
+ } else {
1484
+ row.appendChild(main);
1485
+ if (noteInput) row.appendChild(noteInput);
1486
+ }
1487
+
1488
+ const pinnedInsights = getPinnedInsights(question.id, optionKey);
1489
+ if (pinnedInsights.length > 0) {
1490
+ const pinnedWrap = document.createElement("div");
1491
+ pinnedWrap.className = "option-insight-pinned-list";
1492
+ pinnedInsights.forEach((insight) => {
1493
+ pinnedWrap.appendChild(createPinnedInsightCard(question, optionKey, insight));
1494
+ });
1495
+ row.appendChild(pinnedWrap);
1496
+ }
382
1497
 
383
- function renderLightMarkdown(text) {
384
- if (!text) return "";
385
- let html = text
386
- .replace(/&/g, "&amp;")
387
- .replace(/</g, "&lt;")
388
- .replace(/>/g, "&gt;");
389
- html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
390
- html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
391
- html = html.replace(/\n/g, "<br>");
392
- html = html.replace(/\s(\d+\.)\s/g, "<br>$1 ");
393
- return html;
394
- }
1498
+ return row;
1499
+ }
395
1500
 
396
- function getOptionLabel(option) {
397
- return typeof option === "string" ? option : option.label;
1501
+ row.appendChild(main);
1502
+ if (noteInput) row.appendChild(noteInput);
1503
+ return row;
398
1504
  }
399
1505
 
400
- function isRichOption(option) {
401
- return typeof option === "object" && option !== null && "label" in option;
402
- }
1506
+ function createChoiceQuestionList(question, title, options = {}) {
1507
+ const list = document.createElement("div");
1508
+ list.className = "option-list";
1509
+ list.setAttribute("role", question.type === "single" ? "radiogroup" : "group");
1510
+ list.setAttribute("aria-labelledby", title.id);
403
1511
 
404
- function syncRecommendations(question, options) {
405
- if (!question.recommended) return;
1512
+ const generatedKeys = new Set(options.generatedKeys || []);
406
1513
 
407
- if (question.type === "single") {
408
- if (typeof question.recommended === "string" && options.includes(question.recommended)) {
409
- return;
1514
+ question.options.forEach((option, optionIndex) => {
1515
+ list.appendChild(createChoiceOptionRow(question, option, optionIndex, { generatedKeys }));
1516
+ });
1517
+
1518
+ const generateMoreEl = createGenerateMoreUI(question, list);
1519
+ if (generateMoreEl) list.appendChild(generateMoreEl);
1520
+
1521
+ const otherLabel = document.createElement("label");
1522
+ otherLabel.className = "option-item option-other";
1523
+ const otherCheck = document.createElement("input");
1524
+ otherCheck.type = question.type === "single" ? "radio" : "checkbox";
1525
+ otherCheck.name = question.id;
1526
+ otherCheck.value = "__other__";
1527
+ otherCheck.id = `q-${question.id}-other`;
1528
+ const otherInput = document.createElement("textarea");
1529
+ otherInput.className = "other-input";
1530
+ otherInput.placeholder = "Other...";
1531
+ otherInput.rows = 1;
1532
+ otherInput.dataset.questionId = question.id;
1533
+ const autoResizeOther = () => {
1534
+ otherInput.style.height = "auto";
1535
+ otherInput.style.height = otherInput.scrollHeight + "px";
1536
+ };
1537
+ otherInput.addEventListener("input", () => {
1538
+ autoResizeOther();
1539
+ if (otherInput.value && !otherCheck.checked) {
1540
+ otherCheck.checked = true;
1541
+ if (question.type === "multi") updateDoneState(question.id);
410
1542
  }
411
- delete question.recommended;
412
- delete question.conviction;
413
- return;
414
- }
1543
+ debounceSave();
1544
+ });
1545
+ otherInput.addEventListener("focus", () => {
1546
+ if (!otherCheck.checked) {
1547
+ otherCheck.checked = true;
1548
+ if (question.type === "multi") updateDoneState(question.id);
1549
+ debounceSave();
1550
+ }
1551
+ });
1552
+ otherCheck.addEventListener("change", () => {
1553
+ debounceSave();
1554
+ if (question.type === "multi") updateDoneState(question.id);
1555
+ if (otherCheck.checked) otherInput.focus();
1556
+ });
1557
+ setupEdgeNavigation(otherInput);
1558
+ otherLabel.appendChild(otherCheck);
1559
+ otherLabel.appendChild(otherInput);
1560
+ list.appendChild(otherLabel);
415
1561
 
416
- if (question.type !== "multi") {
417
- delete question.recommended;
418
- delete question.conviction;
419
- return;
1562
+ if (question.type === "multi") {
1563
+ const doneItem = document.createElement("div");
1564
+ doneItem.className = "option-item done-item disabled";
1565
+ doneItem.setAttribute("tabindex", "0");
1566
+ doneItem.dataset.doneFor = question.id;
1567
+ doneItem.innerHTML = '<span class="done-check">✓</span><span>Done</span>';
1568
+ doneItem.addEventListener("click", () => {
1569
+ if (!doneItem.classList.contains("disabled")) {
1570
+ nextQuestion();
1571
+ }
1572
+ });
1573
+ doneItem.addEventListener("keydown", (e) => {
1574
+ if ((e.key === "Enter" || e.key === " ") && !doneItem.classList.contains("disabled")) {
1575
+ e.preventDefault();
1576
+ e.stopPropagation();
1577
+ nextQuestion();
1578
+ }
1579
+ });
1580
+ list.appendChild(doneItem);
420
1581
  }
421
1582
 
422
- const nextRecommended = (Array.isArray(question.recommended)
423
- ? question.recommended
424
- : [question.recommended]).filter((option) => options.includes(option));
425
- if (nextRecommended.length === 0) {
426
- delete question.recommended;
427
- delete question.conviction;
428
- return;
429
- }
430
- question.recommended = nextRecommended;
1583
+ return list;
431
1584
  }
432
1585
 
433
- function renderCodeBlock(block) {
434
- if (!block || !block.code) return null;
1586
+ function renderContentBlock(block) {
1587
+ if (!block || !block.source) return null;
435
1588
 
1589
+ const markdownPreview = isMarkdownLang(block.lang) && block.showSource !== true;
436
1590
  const container = document.createElement("div");
437
1591
  container.className = "code-block";
438
-
439
- const showLineNumbers = !!block.file || !!block.lines;
440
- const isDiff = block.lang === "diff";
441
- const lines = block.code.split("\n");
442
- const highlights = new Set(block.highlights || []);
443
-
444
- // Parse starting line number from lines prop (e.g., "10-16" -> 10, "42" -> 42)
445
- let startLineNum = 1;
446
- if (block.lines) {
447
- const match = block.lines.match(/^(\d+)/);
448
- if (match) startLineNum = parseInt(match[1], 10);
1592
+ if (markdownPreview) {
1593
+ container.classList.add("markdown-content-block");
449
1594
  }
450
1595
 
451
1596
  if (block.file || block.lines || block.lang || block.title) {
@@ -483,6 +1628,25 @@
483
1628
  container.appendChild(header);
484
1629
  }
485
1630
 
1631
+ if (markdownPreview) {
1632
+ const preview = document.createElement("div");
1633
+ preview.className = "markdown-preview";
1634
+ preview.innerHTML = renderMarkdownPreviewFallback(block.source);
1635
+ container.appendChild(preview);
1636
+ return container;
1637
+ }
1638
+
1639
+ const showLineNumbers = !!block.file || !!block.lines;
1640
+ const isDiff = block.lang === "diff";
1641
+ const lines = block.source.split("\n");
1642
+ const highlights = new Set(block.highlights || []);
1643
+
1644
+ let startLineNum = 1;
1645
+ if (block.lines) {
1646
+ const match = block.lines.match(/^(\d+)/);
1647
+ if (match) startLineNum = parseInt(match[1], 10);
1648
+ }
1649
+
486
1650
  const pre = document.createElement("pre");
487
1651
  const code = document.createElement("code");
488
1652
 
@@ -526,7 +1690,7 @@
526
1690
 
527
1691
  code.appendChild(linesContainer);
528
1692
  } else {
529
- code.textContent = block.code;
1693
+ code.textContent = block.source;
530
1694
  }
531
1695
 
532
1696
  pre.appendChild(code);
@@ -1076,8 +2240,14 @@
1076
2240
  return items;
1077
2241
  }
1078
2242
 
2243
+ function getTabStopsForCard(card) {
2244
+ return Array.from(
2245
+ card.querySelectorAll('input[type="radio"], input[type="checkbox"], .option-note-input, .option-ask-btn, .file-dropzone, .image-path-input, .done-item')
2246
+ );
2247
+ }
2248
+
1079
2249
  function isPathInput(el) {
1080
- return el && (el.classList.contains('image-path-input') || el.classList.contains('attach-inline-path') || el.classList.contains('other-input'));
2250
+ return el && (el.classList.contains('image-path-input') || el.classList.contains('attach-inline-path') || el.classList.contains('other-input') || el.classList.contains('option-note-input'));
1081
2251
  }
1082
2252
 
1083
2253
  function isDropzone(el) {
@@ -1133,7 +2303,7 @@
1133
2303
  function highlightOption(card, optionIndex, isKeyboard = true) {
1134
2304
  const options = getOptionsForCard(card);
1135
2305
  options.forEach((opt, i) => {
1136
- const item = isOptionInput(opt) ? opt.closest('.option-item') : opt;
2306
+ const item = isOptionInput(opt) ? (opt.closest('.option-row') || opt.closest('.option-item')) : opt;
1137
2307
  item?.classList.toggle('focused', i === optionIndex);
1138
2308
  });
1139
2309
  const current = options[optionIndex];
@@ -1145,8 +2315,31 @@
1145
2315
  }
1146
2316
  }
1147
2317
 
2318
+ function focusCardTabStop(card, target, isKeyboard = true) {
2319
+ if (!target) return;
2320
+
2321
+ clearOptionHighlight(card);
2322
+
2323
+ const row = target.closest?.('.option-row');
2324
+ const highlightTarget = row || (isOptionInput(target) ? target.closest('.option-item') : target);
2325
+ highlightTarget?.classList.add('focused');
2326
+
2327
+ const rowInput = row?.querySelector('input[type="radio"], input[type="checkbox"]');
2328
+ const options = getOptionsForCard(card);
2329
+ const navTarget = rowInput || target;
2330
+ const nextIndex = options.indexOf(navTarget);
2331
+ if (nextIndex >= 0) {
2332
+ nav.optionIndex = nextIndex;
2333
+ }
2334
+
2335
+ target.focus();
2336
+ if (isKeyboard) {
2337
+ card.classList.add('keyboard-nav');
2338
+ }
2339
+ }
2340
+
1148
2341
  function clearOptionHighlight(card) {
1149
- card.querySelectorAll('.option-item, .done-item, .file-dropzone, .image-path-input').forEach(item => {
2342
+ card.querySelectorAll('.option-row, .option-item, .done-item, .file-dropzone, .image-path-input').forEach(item => {
1150
2343
  item.classList.remove('focused');
1151
2344
  });
1152
2345
  }
@@ -1258,25 +2451,33 @@
1258
2451
  const options = getOptionsForCard(card);
1259
2452
  const textarea = card.querySelector('textarea');
1260
2453
  const isTextFocused = document.activeElement === textarea;
1261
-
2454
+ const inAskArea = document.activeElement?.closest('.option-insight-panel, .option-ask-btn, .option-insight-pinned');
2455
+ const inOptionNote = document.activeElement?.closest('.option-note-wrap');
2456
+
1262
2457
  if (event.key === 'Tab') {
1263
2458
  const inAttachArea = document.activeElement?.closest('.attach-inline');
1264
2459
  const inGenerateArea = document.activeElement?.closest('.generate-more');
1265
- if (inAttachArea || inGenerateArea) return;
1266
-
1267
- event.preventDefault();
2460
+ if (inAttachArea || inGenerateArea || inAskArea || inOptionNote) return;
1268
2461
 
1269
- if (options.length > 0) {
1270
- if (event.shiftKey) {
1271
- nav.optionIndex = (nav.optionIndex - 1 + options.length) % options.length;
1272
- } else {
1273
- nav.optionIndex = (nav.optionIndex + 1) % options.length;
1274
- }
1275
- highlightOption(card, nav.optionIndex);
2462
+ const tabStops = getTabStopsForCard(card);
2463
+ if (tabStops.length === 0) {
2464
+ return;
1276
2465
  }
2466
+
2467
+ event.preventDefault();
2468
+
2469
+ const activeIndex = tabStops.indexOf(document.activeElement);
2470
+ const fallbackIndex = options[nav.optionIndex] ? tabStops.indexOf(options[nav.optionIndex]) : -1;
2471
+ const currentIndex = activeIndex >= 0 ? activeIndex : (fallbackIndex >= 0 ? fallbackIndex : 0);
2472
+ const nextIndex = event.shiftKey
2473
+ ? (currentIndex - 1 + tabStops.length) % tabStops.length
2474
+ : (currentIndex + 1) % tabStops.length;
2475
+ focusCardTabStop(card, tabStops[nextIndex]);
1277
2476
  return;
1278
2477
  }
1279
-
2478
+
2479
+ if (inAskArea || inOptionNote) return;
2480
+
1280
2481
  if (event.key === 'ArrowLeft') {
1281
2482
  if (isTextFocused || isPathInput(document.activeElement)) {
1282
2483
  return;
@@ -1311,16 +2512,19 @@
1311
2512
  }
1312
2513
 
1313
2514
  if (event.key === 'Enter' || event.key === ' ') {
1314
- if (isPathInput(document.activeElement)) {
1315
- return;
1316
- }
1317
- if (document.activeElement?.closest('.attach-inline')) {
1318
- return;
1319
- }
1320
- if (document.activeElement?.closest('.generate-more')) {
1321
- return;
1322
- }
1323
- event.preventDefault();
2515
+ if (isPathInput(document.activeElement)) {
2516
+ return;
2517
+ }
2518
+ if (document.activeElement?.closest('.attach-inline')) {
2519
+ return;
2520
+ }
2521
+ if (document.activeElement?.closest('.generate-more')) {
2522
+ return;
2523
+ }
2524
+ if (document.activeElement?.closest('.option-insight-panel, .option-ask-btn')) {
2525
+ return;
2526
+ }
2527
+ event.preventDefault();
1324
2528
  const option = options[nav.optionIndex];
1325
2529
  if (option) {
1326
2530
  if (isDoneItem(option)) {
@@ -1355,6 +2559,17 @@
1355
2559
  }
1356
2560
  return;
1357
2561
  }
2562
+
2563
+ if ((event.key === 'a' || event.key === 'A') && isOptionInput(document.activeElement)) {
2564
+ event.preventDefault();
2565
+ const focusedInput = options[nav.optionIndex];
2566
+ const row = focusedInput?.closest('.option-row');
2567
+ const askButton = row?.querySelector('.option-ask-btn');
2568
+ if (askButton) {
2569
+ askButton.click();
2570
+ }
2571
+ return;
2572
+ }
1358
2573
  }
1359
2574
 
1360
2575
  if (textarea && !isTextFocused) {
@@ -1423,7 +2638,6 @@
1423
2638
 
1424
2639
  function createGenerateMoreUI(question, list) {
1425
2640
  if (!data.canGenerate) return null;
1426
- if (question.options.some(isRichOption)) return null;
1427
2641
 
1428
2642
  const container = document.createElement("div");
1429
2643
  container.className = "generate-more";
@@ -1482,15 +2696,6 @@
1482
2696
  }, timeoutMs);
1483
2697
  }
1484
2698
 
1485
- function getExistingOptions() {
1486
- const inputs = list.querySelectorAll(
1487
- 'input[name="' + escapeSelector(question.id) + '"]'
1488
- );
1489
- return Array.from(inputs)
1490
- .map((input) => input.value)
1491
- .filter((v) => v && v !== "__other__");
1492
- }
1493
-
1494
2699
  async function runGenerate(btn, mode) {
1495
2700
  if (generating) {
1496
2701
  if (abortController) abortController.abort();
@@ -1507,7 +2712,7 @@
1507
2712
  clearStatus();
1508
2713
 
1509
2714
  abortController = new AbortController();
1510
- const existingOptions = getExistingOptions();
2715
+ const currentValue = getQuestionValue(question);
1511
2716
 
1512
2717
  try {
1513
2718
  const response = await fetch("/generate", {
@@ -1516,7 +2721,6 @@
1516
2721
  body: JSON.stringify({
1517
2722
  token: sessionToken,
1518
2723
  questionId: question.id,
1519
- existingOptions,
1520
2724
  mode,
1521
2725
  }),
1522
2726
  signal: abortController.signal,
@@ -1524,25 +2728,23 @@
1524
2728
 
1525
2729
  const result = await response.json();
1526
2730
  if (!result.ok) throw new Error(result.error || "Generation failed");
1527
- if (!Array.isArray(result.options) || result.options.length === 0) {
2731
+ if (!Array.isArray(result.options)) {
2732
+ throw new Error("Generation returned invalid options");
2733
+ }
2734
+ if (mode === "review" && result.options.length === 0) {
1528
2735
  throw new Error("No options generated");
1529
2736
  }
2737
+ if (Array.isArray(result.optionKeys)) {
2738
+ setOptionKeys(question.id, result.optionKeys);
2739
+ pruneQuestionOptionInsights(question.id);
2740
+ }
1530
2741
 
1531
2742
  if (mode === "review") {
1532
2743
  if (typeof result.question !== "string" || !result.question.trim()) {
1533
2744
  throw new Error("No revised question returned");
1534
2745
  }
1535
2746
 
1536
- const seen = new Set();
1537
- const revisedOptions = result.options.filter((option) => {
1538
- const key = option.toLowerCase().trim();
1539
- if (seen.has(key)) return false;
1540
- seen.add(key);
1541
- return true;
1542
- });
1543
- if (revisedOptions.length === 0) {
1544
- throw new Error("No valid options returned for review");
1545
- }
2747
+ const revisedOptions = result.options;
1546
2748
 
1547
2749
  question.question = result.question.trim();
1548
2750
  question.options = revisedOptions;
@@ -1551,38 +2753,27 @@
1551
2753
  if (title) {
1552
2754
  title.innerHTML = renderLightMarkdown(question.question);
1553
2755
  }
1554
-
1555
- list
1556
- .querySelectorAll('.option-item:not(.option-other):not(.done-item)')
1557
- .forEach((el) => el.remove());
1558
- revisedOptions.forEach((optionText, i) => {
1559
- const optionEl = createGeneratedOption(question, optionText, i);
1560
- list.insertBefore(optionEl, container);
1561
- });
1562
- if (question.type === "multi") updateDoneState(question.id);
2756
+ const revisedLabels = new Set(revisedOptions.map((option) => getOptionLabel(option)));
2757
+ const nextValue = preserveChoiceAnswerValue(question, currentValue, revisedLabels);
2758
+ replaceQuestionOptionList(question, nextValue);
1563
2759
  debounceSave();
1564
2760
  showStatus(
1565
2761
  "Question updated and " + revisedOptions.length + " option" + (revisedOptions.length > 1 ? "s" : "") + " revised",
1566
2762
  2500,
1567
2763
  );
1568
2764
  } else {
1569
- const existingSet = new Set(existingOptions.map((o) => o.toLowerCase().trim()));
1570
- const seen = new Set();
1571
- const newOptions = result.options.filter((o) => {
1572
- const key = o.toLowerCase().trim();
1573
- if (existingSet.has(key) || seen.has(key)) return false;
1574
- seen.add(key);
1575
- return true;
1576
- });
2765
+ const newOptions = result.options;
1577
2766
 
1578
2767
  if (newOptions.length === 0) {
1579
2768
  showStatus("All generated options already exist", 3000);
1580
2769
  } else {
1581
2770
  question.options = question.options.concat(newOptions);
1582
- newOptions.forEach((optionText, i) => {
1583
- const optionEl = createGeneratedOption(question, optionText, i);
1584
- list.insertBefore(optionEl, container);
2771
+ const optionKeys = getOptionKeys(question.id);
2772
+ const generatedKeys = optionKeys.slice(-newOptions.length);
2773
+ replaceQuestionOptionList(question, currentValue, generatedKeys[0] || null, {
2774
+ generatedKeys,
1585
2775
  });
2776
+ debounceSave();
1586
2777
  showStatus(
1587
2778
  newOptions.length + " option" + (newOptions.length > 1 ? "s" : "") + " added",
1588
2779
  2500,
@@ -1612,33 +2803,6 @@
1612
2803
  return container;
1613
2804
  }
1614
2805
 
1615
- function createGeneratedOption(question, optionText, animIndex) {
1616
- const label = document.createElement("label");
1617
- label.className = "option-item generated";
1618
- label.style.animationDelay = (animIndex * 0.08) + "s";
1619
-
1620
- const input = document.createElement("input");
1621
- input.type = question.type === "single" ? "radio" : "checkbox";
1622
- input.name = question.id;
1623
- input.value = optionText;
1624
- input.setAttribute("tabindex", "-1");
1625
-
1626
- input.addEventListener("change", () => {
1627
- debounceSave();
1628
- if (question.type === "multi") {
1629
- updateDoneState(question.id);
1630
- }
1631
- });
1632
-
1633
- const text = document.createElement("span");
1634
- text.textContent = optionText;
1635
-
1636
- label.appendChild(input);
1637
- label.appendChild(text);
1638
-
1639
- return label;
1640
- }
1641
-
1642
2806
  function createQuestionCard(question, index, badgeNumber) {
1643
2807
  const card = document.createElement("section");
1644
2808
  card.className = "question-card";
@@ -1676,11 +2840,11 @@
1676
2840
  card.appendChild(context);
1677
2841
  }
1678
2842
 
1679
- if (question.codeBlock) {
1680
- const codeBlockEl = renderCodeBlock(question.codeBlock);
1681
- if (codeBlockEl) {
1682
- codeBlockEl.classList.add("question-code-block");
1683
- card.appendChild(codeBlockEl);
2843
+ if (question.content) {
2844
+ const contentBlockEl = renderContentBlock(question.content);
2845
+ if (contentBlockEl) {
2846
+ contentBlockEl.classList.add("question-code-block");
2847
+ card.appendChild(contentBlockEl);
1684
2848
  }
1685
2849
  }
1686
2850
 
@@ -1711,135 +2875,7 @@
1711
2875
  }
1712
2876
 
1713
2877
  if (question.type === "single" || question.type === "multi") {
1714
- const list = document.createElement("div");
1715
- list.className = "option-list";
1716
- list.setAttribute("role", question.type === "single" ? "radiogroup" : "group");
1717
- list.setAttribute("aria-labelledby", title.id);
1718
-
1719
- const recommended = question.recommended;
1720
- const recommendedList = Array.isArray(recommended)
1721
- ? recommended
1722
- : recommended
1723
- ? [recommended]
1724
- : [];
1725
- const shouldPreselect = recommendedList.length > 0 && question.conviction !== "slight";
1726
-
1727
- question.options.forEach((option, optionIndex) => {
1728
- const optionLabel = getOptionLabel(option);
1729
- const optionCode = isRichOption(option) ? option.code : null;
1730
-
1731
- const label = document.createElement("label");
1732
- label.className = "option-item";
1733
- if (optionCode) {
1734
- label.classList.add("has-code");
1735
- }
1736
-
1737
- const input = document.createElement("input");
1738
- input.type = question.type === "single" ? "radio" : "checkbox";
1739
- input.name = question.id;
1740
- input.value = optionLabel;
1741
- input.id = `q-${question.id}-${optionIndex}`;
1742
-
1743
- input.addEventListener("change", () => {
1744
- debounceSave();
1745
- if (question.type === "multi") {
1746
- updateDoneState(question.id);
1747
- }
1748
- });
1749
-
1750
- const text = document.createElement("span");
1751
- text.textContent = optionLabel;
1752
-
1753
- if (recommendedList.includes(optionLabel)) {
1754
- const pill = document.createElement("span");
1755
- pill.className = "recommended-pill";
1756
- pill.textContent = "Recommended";
1757
- text.appendChild(pill);
1758
-
1759
- if (shouldPreselect) {
1760
- input.checked = true;
1761
- }
1762
- }
1763
-
1764
- label.appendChild(input);
1765
- label.appendChild(text);
1766
-
1767
- if (optionCode) {
1768
- const codeBlockEl = renderCodeBlock(optionCode);
1769
- if (codeBlockEl) {
1770
- label.appendChild(codeBlockEl);
1771
- }
1772
- }
1773
-
1774
- list.appendChild(label);
1775
- });
1776
-
1777
- const generateMoreEl = createGenerateMoreUI(question, list);
1778
- if (generateMoreEl) list.appendChild(generateMoreEl);
1779
-
1780
- const otherLabel = document.createElement("label");
1781
- otherLabel.className = "option-item option-other";
1782
- const otherCheck = document.createElement("input");
1783
- otherCheck.type = question.type === "single" ? "radio" : "checkbox";
1784
- otherCheck.name = question.id;
1785
- otherCheck.value = "__other__";
1786
- otherCheck.id = `q-${question.id}-other`;
1787
- const otherInput = document.createElement("textarea");
1788
- otherInput.className = "other-input";
1789
- otherInput.placeholder = "Other...";
1790
- otherInput.rows = 1;
1791
- otherInput.dataset.questionId = question.id;
1792
- const autoResizeOther = () => {
1793
- otherInput.style.height = "auto";
1794
- otherInput.style.height = otherInput.scrollHeight + "px";
1795
- };
1796
- otherInput.addEventListener("input", () => {
1797
- autoResizeOther();
1798
- if (otherInput.value && !otherCheck.checked) {
1799
- otherCheck.checked = true;
1800
- if (question.type === "multi") updateDoneState(question.id);
1801
- }
1802
- debounceSave();
1803
- });
1804
- otherInput.addEventListener("focus", () => {
1805
- if (!otherCheck.checked) {
1806
- otherCheck.checked = true;
1807
- if (question.type === "multi") updateDoneState(question.id);
1808
- debounceSave();
1809
- }
1810
- });
1811
- otherCheck.addEventListener("change", () => {
1812
- debounceSave();
1813
- if (question.type === "multi") updateDoneState(question.id);
1814
- if (otherCheck.checked) otherInput.focus();
1815
- });
1816
- setupEdgeNavigation(otherInput);
1817
- otherLabel.appendChild(otherCheck);
1818
- otherLabel.appendChild(otherInput);
1819
- list.appendChild(otherLabel);
1820
-
1821
- if (question.type === "multi") {
1822
- const doneItem = document.createElement("div");
1823
- doneItem.className = "option-item done-item disabled";
1824
- doneItem.setAttribute("tabindex", "0");
1825
- doneItem.dataset.doneFor = question.id;
1826
- doneItem.innerHTML = '<span class="done-check">✓</span><span>Done</span>';
1827
- doneItem.addEventListener("click", () => {
1828
- if (!doneItem.classList.contains("disabled")) {
1829
- nextQuestion();
1830
- }
1831
- });
1832
- doneItem.addEventListener("keydown", (e) => {
1833
- if ((e.key === "Enter" || e.key === " ") && !doneItem.classList.contains("disabled")) {
1834
- e.preventDefault();
1835
- e.stopPropagation();
1836
- nextQuestion();
1837
- }
1838
- });
1839
- list.appendChild(doneItem);
1840
- }
1841
-
1842
- card.appendChild(list);
2878
+ card.appendChild(createChoiceQuestionList(question, title));
1843
2879
  }
1844
2880
 
1845
2881
  if (question.type === "text") {
@@ -2069,17 +3105,7 @@
2069
3105
  if (files && files.length > 0) {
2070
3106
  const file = files[0];
2071
3107
  if (!file.type.startsWith("image/")) return;
2072
- if (question.type === "image") {
2073
- const input = card.querySelector('input[type="file"]');
2074
- if (input) {
2075
- const dt = new DataTransfer();
2076
- dt.items.add(file);
2077
- input.files = dt.files;
2078
- input.dispatchEvent(new Event("change"));
2079
- }
2080
- } else {
2081
- void addPastedImage(question, file);
2082
- }
3108
+ void addDroppedImage(question, file);
2083
3109
  }
2084
3110
  });
2085
3111
 
@@ -2179,8 +3205,9 @@
2179
3205
  input.value = "";
2180
3206
  return;
2181
3207
  }
2182
- } catch (_err) {
2183
- setFieldError(questionId, "Failed to validate image.");
3208
+ } catch (err) {
3209
+ const message = err instanceof Error ? err.message : "Failed to validate image.";
3210
+ setFieldError(questionId, message);
2184
3211
  input.value = "";
2185
3212
  return;
2186
3213
  }
@@ -2188,37 +3215,15 @@
2188
3215
  manager.addFile(questionId, file);
2189
3216
  }
2190
3217
 
2191
- function resolveQuestionContext(target) {
2192
- const element = target && target.closest ? target : null;
2193
- let card = element ? element.closest(".question-card") : null;
2194
-
2195
- if (!card) {
2196
- card = document.querySelector(".question-card.active");
2197
- }
2198
-
2199
- if (card?.dataset?.questionId) {
2200
- const question = questions.find((q) => q.id === card.dataset.questionId);
2201
- if (question) {
2202
- return { question, card };
2203
- }
2204
- }
2205
-
2206
- const question = questions[nav.questionIndex];
2207
- const fallbackCard = nav.cards[nav.questionIndex];
2208
- if (!question || !fallbackCard) return null;
2209
- return { question, card: fallbackCard };
2210
- }
2211
-
2212
3218
  function revealAttachmentArea(questionId) {
2213
3219
  const attachInline = document.querySelector(
2214
3220
  `[data-attach-inline-for="${escapeSelector(questionId)}"]`
2215
3221
  );
2216
- if (attachInline?.classList.contains("hidden")) {
2217
- attachInline.classList.remove("hidden");
2218
- }
3222
+ if (!attachInline?.classList.contains("hidden")) return;
3223
+ attachInline.classList.remove("hidden");
2219
3224
  }
2220
3225
 
2221
- async function addPastedImage(question, file) {
3226
+ async function addDroppedImage(question, file) {
2222
3227
  if (countUploadedFiles(question.id) + 1 > MAX_IMAGES) {
2223
3228
  setFieldError(question.id, `Only ${MAX_IMAGES} images allowed.`);
2224
3229
  return;
@@ -2230,59 +3235,20 @@
2230
3235
  setFieldError(question.id, validation.error);
2231
3236
  return;
2232
3237
  }
2233
- } catch (_err) {
2234
- setFieldError(question.id, "Failed to validate image.");
3238
+ } catch (err) {
3239
+ const message = err instanceof Error ? err.message : "Failed to validate image.";
3240
+ setFieldError(question.id, message);
2235
3241
  return;
2236
3242
  }
2237
3243
 
2238
3244
  setFieldError(question.id, "");
2239
3245
  if (question.type === "image") {
2240
3246
  questionImages.addFile(question.id, file);
2241
- } else {
2242
- revealAttachmentArea(question.id);
2243
- attachments.addFile(question.id, file);
2244
- }
2245
- }
2246
-
2247
- function handlePaste(event) {
2248
- if (nav.inSubmitArea || session.expired) return;
2249
- const clipboard = event.clipboardData;
2250
- if (!clipboard) return;
2251
-
2252
- const active = document.activeElement;
2253
- const isTextInput = active && (active.tagName === "TEXTAREA" || (active.tagName === "INPUT" && active.type === "text"));
2254
- if (isTextInput && clipboard.getData("text/plain")) {
2255
- return;
2256
- }
2257
-
2258
- const context = resolveQuestionContext(event.target);
2259
- if (!context) return;
2260
-
2261
- const items = Array.from(clipboard.items || []);
2262
- const imageItem = items.find((item) => item.type && item.type.startsWith("image/"));
2263
-
2264
- if (imageItem) {
2265
- const file = imageItem.getAsFile();
2266
- if (!file) return;
2267
- event.preventDefault();
2268
- void addPastedImage(context.question, file);
2269
3247
  return;
2270
3248
  }
2271
3249
 
2272
- const text = clipboard.getData("text/plain")?.trim();
2273
- const isPathLike = text && (text.startsWith("/") || text.startsWith("~") || text.match(/^[a-zA-Z]:\\/));
2274
- const hasImageExtension = text && /\.(png|jpe?g|gif|webp)$/i.test(text);
2275
-
2276
- if (isPathLike && hasImageExtension) {
2277
- event.preventDefault();
2278
- const normalizedPath = normalizePath(text);
2279
- if (context.question.type === "image") {
2280
- questionImages.addPath(context.question.id, normalizedPath);
2281
- } else {
2282
- revealAttachmentArea(context.question.id);
2283
- attachments.addPath(context.question.id, normalizedPath);
2284
- }
2285
- }
3250
+ revealAttachmentArea(question.id);
3251
+ attachments.addFile(question.id, file);
2286
3252
  }
2287
3253
 
2288
3254
  function countUploadedFiles(excludingId) {
@@ -2306,13 +3272,24 @@
2306
3272
  if (question.type === "single") {
2307
3273
  const selected = formEl.querySelector(`input[name="${escapeSelector(id)}"]:checked`);
2308
3274
  if (!selected) return "";
2309
- if (selected.value === "__other__") return getOtherValue(id);
2310
- return selected.value;
3275
+ if (selected.value === "__other__") {
3276
+ const otherValue = getOtherValue(id).trim();
3277
+ return otherValue ? { option: otherValue } : "";
3278
+ }
3279
+ const note = questionCanClarifyOption(question) ? getChoiceNote(id, selected.value) : "";
3280
+ return note ? { option: selected.value, note } : { option: selected.value };
2311
3281
  }
2312
3282
  if (question.type === "multi") {
2313
3283
  return Array.from(
2314
3284
  formEl.querySelectorAll(`input[name="${escapeSelector(id)}"]:checked`)
2315
- ).map((input) => input.value === "__other__" ? getOtherValue(id) : input.value).filter(v => v);
3285
+ ).map((input) => {
3286
+ if (input.value === "__other__") {
3287
+ const otherValue = getOtherValue(id).trim();
3288
+ return otherValue ? { option: otherValue } : null;
3289
+ }
3290
+ const note = questionCanClarifyOption(question) ? getChoiceNote(id, input.value) : "";
3291
+ return note ? { option: input.value, note } : { option: input.value };
3292
+ }).filter((value) => value && value.option);
2316
3293
  }
2317
3294
  if (question.type === "text") {
2318
3295
  const textarea = formEl.querySelector(`textarea[data-question-id="${escapeSelector(id)}"]`);
@@ -2338,31 +3315,54 @@
2338
3315
  }
2339
3316
 
2340
3317
  function collectPersistedData() {
2341
- const data = {};
3318
+ const answers = {};
2342
3319
  questions.forEach((question) => {
2343
3320
  if (question.type === "info" || question.type === "image") return;
2344
- data[question.id] = getQuestionValue(question);
3321
+ answers[question.id] = getQuestionValue(question);
2345
3322
  });
2346
- return data;
3323
+ return {
3324
+ answers,
3325
+ savedOptionInsights: serializeSavedOptionInsights(),
3326
+ };
3327
+ }
3328
+
3329
+ function getSavedSingleChoiceValue(value) {
3330
+ return normalizeChoiceResponseValue(value);
3331
+ }
3332
+
3333
+ function getSavedMultiChoiceValues(value) {
3334
+ if (!Array.isArray(value)) return [];
3335
+ return value.map((item) => normalizeChoiceResponseValue(item)).filter(Boolean);
2347
3336
  }
2348
3337
 
2349
- function populateForm(saved) {
3338
+ function populateForm(saved, options = {}) {
3339
+ const { preserveChoiceNotes = false } = options;
2350
3340
  if (!saved) return;
2351
3341
  questions.forEach((question) => {
3342
+ const hasSavedValue = Object.prototype.hasOwnProperty.call(saved, question.id);
2352
3343
  const value = saved[question.id];
2353
- if (question.type === "single" && typeof value === "string") {
3344
+ if (question.type === "single") {
2354
3345
  const radios = formEl.querySelectorAll(
2355
3346
  `input[name="${escapeSelector(question.id)}"]`
2356
3347
  );
2357
3348
  radios.forEach((radio) => {
2358
3349
  radio.checked = false;
2359
3350
  });
2360
- if (value !== "") {
3351
+ if (!preserveChoiceNotes) {
3352
+ clearChoiceNotes(question.id);
3353
+ }
3354
+ if (!hasSavedValue) return;
3355
+ const choiceValue = getSavedSingleChoiceValue(value);
3356
+ if (!choiceValue) return;
3357
+ if (choiceValue.option !== "") {
2361
3358
  const input = formEl.querySelector(
2362
- `input[name="${escapeSelector(question.id)}"][value="${escapeSelector(value)}"]`
3359
+ `input[name="${escapeSelector(question.id)}"][value="${escapeSelector(choiceValue.option)}"]`
2363
3360
  );
2364
3361
  if (input) {
2365
3362
  input.checked = true;
3363
+ if (questionCanClarifyOption(question) && choiceValue.note) {
3364
+ setChoiceNote(question.id, choiceValue.option, choiceValue.note);
3365
+ }
2366
3366
  } else {
2367
3367
  const otherCheck = formEl.querySelector(
2368
3368
  `input[name="${escapeSelector(question.id)}"][value="__other__"]`
@@ -2372,28 +3372,36 @@
2372
3372
  );
2373
3373
  if (otherCheck && otherInput) {
2374
3374
  otherCheck.checked = true;
2375
- otherInput.value = value;
3375
+ otherInput.value = choiceValue.option;
2376
3376
  otherInput.dispatchEvent(new Event("input", { bubbles: true }));
2377
3377
  }
2378
3378
  }
2379
3379
  }
2380
3380
  }
2381
- if (question.type === "multi" && Array.isArray(value)) {
3381
+ if (question.type === "multi") {
2382
3382
  const checkboxes = formEl.querySelectorAll(
2383
3383
  `input[name="${escapeSelector(question.id)}"]`
2384
3384
  );
2385
3385
  checkboxes.forEach((checkbox) => {
2386
3386
  checkbox.checked = false;
2387
3387
  });
3388
+ if (!preserveChoiceNotes) {
3389
+ clearChoiceNotes(question.id);
3390
+ }
3391
+ if (!hasSavedValue) return;
3392
+ const choiceValues = getSavedMultiChoiceValues(value);
2388
3393
  let otherValue = "";
2389
- value.forEach((val) => {
3394
+ choiceValues.forEach((choiceValue) => {
2390
3395
  const input = formEl.querySelector(
2391
- `input[name="${escapeSelector(question.id)}"][value="${escapeSelector(val)}"]`
3396
+ `input[name="${escapeSelector(question.id)}"][value="${escapeSelector(choiceValue.option)}"]`
2392
3397
  );
2393
3398
  if (input) {
2394
3399
  input.checked = true;
2395
- } else if (val) {
2396
- otherValue = val;
3400
+ if (questionCanClarifyOption(question) && choiceValue.note) {
3401
+ setChoiceNote(question.id, choiceValue.option, choiceValue.note);
3402
+ }
3403
+ } else if (choiceValue.option) {
3404
+ otherValue = choiceValue.option;
2397
3405
  }
2398
3406
  });
2399
3407
  if (otherValue) {
@@ -2429,16 +3437,29 @@
2429
3437
  }
2430
3438
  }
2431
3439
 
3440
+ function rerenderChoiceQuestions() {
3441
+ questions.forEach((question) => {
3442
+ if (question.type !== "single" && question.type !== "multi") return;
3443
+ replaceQuestionOptionList(question, getQuestionValue(question));
3444
+ });
3445
+ }
3446
+
2432
3447
  function loadProgress() {
2433
3448
  if (!session.storageKey) return;
2434
3449
  let loaded = false;
2435
3450
  try {
2436
3451
  const saved = localStorage.getItem(session.storageKey);
2437
3452
  if (saved) {
2438
- populateForm(JSON.parse(saved));
3453
+ const parsed = JSON.parse(saved);
3454
+ const answers = parsed && typeof parsed === "object" && parsed.answers && typeof parsed.answers === "object"
3455
+ ? parsed.answers
3456
+ : parsed;
3457
+ populateForm(answers);
3458
+ restoreSavedOptionInsights(parsed?.savedOptionInsights);
2439
3459
  questions.forEach((q) => {
2440
3460
  if (q.type === "multi") updateDoneState(q.id);
2441
3461
  });
3462
+ rerenderChoiceQuestions();
2442
3463
  loaded = true;
2443
3464
  }
2444
3465
  } catch (_err) {
@@ -2541,6 +3562,12 @@
2541
3562
  updateDoneState(q.id);
2542
3563
  }
2543
3564
  });
3565
+ rerenderChoiceQuestions();
3566
+ }
3567
+
3568
+ function populateFromSavedOptionInsights(savedOptionInsights) {
3569
+ restoreSavedOptionInsights(savedOptionInsights);
3570
+ rerenderChoiceQuestions();
2544
3571
  }
2545
3572
 
2546
3573
  function readFileBase64(file) {
@@ -2608,6 +3635,7 @@
2608
3635
  token: sessionToken,
2609
3636
  responses: payload.responses,
2610
3637
  images: payload.images,
3638
+ savedOptionInsights: serializeSavedOptionInsights(),
2611
3639
  submitted,
2612
3640
  }),
2613
3641
  });
@@ -2713,6 +3741,7 @@
2713
3741
  function init() {
2714
3742
  initTheme();
2715
3743
  clearReloadIntent();
3744
+ normalizeOptionKeysFromData();
2716
3745
 
2717
3746
  const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
2718
3747
  const modKey = document.querySelector(".mod-key");
@@ -2747,6 +3776,9 @@
2747
3776
  // Pre-populate: savedAnswers takes precedence over localStorage
2748
3777
  if (data.savedAnswers && Array.isArray(data.savedAnswers)) {
2749
3778
  populateFromSavedAnswers(data.savedAnswers);
3779
+ if (Array.isArray(data.savedOptionInsights)) {
3780
+ populateFromSavedOptionInsights(data.savedOptionInsights);
3781
+ }
2750
3782
  initStorageKeyOnly();
2751
3783
  } else {
2752
3784
  initStorage();
@@ -2851,8 +3883,6 @@
2851
3883
  }
2852
3884
  }
2853
3885
  }, true);
2854
- document.addEventListener("paste", handlePaste);
2855
-
2856
3886
  if (timeout > 0) {
2857
3887
  startCountdownDisplay();
2858
3888
  timers.expiration = setTimeout(() => {