wikimem 0.8.0 → 0.8.1

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.
Files changed (113) hide show
  1. package/dist/cli/commands/init.d.ts.map +1 -1
  2. package/dist/cli/commands/init.js +97 -8
  3. package/dist/cli/commands/init.js.map +1 -1
  4. package/dist/core/connectors.d.ts +1 -1
  5. package/dist/core/connectors.d.ts.map +1 -1
  6. package/dist/core/git.d.ts +1 -1
  7. package/dist/core/git.d.ts.map +1 -1
  8. package/dist/core/git.js.map +1 -1
  9. package/dist/core/ingest.d.ts.map +1 -1
  10. package/dist/core/ingest.js +74 -3
  11. package/dist/core/ingest.js.map +1 -1
  12. package/dist/core/lint.d.ts.map +1 -1
  13. package/dist/core/lint.js +23 -4
  14. package/dist/core/lint.js.map +1 -1
  15. package/dist/core/oauth-defaults.d.ts +31 -0
  16. package/dist/core/oauth-defaults.d.ts.map +1 -0
  17. package/dist/core/oauth-defaults.js +77 -0
  18. package/dist/core/oauth-defaults.js.map +1 -0
  19. package/dist/core/observer.d.ts +24 -1
  20. package/dist/core/observer.d.ts.map +1 -1
  21. package/dist/core/observer.js +146 -4
  22. package/dist/core/observer.js.map +1 -1
  23. package/dist/core/sync/gdrive.d.ts +14 -0
  24. package/dist/core/sync/gdrive.d.ts.map +1 -0
  25. package/dist/core/sync/gdrive.js +205 -0
  26. package/dist/core/sync/gdrive.js.map +1 -0
  27. package/dist/core/sync/github.d.ts +20 -0
  28. package/dist/core/sync/github.d.ts.map +1 -0
  29. package/dist/core/sync/github.js +206 -0
  30. package/dist/core/sync/github.js.map +1 -0
  31. package/dist/core/sync/gmail.d.ts +15 -0
  32. package/dist/core/sync/gmail.d.ts.map +1 -0
  33. package/dist/core/sync/gmail.js +159 -0
  34. package/dist/core/sync/gmail.js.map +1 -0
  35. package/dist/core/sync/index.d.ts +47 -0
  36. package/dist/core/sync/index.d.ts.map +1 -0
  37. package/dist/core/sync/index.js +100 -0
  38. package/dist/core/sync/index.js.map +1 -0
  39. package/dist/core/sync/jira.d.ts +15 -0
  40. package/dist/core/sync/jira.d.ts.map +1 -0
  41. package/dist/core/sync/jira.js +176 -0
  42. package/dist/core/sync/jira.js.map +1 -0
  43. package/dist/core/sync/linear.d.ts +15 -0
  44. package/dist/core/sync/linear.d.ts.map +1 -0
  45. package/dist/core/sync/linear.js +111 -0
  46. package/dist/core/sync/linear.js.map +1 -0
  47. package/dist/core/sync/notion.d.ts +14 -0
  48. package/dist/core/sync/notion.d.ts.map +1 -0
  49. package/dist/core/sync/notion.js +168 -0
  50. package/dist/core/sync/notion.js.map +1 -0
  51. package/dist/core/sync/rss.d.ts +20 -0
  52. package/dist/core/sync/rss.d.ts.map +1 -0
  53. package/dist/core/sync/rss.js +165 -0
  54. package/dist/core/sync/rss.js.map +1 -0
  55. package/dist/core/sync/scheduler.d.ts +31 -0
  56. package/dist/core/sync/scheduler.d.ts.map +1 -0
  57. package/dist/core/sync/scheduler.js +129 -0
  58. package/dist/core/sync/scheduler.js.map +1 -0
  59. package/dist/core/sync/slack.d.ts +16 -0
  60. package/dist/core/sync/slack.d.ts.map +1 -0
  61. package/dist/core/sync/slack.js +173 -0
  62. package/dist/core/sync/slack.js.map +1 -0
  63. package/dist/core/vault.d.ts +22 -0
  64. package/dist/core/vault.d.ts.map +1 -1
  65. package/dist/core/vault.js +65 -0
  66. package/dist/core/vault.js.map +1 -1
  67. package/dist/core/webhooks.d.ts +13 -0
  68. package/dist/core/webhooks.d.ts.map +1 -0
  69. package/dist/core/webhooks.js +206 -0
  70. package/dist/core/webhooks.js.map +1 -0
  71. package/dist/mcp-server.d.ts +11 -6
  72. package/dist/mcp-server.d.ts.map +1 -1
  73. package/dist/mcp-server.js +99 -6
  74. package/dist/mcp-server.js.map +1 -1
  75. package/dist/mcp-tools-extended.d.ts +15 -0
  76. package/dist/mcp-tools-extended.d.ts.map +1 -0
  77. package/dist/mcp-tools-extended.js +277 -0
  78. package/dist/mcp-tools-extended.js.map +1 -0
  79. package/dist/processors/csv.d.ts +18 -0
  80. package/dist/processors/csv.d.ts.map +1 -0
  81. package/dist/processors/csv.js +230 -0
  82. package/dist/processors/csv.js.map +1 -0
  83. package/dist/processors/image.d.ts.map +1 -1
  84. package/dist/processors/image.js +55 -27
  85. package/dist/processors/image.js.map +1 -1
  86. package/dist/processors/pdf.d.ts.map +1 -1
  87. package/dist/processors/pdf.js +5 -1
  88. package/dist/processors/pdf.js.map +1 -1
  89. package/dist/processors/pptx.d.ts +3 -1
  90. package/dist/processors/pptx.d.ts.map +1 -1
  91. package/dist/processors/pptx.js +236 -95
  92. package/dist/processors/pptx.js.map +1 -1
  93. package/dist/processors/xlsx.d.ts +2 -0
  94. package/dist/processors/xlsx.d.ts.map +1 -1
  95. package/dist/processors/xlsx.js +182 -46
  96. package/dist/processors/xlsx.js.map +1 -1
  97. package/dist/templates/source-types.d.ts +33 -0
  98. package/dist/templates/source-types.d.ts.map +1 -0
  99. package/dist/templates/source-types.js +178 -0
  100. package/dist/templates/source-types.js.map +1 -0
  101. package/dist/web/public/index.html +1785 -103
  102. package/dist/web/server.d.ts.map +1 -1
  103. package/dist/web/server.js +746 -38
  104. package/dist/web/server.js.map +1 -1
  105. package/package.json +4 -1
  106. package/src/web/public/index.html +1785 -103
  107. package/templates/source-types/article.md +21 -0
  108. package/templates/source-types/book.md +21 -0
  109. package/templates/source-types/paper.md +23 -0
  110. package/templates/source-types/podcast.md +21 -0
  111. package/templates/source-types/raw-notes.md +17 -0
  112. package/templates/source-types/tweet-thread.md +19 -0
  113. package/templates/source-types/video.md +21 -0
@@ -368,13 +368,17 @@
368
368
  .tree-chevron.open { transform: rotate(90deg); }
369
369
 
370
370
  .tree-icon {
371
- width: 18px;
372
- min-width: 18px;
373
- text-align: center;
374
- font-size: 11px;
371
+ width: 16px;
372
+ min-width: 16px;
373
+ height: 16px;
374
+ display: flex;
375
+ align-items: center;
376
+ justify-content: center;
375
377
  color: var(--text-muted);
376
- margin-right: 4px;
378
+ margin-right: 5px;
379
+ flex-shrink: 0;
377
380
  }
381
+ .tree-icon svg { width: 14px; height: 14px; }
378
382
  .tree-item.active .tree-icon { color: var(--accent); }
379
383
 
380
384
  .tree-label {
@@ -548,6 +552,9 @@
548
552
  }
549
553
  .tab:hover .tab-close, .tab.active .tab-close { opacity: 1; }
550
554
  .tab-close:hover { background: var(--bg-hover); color: var(--text); }
555
+ .tab-drag-over { background: var(--accent-dim); border-left: 2px solid var(--accent); }
556
+ .tab[draggable] { cursor: grab; }
557
+ .tab[draggable]:active { cursor: grabbing; }
551
558
 
552
559
  #tab-new {
553
560
  width: 28px; height: 28px;
@@ -685,6 +692,62 @@
685
692
  transition: border-color 0.2s;
686
693
  }
687
694
  .stat-card:hover { border-color: var(--border); }
695
+
696
+ /* Health badges */
697
+ .health-badge {
698
+ display: inline-flex; align-items: center; gap: 6px;
699
+ padding: 6px 12px; border-radius: var(--radius-md);
700
+ background: var(--bg-card); border: 1px solid var(--border);
701
+ font-size: 12px; color: var(--text-secondary); transition: border-color 0.15s;
702
+ }
703
+ .health-badge:hover { border-color: var(--accent); }
704
+ .health-badge-icon { font-size: 14px; }
705
+ .health-badge-count { font-weight: 600; color: var(--text-bright); }
706
+ .health-badge-label { color: var(--text-dim); }
707
+ .health-badge.warning { border-color: rgba(215,186,125,0.3); background: rgba(215,186,125,0.06); }
708
+ .health-badge.danger { border-color: rgba(241,76,76,0.3); background: rgba(241,76,76,0.06); }
709
+
710
+ /* Conflict card */
711
+ .conflict-card {
712
+ background: var(--bg-card); border: 1px solid var(--border);
713
+ border-radius: var(--radius-md); padding: 14px; margin-bottom: 10px;
714
+ }
715
+ .conflict-card-header {
716
+ display: flex; justify-content: space-between; align-items: center;
717
+ margin-bottom: 10px;
718
+ }
719
+ .conflict-reason {
720
+ font-size: 11px; color: var(--amber); font-style: italic; margin-bottom: 10px;
721
+ }
722
+ .conflict-sides {
723
+ display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px;
724
+ }
725
+ .conflict-side {
726
+ background: var(--bg-surface); border: 1px solid var(--border-subtle);
727
+ border-radius: var(--radius-sm); padding: 10px; font-size: 12px;
728
+ }
729
+ .conflict-side-title {
730
+ font-weight: 600; color: var(--text-bright); margin-bottom: 6px;
731
+ cursor: pointer;
732
+ }
733
+ .conflict-side-title:hover { color: var(--accent); }
734
+ .conflict-side-summary { color: var(--text-secondary); line-height: 1.5; }
735
+ .conflict-side-snippet {
736
+ margin-top: 6px; font-size: 11px; color: var(--text-dim);
737
+ border-top: 1px solid var(--border-subtle); padding-top: 6px;
738
+ max-height: 80px; overflow: hidden;
739
+ }
740
+ .conflict-actions {
741
+ display: flex; gap: 6px; flex-wrap: wrap;
742
+ }
743
+ .conflict-btn {
744
+ padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border);
745
+ background: var(--bg-surface); color: var(--text-secondary);
746
+ font-size: 11px; cursor: pointer; transition: all 0.15s;
747
+ }
748
+ .conflict-btn:hover { border-color: var(--accent); color: var(--text-bright); }
749
+ .conflict-btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
750
+ .conflict-btn.primary:hover { opacity: 0.9; }
688
751
  .stat-value {
689
752
  font-size: 22px;
690
753
  font-weight: 600;
@@ -769,6 +832,29 @@
769
832
  text-wrap: balance;
770
833
  }
771
834
 
835
+ /* ── Fact Timeline (COMP-MP-002 temporal reasoning) ── */
836
+ #page-fact-timeline { margin: 12px 0 18px; }
837
+ .fact-timeline-toggle { display:flex; align-items:center; gap:6px; cursor:pointer; color:var(--text-muted); font-size:12px; user-select:none; padding:4px 0; }
838
+ .fact-timeline-toggle:hover { color:var(--text-primary); }
839
+ .fact-timeline-toggle .ft-chevron { transition:transform 0.15s; font-size:10px; }
840
+ .fact-timeline-toggle .ft-chevron.open { transform:rotate(90deg); }
841
+ .fact-timeline-body { overflow:hidden; max-height:0; transition:max-height 0.25s ease; }
842
+ .fact-timeline-body.open { max-height:600px; overflow-y:auto; }
843
+ .ft-entries { position:relative; padding-left:20px; margin:8px 0 0; }
844
+ .ft-entries::before { content:''; position:absolute; left:6px; top:0; bottom:0; width:2px; background:var(--border-subtle); border-radius:1px; }
845
+ .ft-entry { position:relative; padding:6px 0 6px 12px; font-size:12px; line-height:1.5; }
846
+ .ft-entry::before { content:''; position:absolute; left:-17px; top:12px; width:8px; height:8px; border-radius:50%; border:2px solid var(--accent); background:var(--bg-surface); }
847
+ .ft-entry.ft-current::before { background:var(--accent); }
848
+ .ft-entry-header { display:flex; align-items:center; gap:8px; }
849
+ .ft-version-badge { background:var(--accent); color:#fff; font-size:10px; padding:1px 6px; border-radius:8px; font-weight:600; }
850
+ .ft-version-badge.ft-old { background:var(--border-subtle); color:var(--text-muted); }
851
+ .ft-timestamp { color:var(--text-muted); font-size:11px; }
852
+ .ft-actor { color:var(--text-muted); font-size:11px; font-style:italic; }
853
+ .ft-source { color:var(--text-muted); font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:300px; }
854
+ .ft-diff-btn { font-size:11px; color:var(--accent); background:none; border:1px solid var(--border-subtle); border-radius:4px; padding:1px 6px; cursor:pointer; margin-left:auto; }
855
+ .ft-diff-btn:hover { border-color:var(--accent); }
856
+ .ft-diff-preview { background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:6px; padding:8px 10px; margin:4px 0; font-size:11px; color:var(--text-muted); max-height:120px; overflow-y:auto; white-space:pre-wrap; word-break:break-word; }
857
+
772
858
  /* ── Metadata Properties Panel (Obsidian-style) ── */
773
859
  #page-meta {
774
860
  border-bottom: 1px solid var(--border-subtle);
@@ -1067,6 +1153,21 @@
1067
1153
  .st-btn code { font-size: 11px; font-family: inherit; }
1068
1154
  .st-divider { width: 1px; height: 18px; background: var(--border-subtle, var(--border)); margin: 0 2px; }
1069
1155
 
1156
+ /* ── @Mention Autocomplete (ENT-002) ── */
1157
+ #mention-popup {
1158
+ position: fixed; z-index: 9998; display: none;
1159
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
1160
+ box-shadow: 0 4px 16px rgba(0,0,0,0.35); max-height: 200px; overflow-y: auto;
1161
+ min-width: 200px; max-width: 320px; padding: 4px;
1162
+ }
1163
+ #mention-popup.visible { display: block; }
1164
+ .mention-item {
1165
+ padding: 6px 12px; font-size: 12px; color: var(--text-secondary); cursor: pointer;
1166
+ border-radius: 5px; transition: background 0.08s; display: flex; align-items: center; gap: 8px;
1167
+ }
1168
+ .mention-item:hover, .mention-item.active { background: var(--bg-hover); color: var(--text-bright); }
1169
+ .mention-item-type { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: var(--accent-dim); color: var(--accent); font-weight: 600; text-transform: uppercase; }
1170
+
1070
1171
  /* ── Entity / concept profile (UX-031) ── */
1071
1172
  #page-entity-profile:empty { display: none; }
1072
1173
  #page-entity-profile:not(:empty) { margin: 0 0 8px; }
@@ -1294,6 +1395,23 @@
1294
1395
  .wiki-source-link:hover { text-decoration: underline; text-underline-offset: 2px; color: var(--wiki-link-hover); }
1295
1396
  .wiki-see-also-list { margin: 0; padding-left: 1.25em; list-style: disc; color: var(--text-secondary); font-size: 14px; }
1296
1397
  .wiki-see-also-list li { margin: 5px 0; }
1398
+ /* Similar Pages section */
1399
+ .similar-pages-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
1400
+ .similar-page-card { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius); cursor: pointer; font-size: 13px; color: var(--text-secondary); transition: border-color 0.15s, background 0.15s; max-width: 280px; }
1401
+ .similar-page-card:hover { border-color: var(--accent); background: var(--accent-dim); color: var(--text-bright); }
1402
+ .similar-page-card .sim-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
1403
+ .similar-page-card .sim-score { font-size: 11px; color: var(--text-muted); flex-shrink: 0; font-variant-numeric: tabular-nums; }
1404
+ .similar-page-card .sim-tags { display: flex; gap: 3px; flex-shrink: 0; }
1405
+ .similar-page-card .sim-tag { font-size: 10px; padding: 1px 5px; border-radius: 3px; background: var(--accent-dim); color: var(--accent); }
1406
+ /* Search filters */
1407
+ .search-filters { display: flex; gap: 6px; padding: 4px 12px 6px; flex-wrap: wrap; align-items: center; border-top: 1px solid var(--border-subtle); }
1408
+ .search-filter-chip { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--bg-card); border: 1px solid var(--border-subtle); color: var(--text-secondary); cursor: pointer; transition: all 0.15s; white-space: nowrap; }
1409
+ .search-filter-chip:hover { border-color: var(--accent); color: var(--text-bright); }
1410
+ .search-filter-chip.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
1411
+ .search-filter-select { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--bg-card); border: 1px solid var(--border-subtle); color: var(--text-secondary); outline: none; }
1412
+ .search-filter-select:focus { border-color: var(--accent); }
1413
+ /* Search snippet highlight */
1414
+ #search-results mark, .search-result mark { background: rgba(79,158,255,0.2); color: var(--text-bright); border-radius: 2px; padding: 0 1px; }
1297
1415
  .page-article-footer {
1298
1416
  margin-top: 32px;
1299
1417
  padding-top: 14px;
@@ -1338,6 +1456,36 @@
1338
1456
  #page-title-row #page-title { flex: 1; min-width: 0; }
1339
1457
  #page-edit-btn { display: none !important; }
1340
1458
 
1459
+ /* ── Confidence Badge ── */
1460
+ .confidence-badge {
1461
+ display: inline-flex; align-items: center; gap: 4px;
1462
+ font-size: 11px; font-weight: 500; padding: 2px 8px;
1463
+ border-radius: 10px; white-space: nowrap; flex-shrink: 0;
1464
+ margin-top: 8px; line-height: 1;
1465
+ }
1466
+ .confidence-badge.high { background: var(--green-dim); color: var(--green); }
1467
+ .confidence-badge.medium { background: rgba(215,186,125,0.12); color: var(--amber); }
1468
+ .confidence-badge.low { background: var(--red-dim); color: var(--red); }
1469
+ .confidence-badge svg { width: 10px; height: 10px; }
1470
+
1471
+ /* ── Validation Controls ── */
1472
+ .validation-bar {
1473
+ display: flex; align-items: center; gap: 6px; margin-top: 4px; flex-wrap: wrap;
1474
+ }
1475
+ .validation-btn {
1476
+ display: inline-flex; align-items: center; gap: 4px;
1477
+ font-size: 11px; padding: 3px 10px; border-radius: 4px;
1478
+ border: 1px solid var(--border-subtle); background: transparent;
1479
+ color: var(--text-dim); cursor: pointer; transition: all 0.12s;
1480
+ }
1481
+ .validation-btn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--border); }
1482
+ .validation-btn.active-verified { background: var(--green-dim); color: var(--green); border-color: var(--green); }
1483
+ .validation-btn.active-outdated { background: rgba(215,186,125,0.12); color: var(--amber); border-color: var(--amber); }
1484
+ .validation-btn.active-wrong { background: var(--red-dim); color: var(--red); border-color: var(--red); }
1485
+ .validation-status-label {
1486
+ font-size: 10px; color: var(--text-muted); margin-left: 4px;
1487
+ }
1488
+
1341
1489
  /* ── WYSIWYG Inline Editor (Obsidian-style) ── */
1342
1490
  #page-editor { display: none; } /* kept for compatibility but not used */
1343
1491
  #page-body[contenteditable="true"] {
@@ -2132,6 +2280,63 @@
2132
2280
  .conn-modal-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; }
2133
2281
  .btn-danger:hover { background:var(--red-dim)!important; color:var(--red)!important; }
2134
2282
 
2283
+ /* ── OAuth Connector Cards (v2 — clean one-click) ── */
2284
+ .oauth-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
2285
+ @media (max-width:600px) { .oauth-grid { grid-template-columns:1fr; } }
2286
+ .oauth-card { background:var(--bg-card); border:1px solid var(--border); border-radius:10px; padding:16px; display:flex; flex-direction:column; gap:12px; transition:border-color 0.2s, box-shadow 0.2s; }
2287
+ .oauth-card:hover { border-color:rgba(79,158,255,0.3); }
2288
+ .oauth-card.oauth-connected { border-color:rgba(78,201,176,0.25); }
2289
+ .oauth-card-top { display:flex; align-items:center; gap:10px; }
2290
+ .oauth-card-icon { width:36px; height:36px; border-radius:8px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
2291
+ .oauth-card-icon.github { background:rgba(255,255,255,0.08); color:#e0e0e0; }
2292
+ .oauth-card-icon.slack { background:rgba(74,21,75,0.15); color:#e0e0e0; }
2293
+ .oauth-card-icon.google { background:rgba(66,133,244,0.1); }
2294
+ .oauth-card-icon.linear { background:rgba(99,91,255,0.1); color:#635BFF; }
2295
+ .oauth-card-icon.jira { background:rgba(0,82,204,0.1); color:#0052CC; }
2296
+ .oauth-card-info { flex:1; min-width:0; }
2297
+ .oauth-card-name { font-size:13px; font-weight:600; color:var(--text-bright); }
2298
+ .oauth-card-desc { font-size:11px; color:var(--text-muted); margin-top:1px; line-height:1.3; }
2299
+ .oauth-card-badge { font-size:10px; padding:2px 8px; border-radius:10px; font-weight:600; flex-shrink:0; letter-spacing:0.2px; }
2300
+ .oauth-card-badge.connected { background:rgba(78,201,176,0.12); color:#4ec9b0; }
2301
+ .oauth-card-badge.ready { background:rgba(79,158,255,0.12); color:#4f9eff; }
2302
+ .oauth-card-actions { display:flex; gap:8px; align-items:center; }
2303
+ .oauth-connect-btn { flex:1; padding:8px 16px; border:none; border-radius:var(--radius-md); font-family:var(--font); font-size:12px; font-weight:600; cursor:pointer; transition:opacity 0.15s, transform 0.1s; text-align:center; }
2304
+ .oauth-connect-btn:hover { opacity:0.9; }
2305
+ .oauth-connect-btn:active { transform:scale(0.98); }
2306
+ .oauth-connect-btn.primary { background:var(--accent); color:var(--bg-deep); }
2307
+ .oauth-connect-btn.secondary { background:var(--bg-surface); color:var(--text); border:1px solid var(--border); }
2308
+ .oauth-connect-btn.danger { background:transparent; color:var(--red); border:1px solid rgba(241,76,76,0.25); font-weight:500; flex:0; padding:8px 12px; }
2309
+ .oauth-connect-btn.danger:hover { background:rgba(241,76,76,0.08); }
2310
+ .oauth-connect-btn:disabled { opacity:0.5; cursor:not-allowed; transform:none; }
2311
+ .oauth-connected-meta { font-size:11px; color:var(--text-muted); }
2312
+ .oauth-info-tip { font-size:11px; color:var(--text-muted); line-height:1.4; padding:8px 10px; background:var(--bg-surface); border-radius:var(--radius); }
2313
+ .oauth-info-tip a { color:var(--accent); text-decoration:none; }
2314
+ .oauth-info-tip a:hover { text-decoration:underline; }
2315
+ /* Device flow UI */
2316
+ .device-flow-box { background:var(--bg-surface); border:1px solid var(--border); border-radius:8px; padding:14px; text-align:center; }
2317
+ .device-flow-code { font-family:var(--font-mono); font-size:22px; font-weight:700; color:var(--text-bright); letter-spacing:3px; padding:8px 0; user-select:all; }
2318
+ .device-flow-steps { font-size:12px; color:var(--text-secondary); line-height:1.6; }
2319
+ .device-flow-steps a { color:var(--accent); text-decoration:none; font-weight:500; }
2320
+ .device-flow-steps a:hover { text-decoration:underline; }
2321
+ .device-flow-status { font-size:11px; color:var(--text-muted); margin-top:8px; display:flex; align-items:center; justify-content:center; gap:6px; }
2322
+ .device-flow-spinner { width:12px; height:12px; border:2px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:df-spin 0.8s linear infinite; display:inline-block; }
2323
+ @keyframes df-spin { to { transform:rotate(360deg); } }
2324
+ /* Advanced setup collapsible */
2325
+ .oauth-advanced-toggle { display:flex; align-items:center; gap:6px; padding:10px 0 6px; cursor:pointer; font-size:12px; color:var(--text-muted); font-weight:500; border:none; background:none; font-family:var(--font); }
2326
+ .oauth-advanced-toggle:hover { color:var(--text-secondary); }
2327
+ .oauth-advanced-toggle svg { transition:transform 0.2s; }
2328
+ .oauth-advanced-toggle.open svg { transform:rotate(90deg); }
2329
+ .oauth-advanced-body { display:none; padding:12px 0; }
2330
+ .oauth-advanced-body.open { display:block; }
2331
+ .oauth-setup-steps { margin:8px 0; padding:0; list-style:none; counter-reset:step; }
2332
+ .oauth-setup-steps li { position:relative; padding:6px 0 6px 28px; font-size:11px; color:var(--text-secondary); line-height:1.5; }
2333
+ .oauth-setup-steps li::before { counter-increment:step; content:counter(step); position:absolute; left:0; top:6px; width:20px; height:20px; border-radius:50%; background:var(--bg); border:1px solid var(--border); display:flex; align-items:center; justify-content:center; font-size:9px; font-weight:600; color:var(--text-muted); }
2334
+ .oauth-setup-steps code { background:var(--bg); border:1px solid var(--border); border-radius:3px; padding:1px 4px; font-size:10px; color:var(--accent); }
2335
+ .oauth-input-row { display:flex; gap:8px; margin-top:8px; }
2336
+ .oauth-input-row .settings-input { flex:1; font-size:12px; }
2337
+ .oauth-actions { display:flex; gap:8px; margin-top:10px; align-items:center; }
2338
+ .oauth-actions .settings-status { font-size:11px; }
2339
+
2135
2340
  .pipe-history h3 { font-size:13px; font-weight:600; color:var(--text-secondary); margin-bottom:12px; text-transform:uppercase; letter-spacing:0.5px; }
2136
2341
  .pipe-run-item { background:var(--bg-card); border:1px solid var(--border); border-radius:10px; padding:12px 16px; margin-bottom:8px; cursor:pointer; transition:border-color .15s, background .15s; }
2137
2342
  .pipe-run-item:hover { border-color:var(--accent); background:var(--bg-hover); }
@@ -2337,6 +2542,21 @@
2337
2542
  .btn-secondary { background:var(--bg-card); border:1px solid var(--border); color:var(--text); padding:7px 16px; border-radius:var(--radius-md); font-size:13px; cursor:pointer; }
2338
2543
  .btn-secondary:hover { background:var(--bg-hover); border-color:var(--accent); }
2339
2544
 
2545
+ /* ── Contradiction compare (Observer / SUP-004) ── */
2546
+ .cx-modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.65); z-index:210; display:flex; align-items:center; justify-content:center; backdrop-filter:blur(2px); }
2547
+ .cx-modal { background:var(--bg); border:1px solid var(--border); border-radius:var(--radius-lg); width:min(960px,96vw); max-height:88vh; display:flex; flex-direction:column; box-shadow:0 16px 48px rgba(0,0,0,0.55); }
2548
+ .cx-modal-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:14px 18px; border-bottom:1px solid var(--border); }
2549
+ .cx-modal-header h3 { font-size:14px; font-weight:600; margin:0; line-height:1.35; }
2550
+ .cx-modal-reason { font-size:12px; color:var(--text-secondary); margin-top:6px; line-height:1.45; }
2551
+ .cx-modal-body { display:flex; flex:1; min-height:0; gap:0; }
2552
+ .cx-col { flex:1; min-width:0; display:flex; flex-direction:column; border-right:1px solid var(--border); }
2553
+ .cx-col:last-child { border-right:none; }
2554
+ .cx-col-head { font-size:10px; font-weight:600; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-dim); padding:8px 12px; background:var(--bg-surface); border-bottom:1px solid var(--border); }
2555
+ .cx-col-body { padding:12px; overflow:auto; flex:1; font-family:var(--font-mono); font-size:11px; line-height:1.5; color:var(--text-secondary); white-space:pre-wrap; word-break:break-word; }
2556
+ .cx-row { display:flex; align-items:flex-start; justify-content:space-between; gap:8px; padding:8px 0; border-bottom:1px solid var(--border-subtle); font-size:12px; }
2557
+ .cx-row:last-child { border-bottom:none; }
2558
+ .cx-row-reason { color:var(--text-muted); flex:1; line-height:1.4; }
2559
+
2340
2560
  /* ── History toolbar additions ── */
2341
2561
  .history-toolbar-right { display:flex; gap:8px; margin-left:auto; }
2342
2562
  .btn-accent-outline { background:transparent; border:1px solid var(--accent); color:var(--accent); padding:5px 14px; border-radius:var(--radius-md); cursor:pointer; font-size:12px; font-weight:500; }
@@ -2492,6 +2712,9 @@
2492
2712
  <button class="sidebar-action-btn" id="sa-new-note" title="New Note">
2493
2713
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
2494
2714
  </button>
2715
+ <button class="sidebar-action-btn" id="sa-new-folder" title="New Folder">
2716
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/></svg>
2717
+ </button>
2495
2718
  <button class="sidebar-action-btn" id="sa-collapse" title="Collapse All">
2496
2719
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
2497
2720
  </button>
@@ -2613,7 +2836,21 @@
2613
2836
  </div>
2614
2837
  <div id="url-result" style="margin-top:8px;font-size:12px;color:var(--text-dim);display:none"></div>
2615
2838
  </div>
2839
+ <!-- Connect Sources (prominent on home page per Chairman Prompt #76) -->
2840
+ <div id="home-connectors" style="margin-bottom:16px">
2841
+ <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);margin-bottom:8px">Connect Sources</div>
2842
+ <div id="home-oauth-cards" style="display:flex;gap:8px;flex-wrap:wrap"></div>
2843
+ </div>
2844
+
2616
2845
  <div id="home-god-nodes" class="recent-list" style="display:none;margin-bottom:16px"></div>
2846
+
2847
+ <!-- Wiki Health -->
2848
+ <div id="home-health" style="display:none;margin-bottom:16px">
2849
+ <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);margin-bottom:8px">Wiki Health</div>
2850
+ <div style="display:flex;gap:8px;flex-wrap:wrap" id="health-badges"></div>
2851
+ <div id="conflicts-panel" style="display:none;margin-top:12px"></div>
2852
+ </div>
2853
+
2617
2854
  <div class="home-section-title">Recent pages</div>
2618
2855
  <div class="recent-list" id="home-recent"></div>
2619
2856
  </div><!-- /home-dashboard -->
@@ -2624,13 +2861,16 @@
2624
2861
  <div id="page-breadcrumbs"></div>
2625
2862
  <div id="page-title-row">
2626
2863
  <h1 id="page-title"></h1>
2864
+ <span id="page-confidence-badge" class="confidence-badge" style="display:none"></span>
2627
2865
  <button id="page-edit-btn" onclick="toggleEditMode()" title="Edit page (Ctrl+E)">
2628
2866
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
2629
2867
  </button>
2630
2868
  </div>
2869
+ <div id="page-validation-bar" class="validation-bar" style="display:none"></div>
2631
2870
  <div id="page-meta"></div>
2632
2871
  <div id="page-entity-profile" class="page-entity-profile"></div>
2633
2872
  <div id="page-toc"></div>
2873
+ <div id="page-fact-timeline" style="display:none"></div>
2634
2874
  <div id="page-body" class="md"></div>
2635
2875
  <div id="page-encyclopedia-chrome"></div>
2636
2876
  <div id="wysiwyg-bar">
@@ -2697,8 +2937,8 @@
2697
2937
  <div class="graph-settings-section">
2698
2938
  <span class="graph-settings-label">Labels</span>
2699
2939
  <div class="graph-settings-radios">
2700
- <label><input type="radio" name="graph-labels" value="hubs" checked /> Hubs only</label>
2701
- <label><input type="radio" name="graph-labels" value="all" /> All</label>
2940
+ <label><input type="radio" name="graph-labels" value="hubs" /> Hubs only</label>
2941
+ <label><input type="radio" name="graph-labels" value="all" checked /> All</label>
2702
2942
  <label><input type="radio" name="graph-labels" value="none" /> None</label>
2703
2943
  </div>
2704
2944
  </div>
@@ -2769,7 +3009,7 @@
2769
3009
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
2770
3010
  </button>
2771
3011
  </div>
2772
- <div id="voice-recording-status" style="display:none;margin-top:6px;font-size:11px;color:var(--red);display:flex;align-items:center;gap:6px">
3012
+ <div id="voice-recording-status" style="display:none;margin-top:6px;font-size:11px;color:var(--red);align-items:center;gap:6px">
2773
3013
  <span style="width:6px;height:6px;background:var(--red);border-radius:50%;display:inline-block;animation:voicePulse 1.2s infinite"></span>
2774
3014
  Recording… <span id="voice-timer">0:00</span>
2775
3015
  </div>
@@ -3009,6 +3249,29 @@
3009
3249
  </div>
3010
3250
  </div>
3011
3251
 
3252
+ <!-- Contradiction compare (Observer heuristic pairs) -->
3253
+ <div id="cx-modal-overlay" class="cx-modal-overlay" style="display:none">
3254
+ <div class="cx-modal" role="dialog" aria-modal="true" aria-labelledby="cx-modal-title">
3255
+ <div class="cx-modal-header">
3256
+ <div>
3257
+ <h3 id="cx-modal-title">Compare pages</h3>
3258
+ <div id="cx-modal-reason" class="cx-modal-reason"></div>
3259
+ </div>
3260
+ <button type="button" class="diff-modal-close" id="cx-modal-close" title="Close">&times;</button>
3261
+ </div>
3262
+ <div class="cx-modal-body">
3263
+ <div class="cx-col">
3264
+ <div class="cx-col-head" id="cx-head-a">Page A</div>
3265
+ <div class="cx-col-body" id="cx-body-a"></div>
3266
+ </div>
3267
+ <div class="cx-col">
3268
+ <div class="cx-col-head" id="cx-head-b">Page B</div>
3269
+ <div class="cx-col-body" id="cx-body-b"></div>
3270
+ </div>
3271
+ </div>
3272
+ </div>
3273
+ </div>
3274
+
3012
3275
  <!-- Command Palette (Cmd+P) -->
3013
3276
  <div id="palette-overlay">
3014
3277
  <div id="palette-box">
@@ -3036,6 +3299,9 @@
3036
3299
 
3037
3300
  <input type="file" id="file-input" multiple accept=".md,.txt,.pdf,.docx,.xlsx,.pptx,.csv,.json,.yaml,.yml,.html,.htm" />
3038
3301
 
3302
+ <!-- @Mention Autocomplete Popup -->
3303
+ <div id="mention-popup"></div>
3304
+
3039
3305
  <!-- Floating Selection Toolbar (Notion-style) -->
3040
3306
  <div id="selection-toolbar" class="selection-toolbar">
3041
3307
  <button class="st-btn" data-cmd="bold" title="Bold (⌘B)"><b>B</b></button>
@@ -3067,9 +3333,9 @@
3067
3333
  editTabType: null,
3068
3334
  };
3069
3335
 
3070
- function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
3336
+ function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
3071
3337
  function formatBytes(b) { if(b<1024) return b+' B'; if(b<1048576) return (b/1024).toFixed(1)+' KB'; return (b/1048576).toFixed(1)+' MB'; }
3072
- async function api(path) { const r=await fetch(path); return r.json(); }
3338
+ async function api(path) { const r=await fetch(path); if(!r.ok) throw new Error(`HTTP ${r.status} — ${path}`); return r.json(); }
3073
3339
 
3074
3340
  const BOOKMARKS_STORAGE_KEY = 'wikimem-bookmarks';
3075
3341
  function getBookmarks() {
@@ -3120,16 +3386,35 @@
3120
3386
  return 'cat-page';
3121
3387
  }
3122
3388
 
3389
+ const SVG_ICONS = {
3390
+ folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
3391
+ markdown: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>',
3392
+ pdf: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M10 12h1.5a1.5 1.5 0 0 1 0 3H10v-3z"/><path d="M7 18v-6h1a2 2 0 0 1 0 4H7"/></svg>',
3393
+ image: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
3394
+ video: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><path d="M10 8l6 4-6 4V8z"/></svg>',
3395
+ audio: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>',
3396
+ spreadsheet: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="12" y1="9" x2="12" y2="21"/></svg>',
3397
+ code: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
3398
+ text: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
3399
+ wiki: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>',
3400
+ file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
3401
+ };
3123
3402
  function fileIcon(type, name) {
3124
- if(type==='dir') return '📁';
3403
+ if(type==='dir') return SVG_ICONS.folder;
3125
3404
  if(type==='raw') {
3126
3405
  const ext = (name||'').split('.').pop().toLowerCase();
3127
- if(['pdf'].includes(ext)) return '📄';
3128
- if(['jpg','jpeg','png','gif','webp','svg'].includes(ext)) return '🖼';
3129
- if(['mp3','wav','m4a'].includes(ext)) return '🎵';
3130
- return '📎';
3406
+ if(['pdf'].includes(ext)) return SVG_ICONS.pdf;
3407
+ if(['jpg','jpeg','png','gif','webp','svg','ico'].includes(ext)) return SVG_ICONS.image;
3408
+ if(['mp4','webm','mov','avi','mkv'].includes(ext)) return SVG_ICONS.video;
3409
+ if(['mp3','wav','m4a','ogg','flac','aac','webm'].includes(ext)) return SVG_ICONS.audio;
3410
+ if(['csv','xlsx','xls','tsv'].includes(ext)) return SVG_ICONS.spreadsheet;
3411
+ if(['js','ts','jsx','tsx','py','go','rs','c','cpp','java','rb','sh','bash','zsh'].includes(ext)) return SVG_ICONS.code;
3412
+ if(['md','markdown'].includes(ext)) return SVG_ICONS.markdown;
3413
+ if(['txt','log','ini','cfg','conf'].includes(ext)) return SVG_ICONS.text;
3414
+ if(['json','yaml','yml','xml','toml','html','htm','css'].includes(ext)) return SVG_ICONS.code;
3415
+ return SVG_ICONS.file;
3131
3416
  }
3132
- return '◇';
3417
+ return SVG_ICONS.wiki;
3133
3418
  }
3134
3419
 
3135
3420
  const chevronSvg = '<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>';
@@ -3185,16 +3470,44 @@
3185
3470
  });
3186
3471
  }
3187
3472
 
3473
+ /** Add Wikipedia-style [1][2] citation markers linking to sources (TREND-001) */
3474
+ function addSourceCitations(container, page) {
3475
+ const fm = page?.frontmatter ?? {};
3476
+ const sources = Array.isArray(fm.sources) ? fm.sources : [];
3477
+ if (sources.length === 0) return;
3478
+
3479
+ // Build references section
3480
+ const refsHtml = sources.map((src, i) => {
3481
+ const label = String(src).replace(/^raw\//, '').replace(/^.*\//, '');
3482
+ return `<li id="cite-${i+1}"><a href="#" class="wiki-source-link" onclick="event.preventDefault();openRawFile('${esc(String(src))}')">${esc(label)}</a></li>`;
3483
+ }).join('');
3484
+
3485
+ // Add citation superscripts after the first paragraph
3486
+ const firstP = container.querySelector('p');
3487
+ if (firstP && sources.length > 0) {
3488
+ const citeLinks = sources.map((_, i) =>
3489
+ `<a href="#cite-${i+1}" class="citation-ref" title="Source ${i+1}"><sup>[${i+1}]</sup></a>`
3490
+ ).join('');
3491
+ firstP.insertAdjacentHTML('beforeend', ' ' + citeLinks);
3492
+ }
3493
+
3494
+ // Append references section after content
3495
+ const refsSection = document.createElement('section');
3496
+ refsSection.className = 'page-references-section';
3497
+ refsSection.innerHTML = `<h2 class="page-encyclopedia-heading">References</h2><ol class="page-references-list">${refsHtml}</ol>`;
3498
+ container.appendChild(refsSection);
3499
+ }
3500
+
3188
3501
  // ── Views ──
3189
3502
  function showView(mode) {
3190
3503
  state.viewMode = mode;
3191
- document.getElementById('home-view').classList.toggle('active', mode==='home');
3192
- document.getElementById('page-view').classList.toggle('active', mode==='page');
3193
- document.getElementById('graph-view').classList.toggle('active', mode==='graph');
3194
- document.getElementById('settings-view').classList.toggle('active', mode==='settings');
3195
- document.getElementById('history-view').classList.toggle('active', mode==='history');
3196
- document.getElementById('pipeline-view').classList.toggle('active', mode==='pipeline');
3197
- document.getElementById('timelapse-view').classList.toggle('active', mode==='timelapse');
3504
+ document.getElementById('home-view')?.classList.toggle('active', mode==='home');
3505
+ document.getElementById('page-view')?.classList.toggle('active', mode==='page');
3506
+ document.getElementById('graph-view')?.classList.toggle('active', mode==='graph');
3507
+ document.getElementById('settings-view')?.classList.toggle('active', mode==='settings');
3508
+ document.getElementById('history-view')?.classList.toggle('active', mode==='history');
3509
+ document.getElementById('pipeline-view')?.classList.toggle('active', mode==='pipeline');
3510
+ document.getElementById('timelapse-view')?.classList.toggle('active', mode==='timelapse');
3198
3511
 
3199
3512
  document.getElementById('rail-files').classList.toggle('active', mode==='home'||mode==='page');
3200
3513
  document.getElementById('rail-graph').classList.toggle('active', mode==='graph');
@@ -3400,11 +3713,17 @@
3400
3713
 
3401
3714
  function renderTabs() {
3402
3715
  const bar = document.getElementById('tab-bar');
3403
- const tabsHtml = state.tabs.map(t => {
3716
+ const tabsHtml = state.tabs.map((t, idx) => {
3404
3717
  const active = t.id === state.activeTabId ? ' active' : '';
3405
3718
  const icon = tabIcon(t);
3406
3719
  const dirtyDot = t.dirty ? '<span class="tab-dirty-dot"></span>' : '';
3407
- return `<div class="tab${active}" onclick="activateTab('${esc(t.id)}')">
3720
+ return `<div class="tab${active}" data-tab-idx="${idx}" draggable="true" onclick="activateTab('${esc(t.id)}')"
3721
+ ondragstart="event.dataTransfer.setData('tab-idx','${idx}');event.dataTransfer.effectAllowed='move';this.style.opacity='0.4'"
3722
+ ondragend="this.style.opacity='1'"
3723
+ ondragover="event.preventDefault();this.classList.add('tab-drag-over')"
3724
+ ondragleave="this.classList.remove('tab-drag-over')"
3725
+ ondrop="event.preventDefault();this.classList.remove('tab-drag-over');reorderTab(+event.dataTransfer.getData('tab-idx'),${idx})"
3726
+ oncontextmenu="event.preventDefault();showTabContextMenu(event,'${esc(t.id)}',${idx})">
3408
3727
  <span class="tab-icon">${icon}</span>
3409
3728
  <span class="tab-label">${esc(t.title)}</span>${dirtyDot}
3410
3729
  <button class="tab-close" onclick="event.stopPropagation();closeTab('${esc(t.id)}')">&times;</button>
@@ -3417,6 +3736,44 @@
3417
3736
  `<div id="tab-bar-right"><div class="status-dot"></div><span id="status-text">${document.getElementById('status-text')?.textContent || 'Loading...'}</span></div>`;
3418
3737
  }
3419
3738
 
3739
+ function reorderTab(fromIdx, toIdx) {
3740
+ if (fromIdx === toIdx) return;
3741
+ const [moved] = state.tabs.splice(fromIdx, 1);
3742
+ state.tabs.splice(toIdx, 0, moved);
3743
+ renderTabs();
3744
+ }
3745
+
3746
+ function showTabContextMenu(e, tabId, idx) {
3747
+ let menu = document.getElementById('tab-ctx-menu');
3748
+ if (!menu) {
3749
+ menu = document.createElement('div');
3750
+ menu.id = 'tab-ctx-menu';
3751
+ menu.className = 'ctx-menu';
3752
+ document.body.appendChild(menu);
3753
+ }
3754
+ menu.innerHTML = '';
3755
+ const items = [
3756
+ { label: 'Close Tab', action: () => closeTab(tabId) },
3757
+ { label: 'Close Other Tabs', action: () => { state.tabs = state.tabs.filter(t => t.id === tabId); state.activeTabId = tabId; renderTabs(); } },
3758
+ { label: 'Close Tabs to the Right', action: () => { state.tabs = state.tabs.slice(0, idx + 1); if (!state.tabs.find(t => t.id === state.activeTabId)) { activateTab(tabId); } renderTabs(); } },
3759
+ { label: 'Duplicate Tab', action: () => { const t = state.tabs[idx]; openTab(t.id + '-dup-' + Date.now(), t.title, t.type); } },
3760
+ ];
3761
+ items.forEach(item => {
3762
+ const el = document.createElement('div');
3763
+ el.className = 'ctx-menu-item';
3764
+ el.textContent = item.label;
3765
+ el.addEventListener('click', () => { menu.style.display = 'none'; item.action(); });
3766
+ menu.appendChild(el);
3767
+ });
3768
+ menu.style.display = 'block';
3769
+ menu.style.left = e.clientX + 'px';
3770
+ menu.style.top = e.clientY + 'px';
3771
+ setTimeout(() => {
3772
+ const close = (ev) => { if (!menu.contains(ev.target)) { menu.style.display = 'none'; document.removeEventListener('click', close); } };
3773
+ document.addEventListener('click', close);
3774
+ }, 0);
3775
+ }
3776
+
3420
3777
  function estimateReadingMinutes(wordCount) {
3421
3778
  const n = Number(wordCount) || 0;
3422
3779
  return Math.max(1, Math.round(n / 200));
@@ -3453,6 +3810,9 @@
3453
3810
  parts.push(`<section class="page-encyclopedia-section page-see-also-block" aria-labelledby="wiki-see-also-heading"><h2 id="wiki-see-also-heading" class="page-encyclopedia-heading">See also</h2><ul class="wiki-see-also-list">${items}</ul></section>`);
3454
3811
  }
3455
3812
 
3813
+ // Similar Pages placeholder (loaded async)
3814
+ parts.push(`<section class="page-encyclopedia-section page-similar-block" id="similar-pages-section" style="display:none" aria-labelledby="wiki-similar-heading"><h2 id="wiki-similar-heading" class="page-encyclopedia-heading">Similar pages</h2><div id="similar-pages-list" class="similar-pages-grid"></div></section>`);
3815
+
3456
3816
  const updated = fm.updated || fm.created;
3457
3817
  if (updated) {
3458
3818
  parts.push(`<footer class="page-article-footer"><div class="page-article-footer-inner"><span class="page-last-updated">Last updated: ${esc(String(updated))}</span></div></footer>`);
@@ -3592,6 +3952,11 @@
3592
3952
  document.getElementById('page-breadcrumbs').innerHTML = breadcrumbs;
3593
3953
 
3594
3954
  const fm = page.frontmatter || {};
3955
+
3956
+ // Confidence badge + validation bar
3957
+ renderConfidenceBadge(fm);
3958
+ renderValidationBar(page.title, fm);
3959
+
3595
3960
  const tags = (fm.tags || []).map(t =>
3596
3961
  `<span class="meta-tag-chip">${esc(t)}<button class="chip-remove" onclick="event.stopPropagation();removePageTag('${esc(page.title)}','${esc(t)}')" title="Remove tag">&times;</button></span>`
3597
3962
  ).join('');
@@ -3665,10 +4030,14 @@
3665
4030
  body.innerHTML = rendered;
3666
4031
  body.classList.add('fade-in');
3667
4032
  addCopyButtons(body);
4033
+ addSourceCitations(body, page);
3668
4034
  setTimeout(() => body.classList.remove('fade-in'), 250);
3669
4035
 
3670
4036
  buildToc(body);
3671
4037
 
4038
+ // Fetch and render fact timeline (COMP-MP-002)
4039
+ renderFactTimeline(page.title, fm);
4040
+
3672
4041
  renderEncyclopediaChrome(page);
3673
4042
 
3674
4043
  const blContainer = document.getElementById('page-backlinks');
@@ -3684,6 +4053,9 @@
3684
4053
  highlightActiveTreeItem(title);
3685
4054
  document.getElementById('sb-path').textContent = relPath || title;
3686
4055
  document.getElementById('content').scrollTop = 0;
4056
+
4057
+ // Async: load similar pages
4058
+ loadSimilarPages(page.title);
3687
4059
  } catch(err) {
3688
4060
  console.error('Failed to open page:', err);
3689
4061
  }
@@ -3721,6 +4093,8 @@
3721
4093
  showView('page');
3722
4094
 
3723
4095
  document.getElementById('page-edit-btn').style.display = 'none';
4096
+ document.getElementById('page-confidence-badge').style.display = 'none';
4097
+ document.getElementById('page-validation-bar').style.display = 'none';
3724
4098
  document.getElementById('page-title').textContent = filename;
3725
4099
  const rawParts = filepath.split('/').filter(Boolean);
3726
4100
  let rawBc = `<span onclick="showView('home')" style="cursor:pointer">home</span><span class="breadcrumb-sep">›</span><span>raw</span>`;
@@ -3882,8 +4256,8 @@
3882
4256
  });
3883
4257
  }
3884
4258
 
3885
- // Click on page body → activate WYSIWYG edit
3886
- document.getElementById('page-body').addEventListener('click', (e) => {
4259
+ // Double-click on page body → activate WYSIWYG edit (single-click reserved for link navigation)
4260
+ document.getElementById('page-body').addEventListener('dblclick', (e) => {
3887
4261
  if (state.editing || state.editTabType !== 'wiki') return;
3888
4262
  if (e.target.closest('a[href]')) return; // let normal links work
3889
4263
  activateWysiwyg();
@@ -3897,10 +4271,16 @@
3897
4271
  let rawMd = '';
3898
4272
  let pageSlug = title;
3899
4273
  try {
3900
- const data = await fetch('/api/pages/' + encodeURIComponent(title) + '/raw').then(r => r.json());
4274
+ const r = await fetch('/api/pages/' + encodeURIComponent(title) + '/raw');
4275
+ if (!r.ok) throw new Error('HTTP ' + r.status);
4276
+ const data = await r.json();
3901
4277
  rawMd = data.raw || '';
3902
4278
  pageSlug = data.slug || title;
3903
- } catch {}
4279
+ } catch (err) {
4280
+ console.warn('Could not load page source for editing:', err);
4281
+ if (typeof showToast === 'function') showToast('Could not load source — edit cancelled');
4282
+ return;
4283
+ }
3904
4284
 
3905
4285
  state.editing = true;
3906
4286
  state.editOriginal = rawMd;
@@ -4149,8 +4529,12 @@
4149
4529
 
4150
4530
  for (const item of items) {
4151
4531
  if (item.sep) { menu.innerHTML += '<div class="ctx-menu-sep"></div>'; continue; }
4152
- const cls = item.danger ? ' style="color:var(--red)"' : '';
4153
- menu.innerHTML += `<div class="ctx-menu-item"${cls} onclick="this.parentElement.remove();(${item.action.toString()})()">${item.icon || ''} ${item.label}</div>`;
4532
+ const el = document.createElement('div');
4533
+ el.className = 'ctx-menu-item';
4534
+ if (item.danger) el.style.color = 'var(--red)';
4535
+ el.innerHTML = `${item.icon || ''} ${item.label}`;
4536
+ el.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); item.action(); });
4537
+ menu.appendChild(el);
4154
4538
  }
4155
4539
 
4156
4540
  document.body.appendChild(menu);
@@ -4163,6 +4547,9 @@
4163
4547
  document.getElementById('tree-ctx-menu')?.remove();
4164
4548
  }
4165
4549
  document.addEventListener('click', closeTreeContextMenu);
4550
+ document.addEventListener('contextmenu', (e) => {
4551
+ if (e.target.closest?.('.tree-item')) { e.preventDefault(); }
4552
+ });
4166
4553
 
4167
4554
  async function renameFile(title, path, type) {
4168
4555
  const newName = prompt('New name:', title);
@@ -4399,6 +4786,62 @@
4399
4786
  } catch(e) { el.textContent = originalTitle; showToast('Failed to rename'); }
4400
4787
  }
4401
4788
 
4789
+ // ── Confidence & Validation System ──
4790
+ function renderConfidenceBadge(fm) {
4791
+ const el = document.getElementById('page-confidence-badge');
4792
+ if (!el) return;
4793
+ const val = typeof fm.confidence === 'number' ? fm.confidence : null;
4794
+ if (val === null) { el.style.display = 'none'; return; }
4795
+ const tier = val > 80 ? 'high' : val >= 50 ? 'medium' : 'low';
4796
+ const icons = {
4797
+ high: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
4798
+ medium: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>',
4799
+ low: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
4800
+ };
4801
+ el.className = `confidence-badge ${tier}`;
4802
+ el.innerHTML = `${icons[tier]} ${val}%`;
4803
+ el.title = `Confidence: ${val}% (${tier})`;
4804
+ el.style.display = '';
4805
+ }
4806
+
4807
+ function renderValidationBar(title, fm) {
4808
+ const el = document.getElementById('page-validation-bar');
4809
+ if (!el) return;
4810
+ const vs = fm.validation_status || 'unreviewed';
4811
+ const validatedAt = fm.validated_at ? ` · ${fm.validated_at}` : '';
4812
+ el.innerHTML = `
4813
+ <button class="validation-btn${vs === 'verified' ? ' active-verified' : ''}" onclick="setPageValidation('${esc(title)}','verified')">
4814
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg> Verified
4815
+ </button>
4816
+ <button class="validation-btn${vs === 'outdated' ? ' active-outdated' : ''}" onclick="setPageValidation('${esc(title)}','outdated')">
4817
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> Outdated
4818
+ </button>
4819
+ <button class="validation-btn${vs === 'wrong' ? ' active-wrong' : ''}" onclick="setPageValidation('${esc(title)}','wrong')">
4820
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg> Wrong
4821
+ </button>
4822
+ ${vs !== 'unreviewed' ? `<span class="validation-status-label">${vs}${validatedAt}</span>` : ''}`;
4823
+ el.style.display = 'flex';
4824
+ }
4825
+
4826
+ async function setPageValidation(title, status) {
4827
+ try {
4828
+ const res = await fetch('/api/pages/' + encodeURIComponent(title) + '/validate', {
4829
+ method: 'PATCH',
4830
+ headers: { 'Content-Type': 'application/json' },
4831
+ body: JSON.stringify({ validation_status: status }),
4832
+ });
4833
+ const data = await res.json();
4834
+ if (data.error) { showToast('Validation failed: ' + data.error); return; }
4835
+ showToast(`✓ Marked as ${status}`);
4836
+ // Re-render with updated frontmatter
4837
+ const fm = data.page?.frontmatter || {};
4838
+ renderConfidenceBadge(fm);
4839
+ renderValidationBar(title, fm);
4840
+ } catch (err) {
4841
+ showToast('Validation failed');
4842
+ }
4843
+ }
4844
+
4402
4845
  async function removePageTag(title, tag) {
4403
4846
  try {
4404
4847
  const { raw, slug } = await fetchPageRaw(title);
@@ -4771,11 +5214,25 @@
4771
5214
  let auditActorFilter = 'all';
4772
5215
 
4773
5216
  function inferActor(entry) {
4774
- const msg = (entry.message || '').toLowerCase();
5217
+ const msg = (entry.message || '');
5218
+ const msgLower = msg.toLowerCase();
4775
5219
  const author = (entry.author || '').toLowerCase();
4776
- if(msg.startsWith('observer:') || msg.includes('[observer]') || author.includes('observer')) return 'observer';
4777
- if(msg.startsWith('webhook:') || msg.includes('[webhook]') || author.includes('webhook')) return 'webhook';
4778
- if(msg.startsWith('agent:') || msg.startsWith('auto:') || msg.includes('[agent]') || author.includes('bot') || author.includes('agent')) return 'agent';
5220
+
5221
+ // Check conventional commit format: wiki: feat(observe), wiki: feat(ingest), wiki: feat(scrape)
5222
+ if(/^wiki:\s*feat\(observe\)/i.test(msg)) return 'observer';
5223
+ if(/^wiki:\s*feat\(ingest\)/i.test(msg) || /^wiki:\s*feat\(scrape\)/i.test(msg)) return 'agent';
5224
+
5225
+ // Check explicit prefixes and tags
5226
+ if(msgLower.startsWith('observer:') || msgLower.includes('[observer]') || author.includes('observer')) return 'observer';
5227
+ if(msgLower.startsWith('webhook:') || msgLower.includes('[webhook]') || author.includes('webhook')) return 'webhook';
5228
+ if(msgLower.startsWith('agent:') || msgLower.startsWith('auto:') || msgLower.includes('[agent]') || author.includes('bot') || author.includes('agent')) return 'agent';
5229
+
5230
+ // wiki: feat(manual) or plain wiki: commits from the CLI are human
5231
+ if(/^wiki:\s*feat\(manual\)/i.test(msg)) return 'human';
5232
+
5233
+ // Any other wiki: commit without a known scope — check author for hints
5234
+ if(/^wiki:/i.test(msg) && (author.includes('wikimem') || author === 'wikimem-cli')) return 'agent';
5235
+
4779
5236
  return 'human';
4780
5237
  }
4781
5238
 
@@ -4818,8 +5275,18 @@
4818
5275
 
4819
5276
  checkMigrationBanner();
4820
5277
 
5278
+ // Show loading state immediately
5279
+ timeline.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted)"><div class="spinner" style="margin:0 auto 8px"></div>Loading audit trail…</div>';
5280
+
4821
5281
  try {
4822
- const status = await api('/api/git/status');
5282
+ const wikiOnly = wikiOnlyEl?.checked ?? true;
5283
+ const search = searchEl?.value?.trim() || '';
5284
+ let url = `/api/git/log?limit=50&wikiOnly=${wikiOnly}`;
5285
+ if(search) url += `&search=${encodeURIComponent(search)}`;
5286
+
5287
+ // Fetch status and log in parallel
5288
+ const [status, log] = await Promise.all([api('/api/git/status'), api(url)]);
5289
+
4823
5290
  if(!status.initialized) {
4824
5291
  branchInfo.textContent = 'Not a git repository';
4825
5292
  initBtn.style.display = 'inline-block';
@@ -4830,12 +5297,6 @@
4830
5297
  initBtn.style.display = 'none';
4831
5298
  branchInfo.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg> ${esc(status.branch)}`;
4832
5299
 
4833
- const wikiOnly = wikiOnlyEl?.checked ?? true;
4834
- const search = searchEl?.value?.trim() || '';
4835
- let url = `/api/git/log?limit=50&wikiOnly=${wikiOnly}`;
4836
- if(search) url += `&search=${encodeURIComponent(search)}`;
4837
- const log = await api(url);
4838
-
4839
5300
  if(!log.length) {
4840
5301
  const msg = wikiOnly
4841
5302
  ? 'No wiki commits yet. Ingest content to create wiki commits.'
@@ -5258,19 +5719,21 @@
5258
5719
  return community;
5259
5720
  }
5260
5721
 
5261
- // ── Compute in-degree for god nodes ────────────────────────────────
5722
+ // ── Compute in-degree for hub nodes ─────────────────────────────────
5262
5723
  const inDegree = new Map(data.nodes.map(n => [n.id, 0]));
5263
5724
  for(const l of data.links) {
5264
5725
  const t = typeof l.target==='object' ? l.target.id : l.target;
5265
5726
  inDegree.set(t, (inDegree.get(t)||0)+1);
5266
5727
  }
5267
- const maxDeg = Math.max(1, ...[...inDegree.values()]);
5268
- const isGodNode = id => (inDegree.get(id)||0) >= Math.max(3, maxDeg * 0.4);
5728
+ const totalNodes = data.nodes.length;
5729
+ const isGodNode = id => (inDegree.get(id)||0) >= Math.max(5, Math.ceil(totalNodes * 0.06));
5730
+ // Node radius: matches timelapse formula (based on inbound link count)
5731
+ const nR = id => { const li = inDegree.get(id)||0; return Math.max(4, Math.min(18, 4 + Math.sqrt(li) * 3)); };
5269
5732
 
5270
5733
  const communityMap = detectCommunities(data.nodes, data.links);
5271
5734
  const communityIds = [...new Set(communityMap.values())];
5272
- const communityColors = ['#4f9eff','#4ec9b0','#d7ba7d','#c586c0','#9cdcfe','#dcdcaa','#ce9178','#608b4e','#b5cea8'];
5273
- const communityColor = id => communityColors[communityIds.indexOf(communityMap.get(id)||id) % communityColors.length];
5735
+ const GRAPH_COLORS = ['#4f9eff','#4ec9b0','#d7ba7d','#c586c0','#9cdcfe','#dcdcaa','#ce9178','#608b4e','#b5cea8'];
5736
+ const communityColor = id => GRAPH_COLORS[communityIds.indexOf(communityMap.get(id)||id) % GRAPH_COLORS.length];
5274
5737
 
5275
5738
  const svg = d3.select('#graph-svg');
5276
5739
  const tooltip = document.getElementById('tooltip');
@@ -5308,17 +5771,17 @@
5308
5771
  const link = g.append('g').selectAll('line').data(data.links).join('line')
5309
5772
  .attr('stroke',cs('--border-subtle')).attr('stroke-width',0.8).attr('stroke-opacity',0.4);
5310
5773
 
5311
- // God node outer ring
5774
+ // Hub node outer ring (gold, matching timelapse)
5312
5775
  g.append('g').selectAll('circle.god-ring').data(data.nodes.filter(d=>isGodNode(d.id))).join('circle')
5313
5776
  .attr('class','god-ring')
5314
- .attr('r', d => Math.max(7,Math.min(22,Math.sqrt(d.wordCount/50)))+5)
5315
- .attr('fill','none').attr('stroke',cs('--accent')).attr('stroke-width',1.5)
5316
- .attr('opacity',0.35);
5777
+ .attr('r', d => nR(d.id)+5)
5778
+ .attr('fill','none').attr('stroke','#d4a843').attr('stroke-width',1.5)
5779
+ .attr('opacity',0.5);
5317
5780
 
5318
5781
  const node = g.append('g').selectAll('circle').data(data.nodes).join('circle')
5319
- .attr('r', d => isGodNode(d.id) ? Math.max(7,Math.min(22,Math.sqrt(d.wordCount/50))) : Math.max(4,Math.min(16,Math.sqrt(d.wordCount/70))))
5782
+ .attr('r', d => nR(d.id))
5320
5783
  .attr('fill', d => communityColor(d.id))
5321
- .attr('stroke', d => isGodNode(d.id) ? cs('--accent') : cs('--bg'))
5784
+ .attr('stroke', d => isGodNode(d.id) ? '#d4a843' : cs('--bg'))
5322
5785
  .attr('stroke-width', d => isGodNode(d.id) ? 2 : 1.5)
5323
5786
  .attr('cursor','pointer')
5324
5787
  .call(d3.drag()
@@ -5391,6 +5854,70 @@
5391
5854
  .attr('cy', d => { const n = data.nodes.find(n=>n.id===d.id); return n?.y||0; });
5392
5855
  label.attr('x',d=>d.x).attr('y',d=>d.y);
5393
5856
  });
5857
+
5858
+ // ── Graph settings panel toggle ──
5859
+ const panelToggle = document.getElementById('graph-panel-toggle');
5860
+ const settingsPanel = document.getElementById('graph-settings-panel');
5861
+ if(panelToggle && settingsPanel) {
5862
+ panelToggle.addEventListener('click', () => {
5863
+ const open = settingsPanel.classList.toggle('collapsed');
5864
+ panelToggle.setAttribute('aria-expanded', String(!settingsPanel.classList.contains('collapsed')));
5865
+ settingsPanel.setAttribute('aria-hidden', String(settingsPanel.classList.contains('collapsed')));
5866
+ });
5867
+ }
5868
+
5869
+ // ── Graph labels radio handler ──
5870
+ function applyLabelMode(mode) {
5871
+ if(mode === 'all') label.attr('display', 'block');
5872
+ else if(mode === 'none') label.attr('display', 'none');
5873
+ else label.attr('display', d => isGodNode(d.id) ? 'block' : 'none');
5874
+ }
5875
+ document.querySelectorAll('input[name="graph-labels"]').forEach(r => {
5876
+ r.addEventListener('change', e => applyLabelMode(e.target.value));
5877
+ });
5878
+
5879
+ // ── Graph force strength slider ──
5880
+ const forceRange = document.getElementById('graph-force-range');
5881
+ const forceValue = document.getElementById('graph-force-value');
5882
+ if(forceRange) {
5883
+ forceRange.addEventListener('input', e => {
5884
+ const v = parseInt(e.target.value, 10);
5885
+ sim.force('charge', d3.forceManyBody().strength(v));
5886
+ sim.alpha(0.3).restart();
5887
+ if(forceValue) forceValue.textContent = v < 0 ? `\u2212${Math.abs(v)}` : String(v);
5888
+ });
5889
+ }
5890
+
5891
+ // ── Graph link distance slider ──
5892
+ const linkDistRange = document.getElementById('graph-link-dist-range');
5893
+ const linkDistValue = document.getElementById('graph-link-dist-value');
5894
+ if(linkDistRange) {
5895
+ linkDistRange.addEventListener('input', e => {
5896
+ const v = parseInt(e.target.value, 10);
5897
+ sim.force('link').distance(v);
5898
+ sim.alpha(0.3).restart();
5899
+ if(linkDistValue) linkDistValue.textContent = String(v);
5900
+ });
5901
+ }
5902
+
5903
+ // ── Graph node size radio ──
5904
+ document.querySelectorAll('input[name="graph-node-size"]').forEach(r => {
5905
+ r.addEventListener('change', e => {
5906
+ const mode = e.target.value;
5907
+ if(mode === 'uniform') {
5908
+ node.attr('r', 8);
5909
+ } else if(mode === 'wordcount') {
5910
+ node.attr('r', d => Math.max(4, Math.min(22, Math.sqrt(d.wordCount / 50))));
5911
+ } else {
5912
+ node.attr('r', d => isGodNode(d.id) ? Math.max(7, Math.min(22, Math.sqrt(d.wordCount/50))) : Math.max(4, Math.min(16, Math.sqrt(d.wordCount/70))));
5913
+ }
5914
+ sim.force('collision', d3.forceCollide().radius(d => {
5915
+ const r = parseFloat(node.filter(n => n.id === d.id).attr('r')) || 8;
5916
+ return r + 4;
5917
+ }));
5918
+ sim.alpha(0.3).restart();
5919
+ });
5920
+ });
5394
5921
  }
5395
5922
 
5396
5923
  // ── Graph Export (EXPORT-001) ──
@@ -5553,21 +6080,80 @@
5553
6080
  <div id="connectors-list">
5554
6081
  <div style="color:var(--text-muted);font-size:13px;text-align:center;padding:32px 0">Loading sources…</div>
5555
6082
  </div>
6083
+
6084
+ <div style="margin-top:28px;padding-top:20px;border-top:1px solid var(--border-subtle)">
6085
+ <div class="settings-section-title" style="margin-bottom:4px">Platform Integrations</div>
6086
+ <div class="settings-section-desc">Connect platforms to auto-sync data into your wiki.</div>
6087
+ <div id="oauth-integrations">
6088
+ <div style="color:var(--text-muted);font-size:13px;text-align:center;padding:24px 0">Loading integrations…</div>
6089
+ </div>
6090
+ </div>
6091
+
5556
6092
  <div id="connector-modal" class="connector-modal" style="display:none"></div>
5557
6093
  `;
5558
6094
  loadConnectors();
6095
+ renderOAuthIntegrations();
5559
6096
  } else if(section === 'appearance') {
6097
+ const prefs = loadAppearancePrefs();
5560
6098
  el.innerHTML = `
5561
6099
  <div class="settings-section-title">Appearance</div>
5562
- <div class="settings-section-desc">Customize how wikimem looks.</div>
6100
+ <div class="settings-section-desc">Customize how wikimem looks and feels.</div>
6101
+
5563
6102
  <div class="settings-group">
5564
6103
  <label class="settings-label">Theme</label>
5565
- <select class="settings-select"><option>Dark (default)</option><option disabled>Light (coming soon)</option></select>
6104
+ <div style="display:flex;gap:8px">
6105
+ <button class="settings-btn ${prefs.theme==='dark'?'settings-btn-primary':''}" onclick="setAppearance('theme','dark')">🌙 Dark</button>
6106
+ <button class="settings-btn ${prefs.theme==='light'?'settings-btn-primary':''}" onclick="setAppearance('theme','light')">☀️ Light</button>
6107
+ </div>
5566
6108
  </div>
6109
+
5567
6110
  <div class="settings-group">
5568
- <label class="settings-label">Font Size</label>
5569
- <select class="settings-select"><option>13px (default)</option><option>14px</option><option>15px</option></select>
5570
- </div>`;
6111
+ <label class="settings-label">Font Size <span style="color:var(--text-muted);font-weight:400">${prefs.fontSize}px</span></label>
6112
+ <input type="range" min="12" max="18" step="1" value="${prefs.fontSize}" class="tl-slider" style="max-width:240px;height:4px"
6113
+ oninput="document.querySelector('[data-fs-val]').textContent=this.value+'px';setAppearance('fontSize',+this.value)">
6114
+ <span data-fs-val style="display:none">${prefs.fontSize}px</span>
6115
+ </div>
6116
+
6117
+ <div class="settings-group">
6118
+ <label class="settings-label">Content Width</label>
6119
+ <select class="settings-select" onchange="setAppearance('contentWidth',this.value)">
6120
+ <option value="680" ${prefs.contentWidth===680?'selected':''}>Narrow (680px)</option>
6121
+ <option value="860" ${prefs.contentWidth===860?'selected':''}>Default (860px)</option>
6122
+ <option value="1100" ${prefs.contentWidth===1100?'selected':''}>Wide (1100px)</option>
6123
+ <option value="100%" ${prefs.contentWidth==='100%'?'selected':''}>Full width</option>
6124
+ </select>
6125
+ </div>
6126
+
6127
+ <div class="settings-group">
6128
+ <label class="settings-label">Interface Density</label>
6129
+ <select class="settings-select" onchange="setAppearance('density',this.value)">
6130
+ <option value="compact" ${prefs.density==='compact'?'selected':''}>Compact</option>
6131
+ <option value="comfortable" ${prefs.density==='comfortable'?'selected':''}>Comfortable (default)</option>
6132
+ <option value="spacious" ${prefs.density==='spacious'?'selected':''}>Spacious</option>
6133
+ </select>
6134
+ </div>
6135
+
6136
+ <div class="settings-group">
6137
+ <label class="settings-label">Accent Color</label>
6138
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
6139
+ ${['#4f9eff','#8b5cf6','#10b981','#f59e0b','#ef4444','#ec4899','#06b6d4'].map(c =>
6140
+ `<button style="width:28px;height:28px;border-radius:50%;background:${c};border:2px solid ${c===prefs.accent?'var(--text-bright)':'transparent'};cursor:pointer;transition:border 0.1s" onclick="setAppearance('accent','${c}')"></button>`
6141
+ ).join('')}
6142
+ </div>
6143
+ </div>
6144
+
6145
+ <div class="settings-group">
6146
+ <label class="settings-label">Code Block Line Numbers</label>
6147
+ <label class="auto-toggle" title="Show line numbers in code blocks">
6148
+ <input type="checkbox" ${prefs.codeLineNumbers?'checked':''} onchange="setAppearance('codeLineNumbers',this.checked)">
6149
+ <span class="auto-toggle-track"></span>
6150
+ </label>
6151
+ </div>
6152
+
6153
+ <div style="margin-top:20px;padding-top:16px;border-top:1px solid var(--border-subtle)">
6154
+ <button class="settings-btn" onclick="resetAppearance()">Reset to defaults</button>
6155
+ </div>
6156
+ `;
5571
6157
  } else if(section === 'automations') {
5572
6158
  el.innerHTML = `
5573
6159
  <div class="settings-section-title">Automations</div>
@@ -5641,13 +6227,41 @@
5641
6227
  <option value="manual">Manual only</option>
5642
6228
  </select>
5643
6229
  </div>
6230
+ <div class="settings-group" style="margin-bottom:14px">
6231
+ <label class="settings-label">Auto-Improve Weak Pages</label>
6232
+ <div style="display:flex;align-items:center;gap:10px">
6233
+ <label class="auto-toggle" title="Use LLM to fix weak pages">
6234
+ <input type="checkbox" id="auto-observer-improve" onchange="saveAutoSetting('observer','autoImprove',this.checked)">
6235
+ <span class="auto-toggle-track"></span>
6236
+ </label>
6237
+ <span style="font-size:11px;color:var(--text-muted)">LLM rewrites pages scoring below 50% (up to 3 per run)</span>
6238
+ </div>
6239
+ </div>
6240
+ <div class="settings-group" style="margin-bottom:14px">
6241
+ <label class="settings-label">Model for Observer</label>
6242
+ <select class="settings-select" id="auto-observer-model" onchange="saveAutoSetting('observer','model',this.value)">
6243
+ <option value="" selected>Use default (from Provider settings)</option>
6244
+ <option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
6245
+ <option value="claude-haiku-4-5-20251001">Claude Haiku 4.5</option>
6246
+ <option value="gpt-4o">GPT-4o</option>
6247
+ <option value="gpt-4o-mini">GPT-4o mini</option>
6248
+ <option value="ollama/llama3.1">Ollama — Llama 3.1</option>
6249
+ <option value="ollama/qwen2.5">Ollama — Qwen 2.5</option>
6250
+ </select>
6251
+ <div style="font-size:11px;color:var(--text-muted);margin-top:4px">Choose which model scores and improves pages</div>
6252
+ </div>
5644
6253
  <div class="settings-group" style="margin-bottom:0">
5645
- <label class="settings-label">Last Report</label>
5646
- <div id="auto-observer-last-report" style="font-size:12px;color:var(--text-muted);padding:4px 0">No reports yet</div>
6254
+ <label class="settings-label">Quality report</label>
6255
+ <div id="auto-observer-last-report" style="font-size:12px;color:var(--text-muted);padding:4px 0">Loading…</div>
6256
+ <div id="observer-report-detail" style="margin-top:8px;display:none"></div>
6257
+ <div id="observer-cx-list" style="margin-top:10px;max-height:220px;overflow-y:auto"></div>
5647
6258
  </div>
5648
6259
  <div class="auto-card-footer">
5649
6260
  <span class="auto-last-run" id="auto-observer-last">Last run: never</span>
5650
- <button class="settings-btn settings-btn-primary auto-run-btn" onclick="runAutomation('observer')">Run Now</button>
6261
+ <div style="display:flex;gap:6px">
6262
+ <button class="settings-btn auto-run-btn" onclick="runAutomation('observer')">Scan Only</button>
6263
+ <button class="settings-btn settings-btn-primary auto-run-btn" onclick="runObserverWithImprove()">Scan & Improve</button>
6264
+ </div>
5651
6265
  </div>
5652
6266
  </div>
5653
6267
  </div>
@@ -5737,9 +6351,9 @@
5737
6351
  <div class="settings-section-title">About</div>
5738
6352
  <div class="settings-section-desc">wikimem — self-improving knowledge bases with LLMs</div>
5739
6353
  <div style="color:var(--text-dim);font-size:13px;line-height:1.8">
5740
- <p><strong style="color:var(--text)">Version:</strong> 0.4.0</p>
6354
+ <p><strong style="color:var(--text)">Version:</strong> 0.8.0</p>
5741
6355
  <p><strong style="color:var(--text)">Author:</strong> Naman Parikh</p>
5742
- <p><strong style="color:var(--text)">GitHub:</strong> <a href="https://github.com/naman10parikh/llmwiki" style="color:var(--accent)" target="_blank">naman10parikh/llmwiki</a></p>
6356
+ <p><strong style="color:var(--text)">GitHub:</strong> <a href="https://github.com/naman10parikh/wikimem" style="color:var(--accent)" target="_blank">naman10parikh/wikimem</a></p>
5743
6357
  <p><strong style="color:var(--text)">npm:</strong> <a href="https://www.npmjs.com/package/wikimem" style="color:var(--accent)" target="_blank">wikimem</a></p>
5744
6358
  <p><strong style="color:var(--text)">License:</strong> MIT</p>
5745
6359
  </div>`;
@@ -5764,6 +6378,91 @@
5764
6378
 
5765
6379
  // ── Automations Settings ──────────────────────────────────────────────
5766
6380
  let autoSources = [];
6381
+ let _cxPairs = [];
6382
+
6383
+ function stripFrontmatter(md) {
6384
+ if (typeof md !== 'string') return '';
6385
+ const t = md.trim();
6386
+ if (t.startsWith('---')) {
6387
+ const end = t.indexOf('\n---', 3);
6388
+ if (end !== -1) return t.slice(end + 4).trim();
6389
+ }
6390
+ return t;
6391
+ }
6392
+
6393
+ function closeCxModal() {
6394
+ const o = document.getElementById('cx-modal-overlay');
6395
+ if (o) o.style.display = 'none';
6396
+ }
6397
+
6398
+ async function openContradictionCompare(idx) {
6399
+ const c = _cxPairs[idx];
6400
+ if (!c) return;
6401
+ const titleA = c.titleA || '';
6402
+ const titleB = c.titleB || '';
6403
+ document.getElementById('cx-modal-title').textContent = 'Potential contradiction';
6404
+ document.getElementById('cx-modal-reason').textContent = c.reason || '';
6405
+ document.getElementById('cx-head-a').textContent = titleA;
6406
+ document.getElementById('cx-head-b').textContent = titleB;
6407
+ document.getElementById('cx-body-a').textContent = 'Loading…';
6408
+ document.getElementById('cx-body-b').textContent = 'Loading…';
6409
+ const overlay = document.getElementById('cx-modal-overlay');
6410
+ if (overlay) overlay.style.display = 'flex';
6411
+ try {
6412
+ const [ra, rb] = await Promise.all([
6413
+ fetch('/api/pages/' + encodeURIComponent(titleA) + '/raw').then((r) => r.json()),
6414
+ fetch('/api/pages/' + encodeURIComponent(titleB) + '/raw').then((r) => r.json()),
6415
+ ]);
6416
+ document.getElementById('cx-body-a').textContent = stripFrontmatter(ra.raw || '');
6417
+ document.getElementById('cx-body-b').textContent = stripFrontmatter(rb.raw || '');
6418
+ } catch (e) {
6419
+ document.getElementById('cx-body-a').textContent = 'Could not load page A.';
6420
+ document.getElementById('cx-body-b').textContent = 'Could not load page B.';
6421
+ }
6422
+ }
6423
+
6424
+ function renderContradictionRows(contradictions) {
6425
+ const container = document.getElementById('observer-cx-list');
6426
+ if (!container) return;
6427
+ if (!contradictions || contradictions.length === 0) {
6428
+ container.innerHTML = '<div style="font-size:11px;color:var(--text-muted)">No potential contradictions in the latest scan.</div>';
6429
+ return;
6430
+ }
6431
+ _cxPairs = contradictions;
6432
+ container.innerHTML = contradictions.slice(0, 14).map((c, i) => `
6433
+ <div class="cx-row">
6434
+ <div class="cx-row-reason">${esc(c.reason || '')}</div>
6435
+ <button type="button" class="settings-btn" style="font-size:11px;padding:3px 10px;flex-shrink:0" onclick="openContradictionCompare(${i})">Compare</button>
6436
+ </div>`).join('');
6437
+ }
6438
+
6439
+ async function refreshObserverReportPanel(partial) {
6440
+ const summaryEl = document.getElementById('auto-observer-last-report');
6441
+ if (!summaryEl) return;
6442
+ try {
6443
+ let report = partial;
6444
+ if (!report || !report.date) {
6445
+ const list = await api('/api/observer/reports');
6446
+ if (!list.reports || list.reports.length === 0) {
6447
+ summaryEl.innerHTML = 'No reports yet — run <strong>Scan Only</strong> or <strong>Scan &amp; Improve</strong>.';
6448
+ renderContradictionRows([]);
6449
+ return;
6450
+ }
6451
+ const date = list.reports[0];
6452
+ report = await api('/api/observer/reports/' + encodeURIComponent(date));
6453
+ }
6454
+ const avg = report.averageScore != null ? report.averageScore : '—';
6455
+ const maxS = report.maxScore != null ? report.maxScore : '14';
6456
+ const orphans = report.orphanCount != null ? report.orphanCount : (report.orphans ? report.orphans.length : 0);
6457
+ const gaps = report.gapCount != null ? report.gapCount : (report.gaps ? report.gaps.length : 0);
6458
+ const cx = report.contradictionCount != null ? report.contradictionCount : (report.contradictions ? report.contradictions.length : 0);
6459
+ summaryEl.innerHTML = `Latest <span style="color:var(--text-secondary)">${esc(report.date || '')}</span> — avg score <strong>${avg}/${maxS}</strong>, ${orphans} orphans, ${gaps} gaps, <strong style="color:var(--amber)">${cx}</strong> contradiction flags`;
6460
+ renderContradictionRows(report.contradictions || []);
6461
+ } catch (e) {
6462
+ summaryEl.textContent = 'Could not load observer reports.';
6463
+ renderContradictionRows([]);
6464
+ }
6465
+ }
5767
6466
 
5768
6467
  async function loadAutoSettings() {
5769
6468
  try {
@@ -5790,7 +6489,7 @@
5790
6489
  el('auto-observer-last').textContent = 'Last run: ' + formatTimeAgo(observer.lastRun);
5791
6490
 
5792
6491
  if(el('auto-observer-last-report') && observer.lastReportPath) {
5793
- el('auto-observer-last-report').innerHTML = `<a href="#" onclick="event.preventDefault();openPage('${esc(observer.lastReportPath)}')" style="color:var(--accent)">View last report →</a>`;
6492
+ el('auto-observer-last-report').innerHTML = `<span style="color:var(--text-muted)">Report file: </span><span style="font-family:var(--font-mono);font-size:11px">${esc(observer.lastReportPath)}</span>`;
5794
6493
  }
5795
6494
 
5796
6495
  if(el('auto-webhook-count') && webhook.count != null)
@@ -5802,6 +6501,8 @@
5802
6501
  // Render sources list
5803
6502
  autoSources = sourcing.sources || [];
5804
6503
  renderAutoSources();
6504
+
6505
+ await refreshObserverReportPanel(null);
5805
6506
  } catch(e) { /* settings endpoint may not exist yet */ }
5806
6507
  }
5807
6508
 
@@ -5844,6 +6545,58 @@
5844
6545
  navigator.clipboard.writeText(url).then(() => showToast('✓ Webhook URL copied'));
5845
6546
  }
5846
6547
 
6548
+ // ── Appearance Preferences ──
6549
+ const APPEARANCE_DEFAULTS = { theme:'dark', fontSize:14, contentWidth:860, density:'comfortable', accent:'#4f9eff', codeLineNumbers:false };
6550
+ function loadAppearancePrefs() {
6551
+ try { return { ...APPEARANCE_DEFAULTS, ...JSON.parse(localStorage.getItem('wikimem-appearance')||'{}') }; }
6552
+ catch { return { ...APPEARANCE_DEFAULTS }; }
6553
+ }
6554
+ function saveAppearancePrefs(prefs) { localStorage.setItem('wikimem-appearance', JSON.stringify(prefs)); }
6555
+ function setAppearance(key, value) {
6556
+ const prefs = loadAppearancePrefs();
6557
+ prefs[key] = value;
6558
+ saveAppearancePrefs(prefs);
6559
+ applyAppearance(prefs);
6560
+ if(state.settingsSection === 'appearance') renderSettingsSection('appearance');
6561
+ }
6562
+ function resetAppearance() {
6563
+ localStorage.removeItem('wikimem-appearance');
6564
+ applyAppearance(APPEARANCE_DEFAULTS);
6565
+ renderSettingsSection('appearance');
6566
+ showToast('✓ Appearance reset');
6567
+ }
6568
+ function applyAppearance(prefs) {
6569
+ const r = document.documentElement;
6570
+ if(prefs.theme === 'light') {
6571
+ r.style.setProperty('--bg-deep','#f0f0f0'); r.style.setProperty('--bg-inset','#e8e8e8');
6572
+ r.style.setProperty('--bg','#ffffff'); r.style.setProperty('--bg-surface','#f5f5f5');
6573
+ r.style.setProperty('--bg-card','#eeeeee'); r.style.setProperty('--bg-hover','#e0e0e0');
6574
+ r.style.setProperty('--bg-active','#d5d5d5');
6575
+ r.style.setProperty('--text','#1e1e1e'); r.style.setProperty('--text-secondary','#555');
6576
+ r.style.setProperty('--text-bright','#000'); r.style.setProperty('--text-dim','#888');
6577
+ r.style.setProperty('--text-muted','#999');
6578
+ r.style.setProperty('--border','#d0d0d0'); r.style.setProperty('--border-subtle','#e0e0e0');
6579
+ } else {
6580
+ r.style.removeProperty('--bg-deep'); r.style.removeProperty('--bg-inset');
6581
+ r.style.removeProperty('--bg'); r.style.removeProperty('--bg-surface');
6582
+ r.style.removeProperty('--bg-card'); r.style.removeProperty('--bg-hover');
6583
+ r.style.removeProperty('--bg-active');
6584
+ r.style.removeProperty('--text'); r.style.removeProperty('--text-secondary');
6585
+ r.style.removeProperty('--text-bright'); r.style.removeProperty('--text-dim');
6586
+ r.style.removeProperty('--text-muted');
6587
+ r.style.removeProperty('--border'); r.style.removeProperty('--border-subtle');
6588
+ }
6589
+ r.style.setProperty('--font-size-base', prefs.fontSize + 'px');
6590
+ r.style.setProperty('--accent', prefs.accent);
6591
+ r.style.setProperty('--accent-dim', prefs.accent + '22');
6592
+ document.querySelectorAll('.page-layout').forEach(el => {
6593
+ el.style.maxWidth = typeof prefs.contentWidth === 'number' ? prefs.contentWidth + 'px' : prefs.contentWidth;
6594
+ });
6595
+ const densityPad = prefs.density === 'compact' ? '2px' : prefs.density === 'spacious' ? '6px' : '4px';
6596
+ r.style.setProperty('--density-pad', densityPad);
6597
+ }
6598
+ applyAppearance(loadAppearancePrefs());
6599
+
5847
6600
  async function saveAutoSetting(automation, key, value) {
5848
6601
  try {
5849
6602
  await fetch('/api/automations/settings', {
@@ -5863,15 +6616,98 @@
5863
6616
  const lastEl = document.getElementById(`auto-${type}-last`);
5864
6617
  if(lastEl) { lastEl.textContent = '⟳ Running…'; }
5865
6618
  try {
5866
- await api(endpoint, { method: 'POST' });
6619
+ const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
6620
+ const result = await res.json();
5867
6621
  if(lastEl) { lastEl.textContent = 'Last run: just now'; }
5868
6622
  const s = document.getElementById('automations-status');
5869
- if(s) { s.className='settings-status ok'; s.textContent='✓ Started'; setTimeout(()=>s.textContent='', 3000); }
6623
+ if(s) { s.className='settings-status ok'; s.textContent='✓ Complete'; setTimeout(()=>s.textContent='', 3000); }
6624
+ if(type === 'observer') {
6625
+ const cx = result.contradictionCount != null ? result.contradictionCount : 0;
6626
+ showToast(`Observer: ${result.pagesReviewed} pages, avg ${result.averageScore}/${result.maxScore}, ${result.orphanCount} orphans, ${cx} contradiction flags`);
6627
+ refreshObserverReportPanel(result);
6628
+ renderObserverDetailPanel(result);
6629
+ }
5870
6630
  } catch(e) {
5871
6631
  if(lastEl) { lastEl.textContent = '✗ Failed to start'; }
5872
6632
  }
5873
6633
  }
5874
6634
 
6635
+ async function runObserverWithImprove() {
6636
+ const lastEl = document.getElementById('auto-observer-last');
6637
+ if(lastEl) { lastEl.textContent = '⟳ Scanning & improving…'; }
6638
+ try {
6639
+ const modelSel = document.getElementById('auto-observer-model');
6640
+ const model = modelSel ? modelSel.value : '';
6641
+ const body = { autoImprove: true, maxImprovements: 3 };
6642
+ if (model) body.model = model;
6643
+ const res = await fetch('/api/observer/run', {
6644
+ method: 'POST',
6645
+ headers: { 'Content-Type': 'application/json' },
6646
+ body: JSON.stringify(body)
6647
+ });
6648
+ const result = await res.json();
6649
+ if(lastEl) { lastEl.textContent = 'Last run: just now'; }
6650
+ const improved = result.improvementCount || 0;
6651
+ showToast(`Observer: ${result.pagesReviewed} pages scanned, ${improved} pages improved, avg score ${result.averageScore}/${result.maxScore}`);
6652
+ refreshObserverReportPanel(result);
6653
+ renderObserverDetailPanel(result);
6654
+ if(improved > 0) { loadTree(); loadHome(); }
6655
+ } catch(e) {
6656
+ if(lastEl) { lastEl.textContent = '✗ Failed'; }
6657
+ showToast('Observer run failed: ' + (e.message || e));
6658
+ }
6659
+ }
6660
+
6661
+ function renderObserverDetailPanel(result) {
6662
+ const panel = document.getElementById('observer-report-detail');
6663
+ if (!panel) return;
6664
+ let html = '';
6665
+ const pct = result.maxScore > 0 ? Math.round((result.averageScore / result.maxScore) * 100) : 0;
6666
+ const barColor = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--amber)' : 'var(--red)';
6667
+ html += '<div style="margin-bottom:12px">'
6668
+ + '<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px">'
6669
+ + '<span style="color:var(--text-secondary)">Average Quality</span>'
6670
+ + '<span style="color:' + barColor + ';font-weight:600">' + result.averageScore + '/' + result.maxScore + ' (' + pct + '%)</span></div>'
6671
+ + '<div style="height:6px;background:var(--border);border-radius:3px;overflow:hidden">'
6672
+ + '<div style="height:100%;width:' + pct + '%;background:' + barColor + ';border-radius:3px;transition:width 0.5s"></div></div></div>';
6673
+ html += '<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">'
6674
+ + '<span style="font-size:11px;padding:3px 7px;background:var(--bg-surface);border-radius:var(--radius-sm);border:1px solid var(--border)">📊 ' + result.totalPages + ' pages</span>'
6675
+ + '<span style="font-size:11px;padding:3px 7px;background:var(--bg-surface);border-radius:var(--radius-sm);border:1px solid var(--border)">🔗 ' + result.orphanCount + ' orphans</span>'
6676
+ + '<span style="font-size:11px;padding:3px 7px;background:var(--bg-surface);border-radius:var(--radius-sm);border:1px solid var(--border)">🕳️ ' + result.gapCount + ' gaps</span>'
6677
+ + '<span style="font-size:11px;padding:3px 7px;background:var(--bg-surface);border-radius:var(--radius-sm);border:1px solid var(--border)">⚡ ' + result.contradictionCount + ' contradictions</span></div>';
6678
+ if (result.improvements && result.improvements.length > 0) {
6679
+ html += '<div style="margin-bottom:12px"><div style="font-size:12px;color:var(--text-secondary);margin-bottom:6px;font-weight:500">Improvements</div>';
6680
+ for (const imp of result.improvements) {
6681
+ const icon = imp.improved ? '✅' : '❌';
6682
+ const sc = imp.newScore != null ? (imp.originalScore + ' → ' + imp.newScore) : ('was ' + imp.originalScore);
6683
+ const bg = imp.improved ? 'var(--green-dim)' : 'var(--red-dim)';
6684
+ html += '<div style="font-size:11px;padding:5px 8px;margin-bottom:3px;background:' + bg + ';border-radius:var(--radius-sm)">'
6685
+ + icon + ' <strong>' + esc(imp.title) + '</strong> <span style="color:var(--text-muted)">(' + sc + '/' + result.maxScore + ')</span>'
6686
+ + '<div style="color:var(--text-dim);margin-top:2px;font-size:10px">' + esc(imp.action) + '</div></div>';
6687
+ }
6688
+ html += '</div>';
6689
+ }
6690
+ if (result.weakestPages && result.weakestPages.length > 0) {
6691
+ html += '<div style="margin-bottom:8px"><div style="font-size:12px;color:var(--text-secondary);margin-bottom:6px;font-weight:500">Weakest Pages</div>';
6692
+ for (const p of result.weakestPages) {
6693
+ html += '<div style="font-size:11px;padding:3px 8px;margin-bottom:2px;display:flex;justify-content:space-between;background:var(--bg-surface);border-radius:var(--radius-sm);border:1px solid var(--border)">'
6694
+ + '<span>' + esc(p.title) + '</span>'
6695
+ + '<span style="color:var(--amber);font-weight:500">' + p.score + '/' + p.maxScore + '</span></div>';
6696
+ }
6697
+ html += '</div>';
6698
+ }
6699
+ if (result.topIssues && result.topIssues.length > 0) {
6700
+ html += '<div><div style="font-size:12px;color:var(--text-secondary);margin-bottom:6px;font-weight:500">Top Issues</div>';
6701
+ for (const iss of result.topIssues.slice(0, 5)) {
6702
+ html += '<div style="font-size:11px;padding:2px 0;color:var(--text-dim)">'
6703
+ + '<span style="color:var(--amber);font-weight:500">' + iss.count + '×</span> ' + esc(iss.issue) + '</div>';
6704
+ }
6705
+ html += '</div>';
6706
+ }
6707
+ panel.innerHTML = html;
6708
+ panel.style.display = 'block';
6709
+ }
6710
+
5875
6711
  // ── Pipeline Configuration ───────────────────────────────────────────
5876
6712
  const CORE_STEPS = [
5877
6713
  { id: 'detect', icon: '🔍', label: 'Detect' },
@@ -6402,6 +7238,7 @@
6402
7238
  }
6403
7239
  onboardEl.style.display = 'none';
6404
7240
  dashEl.style.display = '';
7241
+ renderHomeConnectors();
6405
7242
 
6406
7243
  const statusText = `${stats.pageCount} pages · ${stats.wordCount.toLocaleString()} words`;
6407
7244
  document.querySelectorAll('#status-text').forEach(el => el.textContent = statusText);
@@ -6517,6 +7354,12 @@
6517
7354
  : `<div id="search-empty">Type to search across all pages, content, and metadata</div>`;
6518
7355
  return;
6519
7356
  }
7357
+ // Check if we can just update selection classes (avoids DOM destruction on hover)
7358
+ const existingItems = container.querySelectorAll('.search-result');
7359
+ if (existingItems.length === state.searchResults.length && existingItems.length > 0) {
7360
+ existingItems.forEach((el, i) => el.classList.toggle('selected', i === state.searchIndex));
7361
+ return;
7362
+ }
6520
7363
  container.innerHTML = state.searchResults.map((r,i) => {
6521
7364
  const snippet = r.snippet ? `<div style="font-size:11px;color:var(--text-muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:400px">${esc(r.snippet)}</div>` : '';
6522
7365
  const matchIcon = r.matchType === 'content' ? '📝' : '◇';
@@ -6785,6 +7628,22 @@
6785
7628
  if((e.metaKey||e.ctrlKey) && e.key==='g') { e.preventDefault(); railGraphClick(); }
6786
7629
  if((e.metaKey||e.ctrlKey) && e.key===',') { e.preventDefault(); railSettingsClick(); }
6787
7630
  if((e.metaKey||e.ctrlKey) && e.key==='w') { e.preventDefault(); if(state.activeTabId) closeTab(state.activeTabId); }
7631
+ // Cmd+N — new note
7632
+ if((e.metaKey||e.ctrlKey) && e.key==='n') {
7633
+ e.preventDefault();
7634
+ document.getElementById('sa-new-note')?.click();
7635
+ }
7636
+ // Cmd+Tab / Ctrl+Tab — next tab
7637
+ if((e.ctrlKey || e.metaKey) && e.key==='Tab') {
7638
+ e.preventDefault();
7639
+ if(state.tabs.length > 1) {
7640
+ const idx = state.tabs.findIndex(t => t.id === state.activeTabId);
7641
+ const next = e.shiftKey
7642
+ ? (idx - 1 + state.tabs.length) % state.tabs.length
7643
+ : (idx + 1) % state.tabs.length;
7644
+ activateTab(state.tabs[next].id);
7645
+ }
7646
+ }
6788
7647
  if(e.key==='Escape') { closeSearch(); closePalette(); }
6789
7648
  });
6790
7649
 
@@ -7272,16 +8131,253 @@
7272
8131
  loadTree(); loadHome();
7273
8132
  }
7274
8133
 
8134
+ // ── Open Settings to a specific section ──
8135
+ function openSettings(section) {
8136
+ showView('settings');
8137
+ // showView triggers loadSettings() which renders the default section;
8138
+ // after config loads, switch to the requested section
8139
+ const waitForSettings = () => {
8140
+ if (document.getElementById('settings-content')) {
8141
+ renderSettingsSection(section || 'sources');
8142
+ // Highlight the matching nav item
8143
+ document.querySelectorAll('.settings-nav-item').forEach(el => {
8144
+ el.classList.toggle('active', el.dataset.section === (section || 'sources'));
8145
+ });
8146
+ } else {
8147
+ requestAnimationFrame(waitForSettings);
8148
+ }
8149
+ };
8150
+ requestAnimationFrame(waitForSettings);
8151
+ }
8152
+
7275
8153
  // ── OAuth Connect ──
7276
8154
  async function startOAuthConnect(provider) {
7277
8155
  try {
7278
8156
  const res = await fetch('/api/auth/start/' + encodeURIComponent(provider));
7279
8157
  const data = await res.json();
7280
- if (data.error) { showToast('OAuth: ' + data.error); return; }
7281
- if (data.url) window.open(data.url, '_blank', 'width=600,height=700');
8158
+ if (data.error === 'no_credentials') {
8159
+ // GitHub: try device flow (no client_secret needed — just needs client_id)
8160
+ if (provider === 'github') {
8161
+ showPipelineDeviceFlow();
8162
+ return;
8163
+ }
8164
+ // Show inline credential setup modal — don't redirect to Settings
8165
+ showCredentialSetupModal(provider);
8166
+ return;
8167
+ }
8168
+ if (data.error) { showToast('OAuth: ' + (data.message || data.error)); return; }
8169
+ if (data.url) {
8170
+ const popup = window.open(data.url, '_blank', 'width=600,height=700');
8171
+ // Poll for completion — when the popup closes, refresh status
8172
+ if (popup) {
8173
+ const poll = setInterval(() => {
8174
+ if (popup.closed) { clearInterval(poll); loadOAuthStatus(); renderOAuthIntegrations(); }
8175
+ }, 1000);
8176
+ }
8177
+ }
7282
8178
  } catch (err) { showToast('OAuth failed: ' + (err.message || 'unknown')); }
7283
8179
  }
7284
8180
 
8181
+ // Listen for OAuth completion postMessage from callback popup
8182
+ window.addEventListener('message', function(event) {
8183
+ if (event.data && event.data.type === 'wikimem-oauth-connected' && event.data.provider) {
8184
+ showToast(event.data.provider.charAt(0).toUpperCase() + event.data.provider.slice(1) + ' connected — syncing data...');
8185
+ loadOAuthStatus();
8186
+ renderOAuthIntegrations();
8187
+ renderHomeConnectors();
8188
+ loadSidebarConnectors();
8189
+ syncOAuthProvider(event.data.provider);
8190
+ }
8191
+ });
8192
+
8193
+ // Pipeline-page device flow modal for GitHub (no client_secret needed)
8194
+ async function showPipelineDeviceFlow() {
8195
+ // Create modal overlay
8196
+ let modal = document.getElementById('device-flow-modal');
8197
+ if (modal) modal.remove();
8198
+ modal = document.createElement('div');
8199
+ modal.id = 'device-flow-modal';
8200
+ modal.style.cssText = 'position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)';
8201
+ modal.innerHTML = `<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:12px;padding:24px;max-width:420px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,0.4)">
8202
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
8203
+ <h3 style="margin:0;font-size:15px;color:var(--text-bright)">Connect GitHub</h3>
8204
+ <button onclick="document.getElementById('device-flow-modal').remove()" style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:18px">&times;</button>
8205
+ </div>
8206
+ <p style="color:var(--text-secondary);font-size:12px;margin:0 0 16px">No app registration needed — uses GitHub Device Flow.</p>
8207
+ <div id="pipeline-device-flow-content" style="text-align:center;padding:8px 0">
8208
+ <div class="device-flow-spinner" style="margin:0 auto"></div>
8209
+ <div style="margin-top:8px;color:var(--text-muted);font-size:12px">Starting device flow...</div>
8210
+ </div>
8211
+ </div>`;
8212
+ document.body.appendChild(modal);
8213
+ modal.addEventListener('click', function(e) { if (e.target === modal) modal.remove(); });
8214
+
8215
+ // Start device flow
8216
+ try {
8217
+ const res = await fetch('/api/auth/device-flow/start', { method: 'POST' });
8218
+ const data = await res.json();
8219
+ const content = document.getElementById('pipeline-device-flow-content');
8220
+ if (!content) return;
8221
+
8222
+ if (data.error === 'no_client_id') {
8223
+ content.innerHTML = `<div style="text-align:left">
8224
+ <p style="color:var(--text-secondary);font-size:12px;margin:0 0 12px">Device flow requires a GitHub OAuth App client ID. Set the env var or enter credentials:</p>
8225
+ <div style="display:flex;flex-direction:column;gap:8px">
8226
+ <input class="settings-input" id="pipeline-gh-id" type="text" placeholder="GitHub Client ID" style="width:100%;box-sizing:border-box" />
8227
+ <input class="settings-input" id="pipeline-gh-secret" type="password" placeholder="GitHub Client Secret" style="width:100%;box-sizing:border-box" />
8228
+ <button class="settings-btn settings-btn-primary" onclick="savePipelineGitHubCreds()" style="font-size:12px;padding:6px 16px;align-self:flex-end">Save & Connect</button>
8229
+ </div>
8230
+ </div>`;
8231
+ return;
8232
+ }
8233
+
8234
+ if (data.error) {
8235
+ content.innerHTML = '<div style="color:var(--red);font-size:12px">' + (data.message || data.error) + '</div>';
8236
+ return;
8237
+ }
8238
+
8239
+ // Show device code
8240
+ content.innerHTML = `<div style="text-align:left">
8241
+ <div style="font-size:12px;color:var(--text-secondary);margin-bottom:12px">
8242
+ 1. Go to <a href="${data.verification_uri}" target="_blank" style="color:var(--accent)">${data.verification_uri}</a><br/>
8243
+ 2. Enter this code:
8244
+ </div>
8245
+ <div style="font-size:28px;font-weight:700;letter-spacing:4px;text-align:center;padding:12px;background:var(--bg-secondary);border-radius:8px;color:var(--text-bright);font-family:monospace;margin-bottom:8px">${data.user_code}</div>
8246
+ <div style="text-align:center;margin-bottom:12px">
8247
+ <button class="settings-btn" onclick="navigator.clipboard.writeText('${data.user_code}');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy Code',1500)" style="font-size:11px;padding:4px 12px">Copy Code</button>
8248
+ </div>
8249
+ <div id="pipeline-device-status" style="text-align:center;font-size:12px;color:var(--text-muted)">
8250
+ <span class="device-flow-spinner" style="display:inline-block;width:12px;height:12px;border-width:2px;vertical-align:middle;margin-right:6px"></span>
8251
+ Waiting for authorization...
8252
+ </div>
8253
+ </div>`;
8254
+
8255
+ // Poll for completion
8256
+ pollPipelineDeviceFlow(data.device_code, data.interval || 5);
8257
+ } catch (err) {
8258
+ const content = document.getElementById('pipeline-device-flow-content');
8259
+ if (content) content.innerHTML = '<div style="color:var(--red);font-size:12px">Failed to start: ' + (err.message || 'unknown') + '</div>';
8260
+ }
8261
+ }
8262
+
8263
+ function pollPipelineDeviceFlow(deviceCode, interval) {
8264
+ const pollMs = Math.max((interval || 5) * 1000, 5000);
8265
+ const timer = setInterval(async () => {
8266
+ try {
8267
+ const res = await fetch('/api/auth/device-flow/poll', {
8268
+ method: 'POST',
8269
+ headers: { 'Content-Type': 'application/json' },
8270
+ body: JSON.stringify({ device_code: deviceCode }),
8271
+ });
8272
+ const data = await res.json();
8273
+ const statusEl = document.getElementById('pipeline-device-status');
8274
+
8275
+ if (data.status === 'complete') {
8276
+ clearInterval(timer);
8277
+ if (statusEl) statusEl.innerHTML = '<span style="color:var(--green);font-weight:600">Connected! Syncing your data...</span>';
8278
+ showToast('GitHub connected — syncing data...');
8279
+ loadOAuthStatus();
8280
+ syncOAuthProvider('github');
8281
+ // Close modal after brief delay
8282
+ setTimeout(() => { const m = document.getElementById('device-flow-modal'); if (m) m.remove(); }, 2500);
8283
+ } else if (data.status === 'expired') {
8284
+ clearInterval(timer);
8285
+ if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">Code expired. </span><a href="#" onclick="event.preventDefault();document.getElementById(\'device-flow-modal\').remove();showPipelineDeviceFlow()" style="color:var(--accent)">Try again</a>';
8286
+ }
8287
+ } catch { /* network error — keep trying */ }
8288
+ }, pollMs);
8289
+ }
8290
+
8291
+ async function savePipelineGitHubCreds() {
8292
+ const idInput = document.getElementById('pipeline-gh-id');
8293
+ const secretInput = document.getElementById('pipeline-gh-secret');
8294
+ if (!idInput || !secretInput) return;
8295
+ const clientId = idInput.value.trim();
8296
+ const clientSecret = secretInput.value.trim();
8297
+ if (!clientId || !clientSecret) { showToast('Enter both Client ID and Secret'); return; }
8298
+ try {
8299
+ const res = await fetch('/api/config', {
8300
+ method: 'PUT',
8301
+ headers: { 'Content-Type': 'application/json' },
8302
+ body: JSON.stringify({ github_client_id: clientId, github_client_secret: clientSecret }),
8303
+ });
8304
+ const data = await res.json();
8305
+ if (data.error) throw new Error(data.error);
8306
+ showToast('GitHub credentials saved — connecting...');
8307
+ const modal = document.getElementById('device-flow-modal');
8308
+ if (modal) modal.remove();
8309
+ startOAuthConnect('github');
8310
+ } catch (err) { showToast('Save failed: ' + (err.message || 'unknown')); }
8311
+ }
8312
+
8313
+ // ── Credential Setup Modal (shown when user clicks Connect but no credentials exist) ──
8314
+ function showCredentialSetupModal(provider) {
8315
+ const meta = OAUTH_PROVIDER_META[provider];
8316
+ if (!meta) return;
8317
+ const port = location.port || '3456';
8318
+ const callbackUrl = `http://localhost:${port}/api/auth/callback`;
8319
+ const stepsHtml = meta.steps.map(s =>
8320
+ `<li>${s.replace(/CALLBACK/g, '<code style="font-size:11px;color:var(--accent)">' + esc(callbackUrl) + '</code>').replace(/PORT/g, port)}</li>`
8321
+ ).join('');
8322
+
8323
+ let modal = document.getElementById('cred-setup-modal');
8324
+ if (modal) modal.remove();
8325
+ modal = document.createElement('div');
8326
+ modal.id = 'cred-setup-modal';
8327
+ modal.style.cssText = 'position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)';
8328
+ modal.innerHTML = `<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:12px;padding:24px;max-width:480px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,0.4);max-height:80vh;overflow-y:auto">
8329
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
8330
+ <h3 style="margin:0;font-size:15px;color:var(--text-bright)">Connect ${meta.name}</h3>
8331
+ <button onclick="document.getElementById('cred-setup-modal').remove()" style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:18px">&times;</button>
8332
+ </div>
8333
+ <p style="color:var(--text-secondary);font-size:12px;margin:0 0 16px;line-height:1.5">
8334
+ Enter your ${meta.name} OAuth credentials to connect. You can set them as environment variables or enter them below.
8335
+ </p>
8336
+ <div style="background:var(--bg-surface);border-radius:8px;padding:12px;margin-bottom:16px">
8337
+ <div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">Environment variables</div>
8338
+ <code style="font-size:11px;color:var(--accent);word-break:break-all">WIKIMEM_${provider.toUpperCase()}_CLIENT_ID</code><br/>
8339
+ <code style="font-size:11px;color:var(--accent);word-break:break-all">WIKIMEM_${provider.toUpperCase()}_CLIENT_SECRET</code>
8340
+ </div>
8341
+ <details style="margin-bottom:16px">
8342
+ <summary style="font-size:12px;color:var(--text-muted);cursor:pointer;font-weight:500">How to register a ${meta.name} OAuth app</summary>
8343
+ <ol class="oauth-setup-steps" style="margin-top:8px">${stepsHtml}</ol>
8344
+ </details>
8345
+ <div style="display:flex;flex-direction:column;gap:8px">
8346
+ <input class="settings-input" id="cred-modal-id" type="text" placeholder="Client ID" style="width:100%;box-sizing:border-box" />
8347
+ <input class="settings-input" id="cred-modal-secret" type="password" placeholder="Client Secret" style="width:100%;box-sizing:border-box" />
8348
+ <button class="settings-btn settings-btn-primary" onclick="saveCredModalAndConnect('${provider}')" style="font-size:12px;padding:8px 16px;align-self:flex-end">Save & Connect</button>
8349
+ </div>
8350
+ </div>`;
8351
+ document.body.appendChild(modal);
8352
+ modal.addEventListener('click', function(e) { if (e.target === modal) modal.remove(); });
8353
+ }
8354
+
8355
+ async function saveCredModalAndConnect(provider) {
8356
+ const meta = OAUTH_PROVIDER_META[provider];
8357
+ const idInput = document.getElementById('cred-modal-id');
8358
+ const secretInput = document.getElementById('cred-modal-secret');
8359
+ if (!idInput || !secretInput) return;
8360
+ const clientId = idInput.value.trim();
8361
+ const clientSecret = secretInput.value.trim();
8362
+ if (!clientId || !clientSecret) { showToast('Enter both Client ID and Secret'); return; }
8363
+ try {
8364
+ const body = {};
8365
+ body[meta.idKey] = clientId;
8366
+ body[meta.secretKey] = clientSecret;
8367
+ const res = await fetch('/api/config', {
8368
+ method: 'PUT',
8369
+ headers: { 'Content-Type': 'application/json' },
8370
+ body: JSON.stringify(body),
8371
+ });
8372
+ const data = await res.json();
8373
+ if (data.error) throw new Error(data.error);
8374
+ showToast(`${meta.name} credentials saved — connecting...`);
8375
+ const modal = document.getElementById('cred-setup-modal');
8376
+ if (modal) modal.remove();
8377
+ startOAuthConnect(provider);
8378
+ } catch (err) { showToast('Save failed: ' + (err.message || 'unknown')); }
8379
+ }
8380
+
7285
8381
  async function loadOAuthStatus() {
7286
8382
  try {
7287
8383
  const data = await fetch('/api/auth/tokens').then(r => r.json());
@@ -7290,18 +8386,379 @@
7290
8386
  if (!badge) continue;
7291
8387
  if (info.connected) {
7292
8388
  badge.innerHTML = '<span class="connected-badge">Connected</span>';
8389
+ } else if (info.hasCredentials || info.hasDeviceFlow) {
8390
+ badge.innerHTML = '<button class="connect-btn" onclick="event.stopPropagation();startOAuthConnect(\'' + provider + '\')">Connect</button>';
7293
8391
  } else {
7294
8392
  badge.innerHTML = '<button class="connect-btn" onclick="event.stopPropagation();startOAuthConnect(\'' + provider + '\')">Connect</button>';
7295
8393
  }
7296
8394
  }
7297
8395
  // Gmail mirrors google token
7298
8396
  const gmailBadge = document.getElementById('oauth-badge-gmail');
7299
- if (gmailBadge && data.google?.connected) {
7300
- gmailBadge.innerHTML = '<span class="connected-badge">Connected</span>';
8397
+ if (gmailBadge && data.google) {
8398
+ if (data.google.connected) {
8399
+ gmailBadge.innerHTML = '<span class="connected-badge">Connected</span>';
8400
+ } else {
8401
+ gmailBadge.innerHTML = '<button class="connect-btn" onclick="event.stopPropagation();startOAuthConnect(\'google\')">Connect</button>';
8402
+ }
7301
8403
  }
7302
8404
  } catch {}
7303
8405
  }
7304
8406
 
8407
+ async function renderHomeConnectors() {
8408
+ const container = document.getElementById('home-oauth-cards');
8409
+ if (!container) return;
8410
+ const providers = ['github', 'slack', 'google', 'linear', 'jira'];
8411
+ let tokenData = {};
8412
+ try { tokenData = await fetch('/api/auth/tokens').then(r => r.json()); } catch {}
8413
+ container.innerHTML = providers.map(p => {
8414
+ const meta = OAUTH_PROVIDER_META[p];
8415
+ if (!meta) return '';
8416
+ const info = tokenData[p] || {};
8417
+ const connected = !!info.connected;
8418
+ const btnHtml = connected
8419
+ ? '<span style="font-size:10px;color:#34d399;font-weight:600">Connected</span>'
8420
+ : `<button class="connect-btn" onclick="event.stopPropagation();startOAuthConnect('${p}')" style="font-size:10px;padding:2px 8px">Connect</button>`;
8421
+ return `<div style="display:flex;align-items:center;gap:8px;background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:8px 12px;min-width:140px;cursor:pointer" onclick="showView('settings');setTimeout(()=>{const t=document.querySelector('.settings-nav-item[data-section=sources]');if(t)t.click()},100)">
8422
+ <span style="color:var(--text-secondary);flex-shrink:0">${meta.icon}</span>
8423
+ <span style="font-size:12px;color:var(--text-bright);font-weight:500;flex:1">${meta.name}</span>
8424
+ ${btnHtml}
8425
+ </div>`;
8426
+ }).join('');
8427
+ }
8428
+
8429
+ // ── OAuth Setup Wizard ──
8430
+ const OAUTH_PROVIDER_META = {
8431
+ github: {
8432
+ name: 'GitHub',
8433
+ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>',
8434
+ desc: 'Repos, issues, PRs, and starred projects',
8435
+ idKey: 'github_client_id', secretKey: 'github_client_secret',
8436
+ steps: [
8437
+ 'Go to <a href="https://github.com/settings/developers" target="_blank" style="color:var(--accent)">GitHub → Settings → Developer Settings → OAuth Apps</a>',
8438
+ 'Click <strong>New OAuth App</strong>',
8439
+ 'Set <strong>Homepage URL</strong> to <code>http://localhost:PORT</code>',
8440
+ 'Set <strong>Authorization callback URL</strong> to <code>CALLBACK</code>',
8441
+ 'Copy the <strong>Client ID</strong> and generate a <strong>Client Secret</strong>',
8442
+ 'Paste both values below and click <strong>Save Credentials</strong>',
8443
+ ],
8444
+ },
8445
+ slack: {
8446
+ name: 'Slack',
8447
+ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.271 0a2.528 2.528 0 0 1-2.521 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.164 0a2.528 2.528 0 0 1 2.521 2.522v6.312zM15.164 18.956a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.164 24a2.528 2.528 0 0 1-2.521-2.522v-2.522h2.521zm0-1.271a2.528 2.528 0 0 1-2.521-2.521 2.528 2.528 0 0 1 2.521-2.521h6.314A2.528 2.528 0 0 1 24 15.164a2.528 2.528 0 0 1-2.522 2.521h-6.314z"/></svg>',
8448
+ desc: 'Channels, messages, and threads',
8449
+ idKey: 'slack_client_id', secretKey: 'slack_client_secret',
8450
+ steps: [
8451
+ 'Go to <a href="https://api.slack.com/apps" target="_blank" style="color:var(--accent)">api.slack.com/apps</a> and click <strong>Create New App → From scratch</strong>',
8452
+ 'Name it (e.g. "WikiMem") and select your workspace',
8453
+ 'Go to <strong>OAuth & Permissions</strong> → add <code>channels:history</code>, <code>channels:read</code>, <code>users:read</code> scopes',
8454
+ 'Under <strong>Redirect URLs</strong>, add <code>CALLBACK</code>',
8455
+ 'Go to <strong>Basic Information</strong> → copy <strong>Client ID</strong> and <strong>Client Secret</strong>',
8456
+ 'Paste both values below and click <strong>Save Credentials</strong>',
8457
+ ],
8458
+ },
8459
+ google: {
8460
+ name: 'Google',
8461
+ icon: '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>',
8462
+ desc: 'Gmail messages and Google Drive files',
8463
+ idKey: 'google_client_id', secretKey: 'google_client_secret',
8464
+ steps: [
8465
+ 'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank" style="color:var(--accent)">Google Cloud Console → Credentials</a>',
8466
+ 'Click <strong>Create Credentials → OAuth client ID</strong> (create consent screen first if prompted)',
8467
+ 'Application type: <strong>Web application</strong>',
8468
+ 'Add <code>CALLBACK</code> under <strong>Authorized redirect URIs</strong>',
8469
+ 'Copy the <strong>Client ID</strong> and <strong>Client Secret</strong>',
8470
+ 'Enable <strong>Gmail API</strong> and <strong>Google Drive API</strong> in the API Library',
8471
+ 'Paste both values below and click <strong>Save Credentials</strong>',
8472
+ ],
8473
+ },
8474
+ linear: {
8475
+ name: 'Linear',
8476
+ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="#635BFF"><path d="M2.513 12.833l8.654 8.654a10.478 10.478 0 0 1-8.654-8.654zm-.362-2.584a10.478 10.478 0 0 1 3.388-5.725l10.937 10.937a10.478 10.478 0 0 1-5.725 3.388L2.151 10.249zm4.858-6.707a10.478 10.478 0 0 1 3.624-1.391l10.216 10.216a10.48 10.48 0 0 1-1.391 3.624L7.009 3.542zm5.088-1.735a10.478 10.478 0 0 1 4.37.117l6.571 6.571a10.478 10.478 0 0 1 .117 4.37L12.097 1.807zm5.845.904a10.444 10.444 0 0 1 3.37 2.924l-1.237-1.237.092-.092a10.478 10.478 0 0 0-2.924-2.294l.699.699z"/></svg>',
8477
+ desc: 'Issues, projects, and team activity',
8478
+ idKey: 'linear_client_id', secretKey: 'linear_client_secret',
8479
+ steps: [
8480
+ 'Go to <a href="https://linear.app/settings/api" target="_blank" style="color:var(--accent)">Linear → Settings → API → OAuth Applications</a>',
8481
+ 'Click <strong>Create new</strong>',
8482
+ 'Set <strong>Callback URL</strong> to <code>CALLBACK</code>',
8483
+ 'Copy the <strong>Client ID</strong> and <strong>Client Secret</strong>',
8484
+ 'Paste both values below and click <strong>Save Credentials</strong>',
8485
+ ],
8486
+ },
8487
+ jira: {
8488
+ name: 'Jira',
8489
+ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="#0052CC"><path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.593 24V12.518a1.005 1.005 0 0 0-1.022-1.005zM6.348 6.294H17.87a5.218 5.218 0 0 1-5.231 5.214h-2.13V13.565A5.215 5.215 0 0 1 5.277 8.35V6.294h1.071zM12.798.042H1.276a5.218 5.218 0 0 0 5.232 5.214h2.13V7.313a5.215 5.215 0 0 0 5.231 5.215V1.047A1.005 1.005 0 0 0 12.798.042z"/></svg>',
8490
+ desc: 'Issues, boards, and project tracking',
8491
+ idKey: 'jira_client_id', secretKey: 'jira_client_secret',
8492
+ steps: [
8493
+ 'Go to <a href="https://developer.atlassian.com/console/myapps/" target="_blank" style="color:var(--accent)">Atlassian Developer Console</a>',
8494
+ 'Click <strong>Create → OAuth 2.0 integration</strong>',
8495
+ 'Under <strong>Authorization</strong>, add callback URL: <code>CALLBACK</code>',
8496
+ 'Add scopes: <code>read:jira-work</code>, <code>read:jira-user</code>, <code>offline_access</code>',
8497
+ 'Copy the <strong>Client ID</strong> and <strong>Secret</strong>',
8498
+ 'Paste both values below and click <strong>Save Credentials</strong>',
8499
+ ],
8500
+ },
8501
+ };
8502
+
8503
+ // ── Active device flow polling state ──
8504
+ let _deviceFlowPollTimer = null;
8505
+
8506
+ async function renderOAuthIntegrations() {
8507
+ const container = document.getElementById('oauth-integrations');
8508
+ if (!container) return;
8509
+ const port = location.port || '3456';
8510
+ const callbackUrl = `http://localhost:${port}/api/auth/callback`;
8511
+
8512
+ let tokenStatus = {};
8513
+ let configData = {};
8514
+ try {
8515
+ [tokenStatus, configData] = await Promise.all([
8516
+ fetch('/api/auth/tokens').then(r => r.json()),
8517
+ fetch('/api/config').then(r => r.json()),
8518
+ ]);
8519
+ } catch { container.innerHTML = '<div style="color:var(--red);font-size:13px;padding:16px 0">Failed to load integration status</div>'; return; }
8520
+
8521
+ let cards = '';
8522
+ for (const [provider, meta] of Object.entries(OAUTH_PROVIDER_META)) {
8523
+ const token = tokenStatus[provider];
8524
+ const isConnected = token?.connected;
8525
+ const hasCredentials = !!token?.hasCredentials;
8526
+
8527
+ // Badge (only shown when connected or has credentials)
8528
+ let badgeHtml = '';
8529
+ if (isConnected) badgeHtml = '<span class="oauth-card-badge connected">Connected</span>';
8530
+ else if (hasCredentials) badgeHtml = '<span class="oauth-card-badge ready">Ready</span>';
8531
+
8532
+ // Action area
8533
+ let actionHtml = '';
8534
+ if (isConnected) {
8535
+ const connDate = token.connectedAt ? new Date(token.connectedAt).toLocaleDateString() : '';
8536
+ actionHtml = `
8537
+ <div class="oauth-connected-meta">Connected ${connDate}</div>
8538
+ <div class="oauth-card-actions">
8539
+ <button class="oauth-connect-btn secondary" onclick="syncOAuthProvider('${provider}')">Sync Now</button>
8540
+ <button class="oauth-connect-btn danger" onclick="disconnectOAuth('${provider}')">Disconnect</button>
8541
+ </div>`;
8542
+ } else if (hasCredentials) {
8543
+ actionHtml = `
8544
+ <div class="oauth-card-actions">
8545
+ <button class="oauth-connect-btn primary" onclick="startOAuthConnect('${provider}')">Connect</button>
8546
+ </div>`;
8547
+ } else if (provider === 'github') {
8548
+ // GitHub: one-click Device Flow (no credentials needed)
8549
+ actionHtml = `
8550
+ <div class="oauth-card-actions">
8551
+ <button class="oauth-connect-btn primary" id="gh-device-btn" onclick="startGitHubDeviceFlow()">Connect with GitHub</button>
8552
+ </div>
8553
+ <div id="gh-device-flow-ui"></div>`;
8554
+ } else {
8555
+ // Other providers without credentials: show Connect button (credentials resolved at connect time)
8556
+ actionHtml = `
8557
+ <div class="oauth-card-actions">
8558
+ <button class="oauth-connect-btn primary" onclick="startOAuthConnect('${provider}')" id="oauth-connect-${provider}">Connect</button>
8559
+ </div>`;
8560
+ }
8561
+
8562
+ // Advanced setup (collapsible, for non-connected providers without credentials)
8563
+ let advancedHtml = '';
8564
+ if (!isConnected && !hasCredentials && provider !== 'github') {
8565
+ const stepsHtml = meta.steps.map(s =>
8566
+ `<li>${s.replace(/CALLBACK/g, '<code>' + esc(callbackUrl) + '</code>').replace(/PORT/g, port)}</li>`
8567
+ ).join('');
8568
+ const hasId = !!configData[meta.idKey];
8569
+ const hasSecret = !!configData[meta.secretKey];
8570
+ advancedHtml = `
8571
+ <div class="oauth-advanced-body" id="oauth-adv-${provider}">
8572
+ <ol class="oauth-setup-steps">${stepsHtml}</ol>
8573
+ <div class="oauth-input-row">
8574
+ <input class="settings-input" id="oauth-id-${provider}" type="text" placeholder="Client ID" value="${hasId ? '••••••••' : ''}" ${hasId ? 'data-saved="true"' : ''} onfocus="if(this.dataset.saved){this.value='';this.dataset.saved=''}" />
8575
+ <input class="settings-input" id="oauth-secret-${provider}" type="password" placeholder="Client Secret" value="${hasSecret ? '••••••••' : ''}" ${hasSecret ? 'data-saved="true"' : ''} onfocus="if(this.dataset.saved){this.value='';this.dataset.saved=''}" />
8576
+ </div>
8577
+ <div class="oauth-actions">
8578
+ <button class="settings-btn settings-btn-primary" onclick="saveOAuthCredentials('${provider}')" style="font-size:12px;padding:5px 14px">Save & Connect</button>
8579
+ <span class="settings-status" id="oauth-save-status-${provider}"></span>
8580
+ </div>
8581
+ </div>`;
8582
+ }
8583
+
8584
+ cards += `<div class="oauth-card${isConnected ? ' oauth-connected' : ''}" id="oauth-card-${provider}">
8585
+ <div class="oauth-card-top">
8586
+ <div class="oauth-card-icon ${provider}">${meta.icon}</div>
8587
+ <div class="oauth-card-info">
8588
+ <div class="oauth-card-name">${meta.name}</div>
8589
+ <div class="oauth-card-desc">${meta.desc}</div>
8590
+ </div>
8591
+ ${badgeHtml}
8592
+ </div>
8593
+ ${actionHtml}
8594
+ ${advancedHtml}
8595
+ </div>`;
8596
+ }
8597
+ container.innerHTML = `<div class="oauth-grid">${cards}</div>`;
8598
+ }
8599
+
8600
+ function toggleAdvancedSetup(provider) {
8601
+ const body = document.getElementById('oauth-adv-' + provider);
8602
+ if (!body) return;
8603
+ body.classList.toggle('open');
8604
+ }
8605
+
8606
+ // ── GitHub Device Flow ──
8607
+ async function startGitHubDeviceFlow() {
8608
+ const btn = document.getElementById('gh-device-btn');
8609
+ const ui = document.getElementById('gh-device-flow-ui');
8610
+ if (!ui) return;
8611
+ if (btn) { btn.disabled = true; btn.textContent = 'Starting...'; }
8612
+
8613
+ try {
8614
+ const res = await fetch('/api/auth/device-flow/start', { method: 'POST' });
8615
+ const data = await res.json();
8616
+
8617
+ if (data.error === 'no_client_id') {
8618
+ // No device client ID configured — fall back to credential setup
8619
+ if (btn) { btn.disabled = false; btn.textContent = 'Connect with GitHub'; }
8620
+ ui.innerHTML = `<div class="oauth-info-tip" style="margin-top:4px">
8621
+ Device flow requires a GitHub OAuth App client ID.<br/>
8622
+ Set <code style="font-size:10px;color:var(--accent)">WIKIMEM_GITHUB_CLIENT_ID</code> env var, or <a href="https://github.com/settings/developers" target="_blank">create an OAuth App</a> and enter credentials below.
8623
+ </div>
8624
+ <div class="oauth-advanced-body open" style="padding-top:8px">
8625
+ <div class="oauth-input-row">
8626
+ <input class="settings-input" id="oauth-id-github" type="text" placeholder="Client ID" />
8627
+ <input class="settings-input" id="oauth-secret-github" type="password" placeholder="Client Secret" />
8628
+ </div>
8629
+ <div class="oauth-actions">
8630
+ <button class="settings-btn settings-btn-primary" onclick="saveOAuthCredentials('github')" style="font-size:12px;padding:5px 14px">Save & Connect</button>
8631
+ <span class="settings-status" id="oauth-save-status-github"></span>
8632
+ </div>
8633
+ </div>`;
8634
+ return;
8635
+ }
8636
+
8637
+ if (data.error) {
8638
+ if (btn) { btn.disabled = false; btn.textContent = 'Connect with GitHub'; }
8639
+ showToast('Device flow error: ' + (data.message || data.error));
8640
+ return;
8641
+ }
8642
+
8643
+ // Show the device code UI
8644
+ if (btn) btn.style.display = 'none';
8645
+ ui.innerHTML = `<div class="device-flow-box">
8646
+ <div class="device-flow-steps">
8647
+ 1. Go to <a href="${data.verification_uri}" target="_blank">${data.verification_uri}</a><br/>
8648
+ 2. Enter this code:
8649
+ </div>
8650
+ <div class="device-flow-code">${data.user_code}</div>
8651
+ <div style="margin-top:4px">
8652
+ <button class="oauth-connect-btn secondary" style="flex:0;padding:5px 14px;font-size:11px" onclick="navigator.clipboard.writeText('${data.user_code}');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy Code',1500)">Copy Code</button>
8653
+ </div>
8654
+ <div class="device-flow-status" id="gh-device-status">
8655
+ <span class="device-flow-spinner"></span>
8656
+ Waiting for authorization...
8657
+ </div>
8658
+ </div>`;
8659
+
8660
+ // Start polling
8661
+ pollGitHubDeviceFlow(data.device_code, data.interval || 5);
8662
+ } catch (err) {
8663
+ if (btn) { btn.disabled = false; btn.textContent = 'Connect with GitHub'; }
8664
+ showToast('Device flow failed: ' + (err.message || 'unknown'));
8665
+ }
8666
+ }
8667
+
8668
+ function pollGitHubDeviceFlow(deviceCode, interval) {
8669
+ if (_deviceFlowPollTimer) clearInterval(_deviceFlowPollTimer);
8670
+ const pollMs = Math.max((interval || 5) * 1000, 5000);
8671
+
8672
+ _deviceFlowPollTimer = setInterval(async () => {
8673
+ try {
8674
+ const res = await fetch('/api/auth/device-flow/poll', {
8675
+ method: 'POST',
8676
+ headers: { 'Content-Type': 'application/json' },
8677
+ body: JSON.stringify({ device_code: deviceCode }),
8678
+ });
8679
+ const data = await res.json();
8680
+ const statusEl = document.getElementById('gh-device-status');
8681
+
8682
+ if (data.status === 'complete') {
8683
+ clearInterval(_deviceFlowPollTimer);
8684
+ _deviceFlowPollTimer = null;
8685
+ if (statusEl) statusEl.innerHTML = '<span style="color:var(--green);font-weight:600">Connected! Syncing...</span>';
8686
+ showToast('GitHub connected — syncing data...');
8687
+ setTimeout(() => renderOAuthIntegrations(), 1000);
8688
+ loadOAuthStatus();
8689
+ syncOAuthProvider('github');
8690
+ } else if (data.status === 'expired') {
8691
+ clearInterval(_deviceFlowPollTimer);
8692
+ _deviceFlowPollTimer = null;
8693
+ if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">Code expired. </span><a href="#" onclick="event.preventDefault();startGitHubDeviceFlow()" style="color:var(--accent)">Try again</a>';
8694
+ } else if (data.status === 'slow_down') {
8695
+ // GitHub asked us to slow down — we'll just wait for next interval
8696
+ }
8697
+ // 'pending' — keep polling
8698
+ } catch {
8699
+ // Network error — keep trying
8700
+ }
8701
+ }, pollMs);
8702
+ }
8703
+
8704
+ async function saveOAuthCredentials(provider) {
8705
+ const meta = OAUTH_PROVIDER_META[provider];
8706
+ if (!meta) return;
8707
+ const idInput = document.getElementById('oauth-id-' + provider);
8708
+ const secretInput = document.getElementById('oauth-secret-' + provider);
8709
+ const statusEl = document.getElementById('oauth-save-status-' + provider);
8710
+ if (!idInput || !secretInput) return;
8711
+
8712
+ const clientId = idInput.value.trim();
8713
+ const clientSecret = secretInput.value.trim();
8714
+
8715
+ if (idInput.dataset.saved && !clientId) { showToast('Client ID is empty — clear field and enter new value'); return; }
8716
+ if (secretInput.dataset.saved && !clientSecret) { showToast('Client Secret is empty — clear field and enter new value'); return; }
8717
+
8718
+ const updates = {};
8719
+ if (clientId && clientId !== '••••••••') updates[meta.idKey] = clientId;
8720
+ if (clientSecret && clientSecret !== '••••••••') updates[meta.secretKey] = clientSecret;
8721
+
8722
+ if (Object.keys(updates).length === 0) { showToast('Enter Client ID and Secret first'); return; }
8723
+
8724
+ if (statusEl) { statusEl.textContent = 'Saving…'; statusEl.style.color = 'var(--text-muted)'; }
8725
+
8726
+ try {
8727
+ const res = await fetch('/api/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) });
8728
+ const data = await res.json();
8729
+ if (data.error) throw new Error(data.error);
8730
+ if (statusEl) { statusEl.textContent = 'Saved'; statusEl.style.color = 'var(--green)'; }
8731
+ showToast(`${meta.name} credentials saved`);
8732
+ setTimeout(() => renderOAuthIntegrations(), 800);
8733
+ } catch (err) {
8734
+ if (statusEl) { statusEl.textContent = 'Failed: ' + (err.message || 'unknown'); statusEl.style.color = 'var(--red)'; }
8735
+ }
8736
+ }
8737
+
8738
+ async function disconnectOAuth(provider) {
8739
+ const meta = OAUTH_PROVIDER_META[provider];
8740
+ try {
8741
+ await fetch('/api/auth/tokens/' + encodeURIComponent(provider), { method: 'DELETE' });
8742
+ showToast(`${meta?.name || provider} disconnected`);
8743
+ renderOAuthIntegrations();
8744
+ } catch (err) { showToast('Disconnect failed: ' + (err.message || 'unknown')); }
8745
+ }
8746
+
8747
+ async function syncOAuthProvider(provider) {
8748
+ const meta = OAUTH_PROVIDER_META[provider];
8749
+ showToast(`Syncing ${meta?.name || provider}…`);
8750
+ try {
8751
+ const res = await fetch('/api/sync/' + encodeURIComponent(provider), { method: 'POST' });
8752
+ const data = await res.json();
8753
+ if (data.error || data.errors?.length) {
8754
+ showToast(`Sync error: ${data.errors?.[0] || data.error}`);
8755
+ } else {
8756
+ showToast(`✓ ${meta?.name || provider}: ${data.filesWritten || 0} files synced in ${((data.duration || 0) / 1000).toFixed(1)}s`);
8757
+ loadTree();
8758
+ }
8759
+ } catch (err) { showToast('Sync failed: ' + (err.message || 'unknown')); }
8760
+ }
8761
+
7305
8762
  function showStageDetail(stepId) {
7306
8763
  const step = PIPELINE_STEPS.find(s => s.id === stepId);
7307
8764
  const event = currentPipelineSteps[stepId];
@@ -7437,6 +8894,9 @@
7437
8894
  _tlGraphLastHash: null,
7438
8895
  _tlSliderRaf: null,
7439
8896
  _tlSliderPendingIdx: null,
8897
+ _tlPlayRaf: null,
8898
+ _tlDragging: false,
8899
+ _tlRenderDebounce: null,
7440
8900
  };
7441
8901
 
7442
8902
  function railTimelapseClick() {
@@ -7560,10 +9020,13 @@
7560
9020
  const commit = tlState.commits[idx];
7561
9021
  if(!commit) return;
7562
9022
 
7563
- const slider = document.getElementById('tl-slider');
7564
- slider.value = String(idx);
7565
- const max = parseInt(slider.max) || 1;
7566
- slider.style.setProperty('--slider-pct', `${(idx / max) * 100}%`);
9023
+ // Don't overwrite slider position while user is actively dragging
9024
+ if(!tlState._tlDragging) {
9025
+ const slider = document.getElementById('tl-slider');
9026
+ slider.value = String(idx);
9027
+ const max = parseInt(slider.max) || 1;
9028
+ slider.style.setProperty('--slider-pct', `${(idx / max) * 100}%`);
9029
+ }
7567
9030
 
7568
9031
  const date = new Date(commit.date);
7569
9032
  const dateStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
@@ -7934,7 +9397,8 @@
7934
9397
  const cs = k => getComputedStyle(document.documentElement).getPropertyValue(k).trim();
7935
9398
  const COLORS = ['#4f9eff','#4ec9b0','#d7ba7d','#c586c0','#9cdcfe','#dcdcaa','#ce9178','#608b4e','#b5cea8'];
7936
9399
  const cColor = c => COLORS[(c||0) % COLORS.length];
7937
- const isHub = d => (d.linksIn||0) >= 3;
9400
+ const totalNodes = data.nodes.length;
9401
+ const isHub = d => (d.linksIn||0) >= Math.max(5, Math.ceil(totalNodes * 0.06));
7938
9402
  const nR = d => Math.max(4, Math.min(18, 4 + Math.sqrt(d.linksIn||0) * 3));
7939
9403
  const linkKey = d => `${d.source?.id??d.source}\u2192${d.target?.id??d.target}`;
7940
9404
 
@@ -7970,11 +9434,8 @@
7970
9434
  svg.call(d3.zoom().scaleExtent([0.15,5]).on('zoom', e => g.attr('transform', e.transform)));
7971
9435
  }
7972
9436
 
7973
- if(crossfade && tlState._tlGraphLastHash) {
7974
- g.interrupt().style('opacity', 0.48);
7975
- } else {
7976
- g.style('opacity', 1);
7977
- }
9437
+ // No opacity flash — let D3 data joins handle smooth transitions
9438
+ g.style('opacity', 1);
7978
9439
 
7979
9440
  let tip = container.querySelector('.tl-tip');
7980
9441
  if(!tip) { tip = document.createElement('div'); tip.className = 'tl-tip'; container.appendChild(tip); }
@@ -8008,7 +9469,7 @@
8008
9469
  sim.force('charge', d3.forceManyBody().strength(-220));
8009
9470
  sim.force('center', d3.forceCenter(width/2, height/2));
8010
9471
  sim.force('collision', d3.forceCollide().radius(d => isHub(d)?26:18));
8011
- sim.alpha(0.3).restart();
9472
+ sim.alpha(tlState.playing ? 0.15 : 0.3).restart();
8012
9473
  } else {
8013
9474
  if(tlState._tlGraphSim) {
8014
9475
  tlState._tlGraphSim.stop();
@@ -8108,9 +9569,6 @@
8108
9569
  });
8109
9570
 
8110
9571
  tlState._tlGraphLastHash = commit.hash;
8111
- if(crossfade) {
8112
- g.transition().duration(280).style('opacity', 1);
8113
- }
8114
9572
 
8115
9573
  if(typeof idx === 'number') {
8116
9574
  const lo = Math.max(0,idx-5), hi = Math.min(tlState.commits.length-1,idx+5);
@@ -8129,14 +9587,47 @@
8129
9587
  document.getElementById('tl-play').textContent = '⏸';
8130
9588
  document.getElementById('tl-play').classList.add('playing');
8131
9589
  const speed = parseInt(document.getElementById('tl-speed').value) || 2000;
8132
- tlState.interval = setInterval(() => {
8133
- if(tlState.currentIdx >= tlState.commits.length - 1) { tlPause(); return; }
8134
- renderTimelapseAt(tlState.currentIdx + 1, { crossfade: true });
8135
- }, speed);
9590
+ const slider = document.getElementById('tl-slider');
9591
+ const max = tlState.commits.length - 1;
9592
+ if(max <= 0) { tlPause(); return; }
9593
+
9594
+ let lastTime = null;
9595
+ let fractionalIdx = tlState.currentIdx;
9596
+
9597
+ function animate(ts) {
9598
+ if(!tlState.playing || tlState._tlDragging) return;
9599
+ if(lastTime === null) { lastTime = ts; tlState._tlPlayRaf = requestAnimationFrame(animate); return; }
9600
+ const dt = ts - lastTime;
9601
+ lastTime = ts;
9602
+
9603
+ // Advance fractional index smoothly based on speed setting
9604
+ // speed = ms per commit, so rate = 1/speed commits per ms
9605
+ fractionalIdx += dt / speed;
9606
+
9607
+ // Smoothly update slider position (continuous, not integer-locked)
9608
+ const pct = Math.min(fractionalIdx / max, 1) * 100;
9609
+ slider.style.setProperty('--slider-pct', `${pct}%`);
9610
+ slider.value = String(Math.min(Math.round(fractionalIdx), max));
9611
+
9612
+ // Only render at integer commit boundaries
9613
+ const targetIdx = Math.floor(fractionalIdx);
9614
+ if(targetIdx > tlState.currentIdx && targetIdx <= max) {
9615
+ renderTimelapseAt(targetIdx, { crossfade: false });
9616
+ }
9617
+
9618
+ if(fractionalIdx >= max) {
9619
+ renderTimelapseAt(max, { crossfade: false });
9620
+ tlPause();
9621
+ return;
9622
+ }
9623
+ tlState._tlPlayRaf = requestAnimationFrame(animate);
9624
+ }
9625
+ tlState._tlPlayRaf = requestAnimationFrame(animate);
8136
9626
  }
8137
9627
 
8138
9628
  function tlPause() {
8139
9629
  tlState.playing = false;
9630
+ if(tlState._tlPlayRaf) { cancelAnimationFrame(tlState._tlPlayRaf); tlState._tlPlayRaf = null; }
8140
9631
  clearInterval(tlState.interval);
8141
9632
  document.getElementById('tl-play').textContent = '▶';
8142
9633
  document.getElementById('tl-play').classList.remove('playing');
@@ -8186,6 +9677,11 @@
8186
9677
  if(e.target === e.currentTarget) closePRModal();
8187
9678
  });
8188
9679
 
9680
+ const cxClose = document.getElementById('cx-modal-close');
9681
+ if (cxClose) cxClose.addEventListener('click', closeCxModal);
9682
+ const cxOv = document.getElementById('cx-modal-overlay');
9683
+ if (cxOv) cxOv.addEventListener('click', (e) => { if (e.target === e.currentTarget) closeCxModal(); });
9684
+
8189
9685
  // Settings nav
8190
9686
  document.querySelectorAll('.settings-nav-item').forEach(el => {
8191
9687
  el.addEventListener('click', () => renderSettingsSection(el.dataset.section));
@@ -8218,32 +9714,72 @@
8218
9714
  alert('Failed to create page');
8219
9715
  }
8220
9716
  });
9717
+ document.getElementById('sa-new-folder').addEventListener('click', () => createSubfolder('', 'wiki'));
8221
9718
  document.getElementById('sa-collapse').addEventListener('click', collapseAllFolders);
8222
9719
  document.getElementById('sa-refresh').addEventListener('click', () => { loadTree(); loadHome(); });
8223
9720
  document.getElementById('git-status-badge').addEventListener('click', toggleGitDropdown);
8224
9721
 
9722
+ // Drag detection: pointerdown sets dragging flag and pauses playback immediately
9723
+ document.getElementById('tl-slider').addEventListener('pointerdown', () => {
9724
+ tlState._tlDragging = true;
9725
+ if(tlState.playing) tlPause();
9726
+ });
9727
+ // pointerup clears dragging flag (also on document in case pointer leaves slider)
9728
+ function tlSliderPointerUp() {
9729
+ if(!tlState._tlDragging) return;
9730
+ tlState._tlDragging = false;
9731
+ // Flush any pending debounced render at the final position
9732
+ if(tlState._tlRenderDebounce != null) {
9733
+ clearTimeout(tlState._tlRenderDebounce);
9734
+ tlState._tlRenderDebounce = null;
9735
+ }
9736
+ if(tlState._tlSliderRaf != null) {
9737
+ cancelAnimationFrame(tlState._tlSliderRaf);
9738
+ tlState._tlSliderRaf = null;
9739
+ }
9740
+ renderTimelapseAt(tlState._tlSliderPendingIdx != null ? tlState._tlSliderPendingIdx : tlState.currentIdx, { crossfade: true });
9741
+ }
9742
+ document.getElementById('tl-slider').addEventListener('pointerup', tlSliderPointerUp);
9743
+ document.addEventListener('pointerup', tlSliderPointerUp);
9744
+
9745
+ // input: fires continuously during drag — keep slider visual responsive, debounce heavy render
8225
9746
  document.getElementById('tl-slider').addEventListener('input', e => {
8226
9747
  const val = parseInt(e.target.value, 10);
8227
9748
  const max = parseInt(e.target.max, 10) || 1;
9749
+ // Always update the slider track fill immediately (cheap CSS-only)
8228
9750
  e.target.style.setProperty('--slider-pct', `${(val / max) * 100}%`);
8229
9751
  tlState._tlSliderPendingIdx = val;
8230
- if(tlState._tlSliderRaf != null) return;
8231
- tlState._tlSliderRaf = requestAnimationFrame(() => {
8232
- tlState._tlSliderRaf = null;
8233
- renderTimelapseAt(tlState._tlSliderPendingIdx);
8234
- });
9752
+
9753
+ // Update commit info label immediately (lightweight DOM update)
9754
+ const commit = tlState.commits[val];
9755
+ if(commit) {
9756
+ document.getElementById('tl-label-current').textContent = `${val + 1} / ${tlState.commits.length}`;
9757
+ }
9758
+
9759
+ // Debounce the expensive tree/graph render (80ms) to avoid blocking the UI thread
9760
+ if(tlState._tlRenderDebounce != null) clearTimeout(tlState._tlRenderDebounce);
9761
+ tlState._tlRenderDebounce = setTimeout(() => {
9762
+ tlState._tlRenderDebounce = null;
9763
+ renderTimelapseAt(val, { crossfade: false });
9764
+ }, 80);
8235
9765
  });
9766
+
9767
+ // change: fires once on slider release (mouse up / touch end) — final authoritative render
8236
9768
  document.getElementById('tl-slider').addEventListener('change', e => {
8237
9769
  const val = parseInt(e.target.value, 10);
9770
+ if(tlState._tlRenderDebounce != null) {
9771
+ clearTimeout(tlState._tlRenderDebounce);
9772
+ tlState._tlRenderDebounce = null;
9773
+ }
8238
9774
  if(tlState._tlSliderRaf != null) {
8239
9775
  cancelAnimationFrame(tlState._tlSliderRaf);
8240
9776
  tlState._tlSliderRaf = null;
8241
9777
  }
8242
- renderTimelapseAt(val);
9778
+ renderTimelapseAt(val, { crossfade: true });
8243
9779
  });
8244
9780
  document.getElementById('tl-play').addEventListener('click', tlPlay);
8245
- document.getElementById('tl-prev').addEventListener('click', () => { if(tlState.currentIdx > 0) renderTimelapseAt(tlState.currentIdx - 1); });
8246
- document.getElementById('tl-next').addEventListener('click', () => { if(tlState.currentIdx < tlState.commits.length - 1) renderTimelapseAt(tlState.currentIdx + 1); });
9781
+ document.getElementById('tl-prev').addEventListener('click', () => { if(tlState.currentIdx > 0) renderTimelapseAt(tlState.currentIdx - 1, { crossfade: true }); });
9782
+ document.getElementById('tl-next').addEventListener('click', () => { if(tlState.currentIdx < tlState.commits.length - 1) renderTimelapseAt(tlState.currentIdx + 1, { crossfade: true }); });
8247
9783
  document.getElementById('tl-speed').addEventListener('change', () => { if(tlState.playing) { tlPause(); tlPlay(); } });
8248
9784
  document.getElementById('tl-mode-tree').addEventListener('click', () => {
8249
9785
  tlState.mode = 'tree';
@@ -8320,14 +9856,18 @@
8320
9856
  setTimeout(positionSelToolbar, 10);
8321
9857
  });
8322
9858
  document.addEventListener('mousedown', (e) => {
8323
- if (!_selToolbar.contains(e.target)) hideSelToolbar();
9859
+ if (!_selToolbar.contains(e.target) && !_selPreventHide) hideSelToolbar();
8324
9860
  });
8325
9861
 
8326
- _selToolbar.addEventListener('click', (e) => {
9862
+ let _selPreventHide = false;
9863
+ _selToolbar.addEventListener('mousedown', (e) => {
9864
+ e.preventDefault();
9865
+ _selPreventHide = true;
8327
9866
  const btn = e.target.closest('.st-btn');
8328
9867
  if (!btn) return;
8329
- e.preventDefault();
8330
9868
  const cmd = btn.dataset.cmd;
9869
+ const body = document.getElementById('page-body');
9870
+ body.focus();
8331
9871
  if (cmd === 'bold') {
8332
9872
  document.execCommand('bold');
8333
9873
  } else if (cmd === 'italic') {
@@ -8335,23 +9875,165 @@
8335
9875
  } else if (cmd === 'code') {
8336
9876
  const sel = window.getSelection();
8337
9877
  if (sel && sel.rangeCount) {
9878
+ let node = sel.anchorNode;
9879
+ while (node && node !== body) {
9880
+ if (node.nodeType === 1 && node.tagName === 'CODE') {
9881
+ const text = document.createTextNode(node.textContent);
9882
+ node.parentNode.replaceChild(text, node);
9883
+ const r = document.createRange(); r.selectNodeContents(text);
9884
+ sel.removeAllRanges(); sel.addRange(r);
9885
+ markDirty(); setTimeout(positionSelToolbar, 10);
9886
+ setTimeout(() => { _selPreventHide = false; }, 100);
9887
+ return;
9888
+ }
9889
+ node = node.parentNode;
9890
+ }
8338
9891
  const range = sel.getRangeAt(0);
8339
9892
  const code = document.createElement('code');
8340
- code.appendChild(range.extractContents());
8341
- range.insertNode(code);
8342
- sel.collapseToEnd();
9893
+ try { range.surroundContents(code); } catch { code.appendChild(range.extractContents()); range.insertNode(code); }
9894
+ const nr = document.createRange(); nr.selectNodeContents(code);
9895
+ sel.removeAllRanges(); sel.addRange(nr);
8343
9896
  }
8344
9897
  } else if (cmd === 'link') {
8345
9898
  const sel = window.getSelection();
8346
9899
  if (!sel || sel.isCollapsed) return;
9900
+ let linkNode = sel.anchorNode;
9901
+ while (linkNode && linkNode !== body) {
9902
+ if (linkNode.nodeType === 1 && linkNode.tagName === 'A') {
9903
+ document.execCommand('unlink'); markDirty();
9904
+ setTimeout(() => { _selPreventHide = false; }, 100);
9905
+ return;
9906
+ }
9907
+ linkNode = linkNode.parentNode;
9908
+ }
8347
9909
  const url = prompt('Enter URL:');
8348
- if (!url) return;
9910
+ if (!url) { setTimeout(() => { _selPreventHide = false; }, 100); return; }
8349
9911
  document.execCommand('createLink', false, url);
8350
9912
  } else if (cmd === 'h2' || cmd === 'h3') {
8351
9913
  document.execCommand('formatBlock', false, cmd);
8352
9914
  }
8353
9915
  markDirty();
8354
9916
  setTimeout(positionSelToolbar, 10);
9917
+ setTimeout(() => { _selPreventHide = false; }, 100);
9918
+ });
9919
+
9920
+ // ── @Mention Autocomplete (ENT-002) ──
9921
+ const _mentionPopup = document.getElementById('mention-popup');
9922
+ let _mentionActive = false, _mentionQuery = '', _mentionItems = [], _mentionIdx = 0;
9923
+
9924
+ function getCaretCoords() {
9925
+ const sel = window.getSelection();
9926
+ if (!sel || !sel.rangeCount) return null;
9927
+ const range = sel.getRangeAt(0).cloneRange();
9928
+ range.collapse(true);
9929
+ const rect = range.getBoundingClientRect();
9930
+ return { left: rect.left, top: rect.bottom + 4 };
9931
+ }
9932
+
9933
+ function showMentionPopup(query) {
9934
+ if (!state.allPages || !state.allPages.length) { hideMentionPopup(); return; }
9935
+ const q = query.toLowerCase();
9936
+ _mentionItems = state.allPages
9937
+ .filter(p => p.title.toLowerCase().includes(q) || (p.slug || '').toLowerCase().includes(q))
9938
+ .slice(0, 8);
9939
+ if (!_mentionItems.length) { hideMentionPopup(); return; }
9940
+ _mentionIdx = 0;
9941
+ _mentionPopup.innerHTML = _mentionItems.map((p, i) => {
9942
+ const typeLabel = (p.frontmatter?.type || 'page').toLowerCase();
9943
+ return `<div class="mention-item${i === 0 ? ' active' : ''}" data-idx="${i}">
9944
+ <span class="mention-item-type">${esc(typeLabel)}</span>
9945
+ <span>${esc(p.title)}</span>
9946
+ </div>`;
9947
+ }).join('');
9948
+ const coords = getCaretCoords();
9949
+ if (coords) {
9950
+ _mentionPopup.style.left = Math.min(coords.left, window.innerWidth - 330) + 'px';
9951
+ _mentionPopup.style.top = coords.top + 'px';
9952
+ }
9953
+ _mentionPopup.classList.add('visible');
9954
+ _mentionActive = true;
9955
+ _mentionPopup.querySelectorAll('.mention-item').forEach(el => {
9956
+ el.addEventListener('mousedown', (e) => {
9957
+ e.preventDefault();
9958
+ insertMention(_mentionItems[+el.dataset.idx]);
9959
+ });
9960
+ });
9961
+ }
9962
+
9963
+ function hideMentionPopup() {
9964
+ _mentionPopup.classList.remove('visible');
9965
+ _mentionPopup.innerHTML = '';
9966
+ _mentionActive = false;
9967
+ _mentionQuery = '';
9968
+ _mentionItems = [];
9969
+ }
9970
+
9971
+ function insertMention(page) {
9972
+ if (!page) return;
9973
+ const sel = window.getSelection();
9974
+ if (!sel || !sel.rangeCount) return;
9975
+ const range = sel.getRangeAt(0);
9976
+ const textNode = range.startContainer;
9977
+ if (textNode.nodeType !== Node.TEXT_NODE) { hideMentionPopup(); return; }
9978
+ const text = textNode.textContent;
9979
+ const caretPos = range.startOffset;
9980
+ const atIdx = text.lastIndexOf('@', caretPos - 1);
9981
+ if (atIdx === -1) { hideMentionPopup(); return; }
9982
+ const before = text.substring(0, atIdx);
9983
+ const after = text.substring(caretPos);
9984
+ textNode.textContent = before + after;
9985
+ const link = document.createElement('a');
9986
+ link.className = 'wikilink';
9987
+ link.href = '#';
9988
+ link.dataset.wikilink = page.title;
9989
+ link.textContent = page.title;
9990
+ link.onclick = (e) => { e.preventDefault(); if (!state.editing) openPage(page.title); };
9991
+ const afterNode = textNode.splitText(before.length);
9992
+ textNode.parentNode.insertBefore(link, afterNode);
9993
+ const space = document.createTextNode(' ');
9994
+ link.parentNode.insertBefore(space, afterNode);
9995
+ const r = document.createRange();
9996
+ r.setStartAfter(space);
9997
+ r.collapse(true);
9998
+ sel.removeAllRanges();
9999
+ sel.addRange(r);
10000
+ hideMentionPopup();
10001
+ markDirty();
10002
+ }
10003
+
10004
+ document.getElementById('page-body').addEventListener('input', () => {
10005
+ if (!state.editing) return;
10006
+ const sel = window.getSelection();
10007
+ if (!sel || !sel.rangeCount) { hideMentionPopup(); return; }
10008
+ const range = sel.getRangeAt(0);
10009
+ const textNode = range.startContainer;
10010
+ if (textNode.nodeType !== Node.TEXT_NODE) { hideMentionPopup(); return; }
10011
+ const text = textNode.textContent.substring(0, range.startOffset);
10012
+ const atMatch = text.match(/@([a-zA-Z0-9 _-]{0,30})$/);
10013
+ if (atMatch) {
10014
+ _mentionQuery = atMatch[1];
10015
+ showMentionPopup(_mentionQuery);
10016
+ } else {
10017
+ hideMentionPopup();
10018
+ }
10019
+ });
10020
+
10021
+ document.getElementById('page-body').addEventListener('keydown', (e) => {
10022
+ if (!_mentionActive) return;
10023
+ if (e.key === 'ArrowDown') {
10024
+ e.preventDefault();
10025
+ _mentionIdx = Math.min(_mentionIdx + 1, _mentionItems.length - 1);
10026
+ _mentionPopup.querySelectorAll('.mention-item').forEach((el, i) => el.classList.toggle('active', i === _mentionIdx));
10027
+ } else if (e.key === 'ArrowUp') {
10028
+ e.preventDefault();
10029
+ _mentionIdx = Math.max(_mentionIdx - 1, 0);
10030
+ _mentionPopup.querySelectorAll('.mention-item').forEach((el, i) => el.classList.toggle('active', i === _mentionIdx));
10031
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
10032
+ if (_mentionItems[_mentionIdx]) { e.preventDefault(); insertMention(_mentionItems[_mentionIdx]); }
10033
+ } else if (e.key === 'Escape') {
10034
+ e.preventDefault();
10035
+ hideMentionPopup();
10036
+ }
8355
10037
  });
8356
10038
  </script>
8357
10039
  </body>