memorix 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,7 @@ const i18n = {
31
31
  nodes: 'nodes',
32
32
  edges: 'edges',
33
33
  clickNodeToView: 'Click a node to view details',
34
+ legend: 'Legend',
34
35
  noObservations: 'No observations',
35
36
  noRelations: 'No relations',
36
37
 
@@ -45,12 +46,21 @@ const i18n = {
45
46
  exportData: 'Export',
46
47
  deleteObs: 'Delete',
47
48
  deleteConfirm: 'Delete observation #%id%?',
49
+ batchCleanup: 'Cleanup',
50
+ selected: 'selected',
51
+ cancel: 'Cancel',
52
+ deleteSelected: 'Delete Selected',
53
+ batchDeleteConfirm: 'Delete %count% observations?',
48
54
  deleted: 'Deleted',
49
55
  narrative: 'Narrative',
50
56
  facts: 'Facts',
51
57
  concepts: 'Concepts',
52
58
  files: 'Files Modified',
53
59
  clickToExpand: 'Click to expand',
60
+ vectorSearch: 'Vector Search',
61
+ fulltextOnly: 'Fulltext Only',
62
+ enabled: 'Enabled',
63
+ typeDistribution: 'Type Distribution',
54
64
 
55
65
  // Retention
56
66
  memoryRetention: 'Memory Retention',
@@ -100,6 +110,7 @@ const i18n = {
100
110
  nodes: '个节点',
101
111
  edges: '条边',
102
112
  clickNodeToView: '点击节点查看详情',
113
+ legend: '图例',
103
114
  noObservations: '暂无观察',
104
115
  noRelations: '暂无关系',
105
116
 
@@ -114,12 +125,21 @@ const i18n = {
114
125
  exportData: '导出',
115
126
  deleteObs: '删除',
116
127
  deleteConfirm: '确认删除观察 #%id%?',
128
+ batchCleanup: '清理',
129
+ selected: '已选中',
130
+ cancel: '取消',
131
+ deleteSelected: '删除选中',
132
+ batchDeleteConfirm: '确认删除 %count% 条观察?',
117
133
  deleted: '已删除',
118
134
  narrative: '叙述',
119
135
  facts: '事实',
120
136
  concepts: '概念',
121
137
  files: '相关文件',
122
138
  clickToExpand: '点击展开',
139
+ vectorSearch: '向量搜索',
140
+ fulltextOnly: '仅全文搜索',
141
+ enabled: '已启用',
142
+ typeDistribution: '类型分布',
123
143
 
124
144
  // Retention
125
145
  memoryRetention: '记忆衰减',
@@ -386,6 +406,11 @@ async function loadDashboard() {
386
406
  <div class="stat-label">${t('nextId')}</div>
387
407
  <div class="stat-value">#${stats.nextId}</div>
388
408
  </div>
409
+ <div class="stat-card" data-accent="${stats.embedding?.enabled ? 'cyan' : 'amber'}">
410
+ <div class="stat-label">${t('vectorSearch')}</div>
411
+ <div class="stat-value" style="font-size: 18px;">${stats.embedding?.enabled ? '✓ ' + t('enabled') : t('fulltextOnly')}</div>
412
+ ${stats.embedding?.provider ? `<div style="font-size: 10px; color: var(--text-muted); margin-top: 4px; font-family: var(--font-mono);">${stats.embedding.provider} (${stats.embedding.dimensions}d)</div>` : ''}
413
+ </div>
389
414
  </div>
390
415
 
391
416
  <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
@@ -394,16 +419,23 @@ async function loadDashboard() {
394
419
  <span class="panel-title">${t('observationTypes')}</span>
395
420
  </div>
396
421
  <div class="panel-body">
397
- ${typeEntries.length > 0 ? typeEntries.map(([type, count]) => `
398
- <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
399
- <span style="width: 20px; text-align: center;">${typeIcons[type] || '❓'}</span>
400
- <span style="width: 120px; font-size: 12px; color: var(--text-secondary);">${type}</span>
401
- <div style="flex: 1; height: 6px; background: rgba(255,255,255,0.04); border-radius: 3px; overflow: hidden;">
402
- <div style="width: ${(count / maxTypeCount) * 100}%; height: 100%; background: var(--type-${type}, var(--accent-cyan)); border-radius: 3px;"></div>
422
+ ${typeEntries.length > 0 ? `
423
+ <div style="display: flex; gap: 20px; align-items: flex-start;">
424
+ <canvas id="type-pie-chart" width="140" height="140" style="flex-shrink: 0;"></canvas>
425
+ <div style="flex: 1;">
426
+ ${typeEntries.map(([type, count]) => `
427
+ <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
428
+ <span style="width: 18px; text-align: center; font-size: 13px;">${typeIcons[type] || '❓'}</span>
429
+ <span style="width: 110px; font-size: 11px; color: var(--text-secondary);">${type}</span>
430
+ <div style="flex: 1; height: 5px; background: rgba(128,128,128,0.1); border-radius: 3px; overflow: hidden;">
431
+ <div style="width: ${(count / maxTypeCount) * 100}%; height: 100%; background: var(--type-${type}, var(--accent-cyan)); border-radius: 3px;"></div>
432
+ </div>
433
+ <span style="font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); min-width: 22px; text-align: right;">${count}</span>
434
+ </div>
435
+ `).join('')}
403
436
  </div>
404
- <span style="font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); min-width: 24px; text-align: right;">${count}</span>
405
437
  </div>
406
- `).join('') : `<p style="color: var(--text-muted); font-size: 13px;">${t('noObservationsYet')}</p>`}
438
+ ` : `<p style="color: var(--text-muted); font-size: 13px;">${t('noObservationsYet')}</p>`}
407
439
  </div>
408
440
  </div>
409
441
 
@@ -430,6 +462,55 @@ async function loadDashboard() {
430
462
  </div>
431
463
  </div>
432
464
  `;
465
+
466
+ // Render pie chart if data exists
467
+ if (typeEntries.length > 0) {
468
+ requestAnimationFrame(() => renderPieChart('type-pie-chart', typeEntries, typeIcons));
469
+ }
470
+ }
471
+
472
+ /** Draw a mini donut chart on a canvas */
473
+ function renderPieChart(canvasId, entries, icons) {
474
+ const canvas = document.getElementById(canvasId);
475
+ if (!canvas) return;
476
+ const ctx = canvas.getContext('2d');
477
+ const dpr = window.devicePixelRatio || 1;
478
+ const size = 140;
479
+ canvas.width = size * dpr;
480
+ canvas.height = size * dpr;
481
+ canvas.style.width = size + 'px';
482
+ canvas.style.height = size + 'px';
483
+ ctx.scale(dpr, dpr);
484
+
485
+ const cx = size / 2, cy = size / 2, r = 54, inner = 34;
486
+ const total = entries.reduce((s, e) => s + e[1], 0);
487
+ const colors = [
488
+ '#06b6d4', '#a855f7', '#f59e0b', '#22c55e',
489
+ '#3b82f6', '#ef4444', '#ec4899', '#f97316', '#6366f1',
490
+ ];
491
+
492
+ let angle = -Math.PI / 2;
493
+ entries.forEach(([type, count], i) => {
494
+ const slice = (count / total) * Math.PI * 2;
495
+ ctx.beginPath();
496
+ ctx.moveTo(cx + inner * Math.cos(angle), cy + inner * Math.sin(angle));
497
+ ctx.arc(cx, cy, r, angle, angle + slice);
498
+ ctx.arc(cx, cy, inner, angle + slice, angle, true);
499
+ ctx.closePath();
500
+ ctx.fillStyle = colors[i % colors.length];
501
+ ctx.fill();
502
+ angle += slice;
503
+ });
504
+
505
+ // Center text
506
+ ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--text-primary').trim() || '#fff';
507
+ ctx.font = 'bold 20px system-ui';
508
+ ctx.textAlign = 'center';
509
+ ctx.textBaseline = 'middle';
510
+ ctx.fillText(total, cx, cy - 6);
511
+ ctx.font = '10px system-ui';
512
+ ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--text-muted').trim() || '#888';
513
+ ctx.fillText('total', cx, cy + 10);
433
514
  }
434
515
 
435
516
  // ============================================================
@@ -632,6 +713,11 @@ function renderGraph(graph) {
632
713
  for (const node of nodes) {
633
714
  const active = node === hoveredNode || node === selectedNode;
634
715
 
716
+ // Legend hover dimming
717
+ if (node._dimmed) {
718
+ ctx.globalAlpha = 0.15;
719
+ }
720
+
635
721
  // Glow + dashed ring
636
722
  if (active) {
637
723
  const pr = node.radius + 12 + Math.sin(pulsePhase * 3) * 3;
@@ -686,11 +772,91 @@ function renderGraph(graph) {
686
772
  ctx.fillText(String(node.observations.length), bx, by);
687
773
  ctx.textBaseline = 'alphabetic';
688
774
  }
775
+
776
+ // Reset alpha after dimming
777
+ ctx.globalAlpha = 1;
689
778
  }
690
779
 
691
780
  if (selectedNode && !animating) requestAnimationFrame(draw);
692
781
  }
693
782
 
783
+ // --- Knowledge Graph Legend ---
784
+ function buildLegend() {
785
+ let existing = container.querySelector('.graph-legend');
786
+ if (existing) existing.remove();
787
+
788
+ const legend = document.createElement('div');
789
+ legend.className = 'graph-legend';
790
+ legend.style.cssText = `
791
+ position: absolute; top: 12px; right: 12px; z-index: 10;
792
+ background: var(--bg-card);
793
+ backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
794
+ border: 1px solid var(--border-medium);
795
+ border-radius: 12px; padding: 12px 14px; min-width: 140px;
796
+ font-family: 'Inter', sans-serif; font-size: 11px;
797
+ color: var(--text-secondary);
798
+ box-shadow: 0 4px 24px rgba(0,0,0,0.12);
799
+ transition: opacity 0.3s;
800
+ `;
801
+
802
+ // Type stats
803
+ const typeCount = {};
804
+ nodes.forEach(n => { typeCount[n.type] = (typeCount[n.type] || 0) + 1; });
805
+
806
+ // Title
807
+ const title = document.createElement('div');
808
+ title.style.cssText = 'font-weight: 600; font-size: 11px; margin-bottom: 8px; color: var(--text-primary); letter-spacing: 0.5px; text-transform: uppercase;';
809
+ title.textContent = t('legend') || 'Legend';
810
+ legend.appendChild(title);
811
+
812
+ // Type entries
813
+ Object.entries(typeCount)
814
+ .sort((a, b) => b[1] - a[1])
815
+ .forEach(([type, count]) => {
816
+ const row = document.createElement('div');
817
+ row.style.cssText = 'display: flex; align-items: center; gap: 8px; padding: 3px 4px; border-radius: 6px; cursor: pointer; transition: background 0.2s;';
818
+
819
+ const dot = document.createElement('span');
820
+ dot.style.cssText = `width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; background: ${typeColors[type] || '#666'}; box-shadow: 0 0 6px ${typeColors[type] || '#666'}44;`;
821
+
822
+ const label = document.createElement('span');
823
+ label.style.cssText = 'flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;';
824
+ label.textContent = type;
825
+
826
+ const badge = document.createElement('span');
827
+ badge.style.cssText = 'font-size: 10px; opacity: 0.6;';
828
+ badge.textContent = count;
829
+
830
+ row.appendChild(dot);
831
+ row.appendChild(label);
832
+ row.appendChild(badge);
833
+
834
+ // Hover to highlight same-type nodes
835
+ row.addEventListener('mouseenter', () => {
836
+ row.style.background = 'var(--bg-card-hover)';
837
+ nodes.forEach(n => { n._dimmed = n.type !== type; });
838
+ draw();
839
+ });
840
+ row.addEventListener('mouseleave', () => {
841
+ row.style.background = '';
842
+ nodes.forEach(n => { n._dimmed = false; });
843
+ draw();
844
+ });
845
+
846
+ legend.appendChild(row);
847
+ });
848
+
849
+ // Stats footer
850
+ const stats = document.createElement('div');
851
+ stats.style.cssText = 'margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border-subtle); font-size: 10px; opacity: 0.5;';
852
+ stats.textContent = `${nodes.length} nodes · ${edges.length} edges`;
853
+ legend.appendChild(stats);
854
+
855
+ container.style.position = 'relative';
856
+ container.appendChild(legend);
857
+ }
858
+ buildLegend();
859
+
694
860
  function showDetail(node) {
695
861
  const panel = document.getElementById('graph-detail');
696
862
  if (!node) {
@@ -775,6 +941,85 @@ function renderGraph(graph) {
775
941
  let allObservations = [];
776
942
  let obsFilter = '';
777
943
  let obsTypeFilter = '';
944
+ let batchMode = false;
945
+ let selectedIds = new Set();
946
+
947
+ // Low quality detection (same patterns as CLI cleanup)
948
+ const LOW_QUALITY_OBS_PATTERNS = [
949
+ /^Session activity/i,
950
+ /^Updated \S+\.\w+$/i,
951
+ /^Created \S+\.\w+$/i,
952
+ /^Deleted \S+\.\w+$/i,
953
+ /^Modified \S+\.\w+$/i,
954
+ /^Ran command:/i,
955
+ /^Read file:/i,
956
+ ];
957
+ function isLowQualityObs(title) {
958
+ return LOW_QUALITY_OBS_PATTERNS.some(p => p.test(title.trim()));
959
+ }
960
+
961
+ function renderBatchToolbar() {
962
+ const slot = document.getElementById('batch-toolbar-slot');
963
+ if (!slot) return;
964
+ if (!batchMode || selectedIds.size === 0) {
965
+ slot.innerHTML = '';
966
+ return;
967
+ }
968
+ slot.innerHTML = `
969
+ <div class="batch-toolbar">
970
+ <span class="batch-count">${selectedIds.size} ${t('selected') || 'selected'}</span>
971
+ <button class="batch-cancel-btn" onclick="exitBatchMode()">${t('cancel') || 'Cancel'}</button>
972
+ <button class="batch-delete-btn" onclick="batchDeleteSelected()">🗑️ ${t('deleteSelected') || 'Delete Selected'}</button>
973
+ </div>
974
+ `;
975
+ }
976
+
977
+ async function batchDeleteSelected() {
978
+ if (selectedIds.size === 0) return;
979
+ const msg = (t('batchDeleteConfirm') || 'Delete %count% observations?').replace('%count%', selectedIds.size);
980
+ if (!confirm(msg)) return;
981
+
982
+ const sep = selectedProject ? `?project=${encodeURIComponent(selectedProject)}` : '';
983
+ let deleted = 0;
984
+ for (const id of selectedIds) {
985
+ try {
986
+ const res = await fetch(`/api/observations/${id}${sep}`, { method: 'DELETE' });
987
+ const data = await res.json();
988
+ if (data.ok) deleted++;
989
+ } catch { /* ignore individual failures */ }
990
+ }
991
+
992
+ allObservations = allObservations.filter(o => !selectedIds.has(o.id));
993
+ selectedIds.clear();
994
+ batchMode = false;
995
+ renderObsList();
996
+ renderBatchToolbar();
997
+
998
+ // Update counter
999
+ const subtitle = document.querySelector('#page-observations .page-subtitle');
1000
+ if (subtitle) subtitle.textContent = `${allObservations.length} ${t('observationsStored')}`;
1001
+ }
1002
+
1003
+ function exitBatchMode() {
1004
+ batchMode = false;
1005
+ selectedIds.clear();
1006
+ renderObsList();
1007
+ renderBatchToolbar();
1008
+ }
1009
+
1010
+ function toggleObsSelect(id) {
1011
+ if (selectedIds.has(id)) {
1012
+ selectedIds.delete(id);
1013
+ } else {
1014
+ selectedIds.add(id);
1015
+ }
1016
+ renderBatchToolbar();
1017
+ }
1018
+
1019
+ // Make batch functions globally accessible
1020
+ window.exitBatchMode = exitBatchMode;
1021
+ window.batchDeleteSelected = batchDeleteSelected;
1022
+ window.toggleObsSelect = toggleObsSelect;
778
1023
 
779
1024
  async function loadObservations() {
780
1025
  const container = document.getElementById('page-observations');
@@ -797,12 +1042,19 @@ async function loadObservations() {
797
1042
  <h1 class="page-title">${t('observations')}</h1>
798
1043
  <p class="page-subtitle">${allObservations.length} ${t('observationsStored')}</p>
799
1044
  </div>
800
- <button class="export-btn" id="btn-export" title="${t('exportData')}">
801
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v8M4 7l4 4 4-4M2 12v2h12v-2"/></svg>
802
- ${t('exportData')}
803
- </button>
1045
+ <div style="display:flex;gap:8px;">
1046
+ <button class="export-btn" id="btn-batch-cleanup" title="${t('batchCleanup') || 'Batch Cleanup'}">
1047
+ 🧹 ${t('batchCleanup') || 'Cleanup'}
1048
+ </button>
1049
+ <button class="export-btn" id="btn-export" title="${t('exportData')}">
1050
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v8M4 7l4 4 4-4M2 12v2h12v-2"/></svg>
1051
+ ${t('exportData')}
1052
+ </button>
1053
+ </div>
804
1054
  </div>
805
1055
 
1056
+ <div id="batch-toolbar-slot"></div>
1057
+
806
1058
  <div class="search-bar">
807
1059
  <input class="search-input" id="obs-search" type="text" placeholder="${t('searchObservations')}" />
808
1060
  <button class="filter-btn active" data-type="" id="filter-all">${t('all')}</button>
@@ -818,6 +1070,22 @@ async function loadObservations() {
818
1070
  window.open(`/api/export${sep}`, '_blank');
819
1071
  });
820
1072
 
1073
+ // Batch cleanup: enter batch mode, auto-select low-quality observations
1074
+ document.getElementById('btn-batch-cleanup').addEventListener('click', () => {
1075
+ batchMode = !batchMode;
1076
+ if (batchMode) {
1077
+ // Auto-select low quality ones
1078
+ selectedIds.clear();
1079
+ allObservations.forEach(obs => {
1080
+ if (isLowQualityObs(obs.title || '')) selectedIds.add(obs.id);
1081
+ });
1082
+ } else {
1083
+ selectedIds.clear();
1084
+ }
1085
+ renderObsList();
1086
+ renderBatchToolbar();
1087
+ });
1088
+
821
1089
  document.getElementById('obs-search').addEventListener('input', (e) => {
822
1090
  obsFilter = e.target.value.toLowerCase();
823
1091
  renderObsList();
@@ -865,26 +1133,32 @@ function renderObsList() {
865
1133
  return;
866
1134
  }
867
1135
 
868
- list.innerHTML = filtered.map(obs => `
869
- <div class="obs-card" data-obs-id="${obs.id}">
1136
+ list.innerHTML = filtered.map(obs => {
1137
+ const isLow = isLowQualityObs(obs.title || '');
1138
+ const isSelected = selectedIds.has(obs.id);
1139
+ const hl = (text) => obsFilter ? escapeHtml(text).replace(new RegExp(`(${escapeHtml(obsFilter).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'), '<mark>$1</mark>') : escapeHtml(text);
1140
+ return `
1141
+ <div class="obs-card${isLow ? ' low-quality' : ''}" data-obs-id="${obs.id}">
870
1142
  <div class="obs-card-header" onclick="toggleObsDetail(${obs.id})">
1143
+ ${batchMode ? `<input type="checkbox" class="obs-checkbox" ${isSelected ? 'checked' : ''} onclick="event.stopPropagation(); toggleObsSelect(${obs.id}); this.checked = !this.checked;" />` : ''}
871
1144
  <span class="obs-card-id">#${obs.id}</span>
872
1145
  <span class="type-badge" data-type="${obs.type || 'unknown'}">
873
1146
  ${typeIcons[obs.type] || '❓'} ${obs.type || 'unknown'}
874
1147
  </span>
875
- <span class="obs-card-title">${escapeHtml(obs.title || t('untitled'))}</span>
1148
+ ${isLow ? '<span class="low-quality-badge">low quality</span>' : ''}
1149
+ <span class="obs-card-title">${hl(obs.title || t('untitled'))}</span>
876
1150
  <span class="obs-expand-icon">▼</span>
877
1151
  </div>
878
1152
  <div class="obs-card-meta">
879
- <span>📁 ${escapeHtml(obs.entityName || 'unknown')}</span>
1153
+ <span>📁 ${hl(obs.entityName || 'unknown')}</span>
880
1154
  ${obs.createdAt ? `<span>🕐 ${formatTime(obs.createdAt)}</span>` : ''}
881
1155
  ${obs.accessCount ? `<span>👁 ${obs.accessCount}</span>` : ''}
882
1156
  </div>
883
1157
  <div class="obs-detail" id="obs-detail-${obs.id}" style="display:none;">
884
- ${obs.narrative ? `<div class="obs-detail-section"><label>${t('narrative')}</label><div class="obs-card-narrative">${escapeHtml(obs.narrative)}</div></div>` : ''}
885
- ${obs.facts && obs.facts.length > 0 ? `<div class="obs-detail-section"><label>${t('facts')}</label><div class="obs-card-facts">${obs.facts.map(f => `<span class="fact-tag">${escapeHtml(f)}</span>`).join('')}</div></div>` : ''}
886
- ${obs.concepts && obs.concepts.length > 0 ? `<div class="obs-detail-section"><label>${t('concepts')}</label><div class="obs-card-facts">${obs.concepts.map(c => `<span class="fact-tag concept-tag">${escapeHtml(c)}</span>`).join('')}</div></div>` : ''}
887
- ${obs.filesModified && obs.filesModified.length > 0 ? `<div class="obs-detail-section"><label>${t('files')}</label><div class="obs-card-facts">${obs.filesModified.map(f => `<span class="fact-tag file-tag">${escapeHtml(f)}</span>`).join('')}</div></div>` : ''}
1158
+ ${obs.narrative ? `<div class="obs-detail-section"><label>${t('narrative')}</label><div class="obs-card-narrative">${hl(obs.narrative)}</div></div>` : ''}
1159
+ ${obs.facts && obs.facts.length > 0 ? `<div class="obs-detail-section"><label>${t('facts')}</label><div class="obs-card-facts">${obs.facts.map(f => `<span class="fact-tag">${hl(f)}</span>`).join('')}</div></div>` : ''}
1160
+ ${obs.concepts && obs.concepts.length > 0 ? `<div class="obs-detail-section"><label>${t('concepts')}</label><div class="obs-card-facts">${obs.concepts.map(c => `<span class="fact-tag concept-tag">${hl(c)}</span>`).join('')}</div></div>` : ''}
1161
+ ${obs.filesModified && obs.filesModified.length > 0 ? `<div class="obs-detail-section"><label>${t('files')}</label><div class="obs-card-facts">${obs.filesModified.map(f => `<span class="fact-tag file-tag">${hl(f)}</span>`).join('')}</div></div>` : ''}
888
1162
  <div class="obs-detail-actions">
889
1163
  <button class="delete-btn" onclick="deleteObs(${obs.id}, event)">
890
1164
  <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M6 7v5M10 7v5M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
@@ -893,7 +1167,8 @@ function renderObsList() {
893
1167
  </div>
894
1168
  </div>
895
1169
  </div>
896
- `).join('');
1170
+ `;
1171
+ }).join('');
897
1172
  }
898
1173
 
899
1174
  // ============================================================
@@ -991,9 +1266,30 @@ function toggleObsDetail(id) {
991
1266
  const card = detail?.closest('.obs-card');
992
1267
  if (!detail || !card) return;
993
1268
 
994
- const isOpen = detail.style.display !== 'none';
995
- detail.style.display = isOpen ? 'none' : 'block';
996
- card.classList.toggle('expanded', !isOpen);
1269
+ const isOpen = card.classList.contains('expanded');
1270
+
1271
+ if (isOpen) {
1272
+ // Collapse with animation
1273
+ detail.style.maxHeight = detail.scrollHeight + 'px';
1274
+ requestAnimationFrame(() => {
1275
+ detail.style.maxHeight = '0';
1276
+ detail.style.opacity = '0';
1277
+ });
1278
+ setTimeout(() => { detail.style.display = 'none'; }, 300);
1279
+ card.classList.remove('expanded');
1280
+ } else {
1281
+ // Expand with animation
1282
+ detail.style.display = 'block';
1283
+ detail.style.maxHeight = '0';
1284
+ detail.style.opacity = '0';
1285
+ requestAnimationFrame(() => {
1286
+ detail.style.maxHeight = detail.scrollHeight + 'px';
1287
+ detail.style.opacity = '1';
1288
+ });
1289
+ // Remove max-height constraint after animation
1290
+ setTimeout(() => { detail.style.maxHeight = 'none'; }, 300);
1291
+ card.classList.add('expanded');
1292
+ }
997
1293
 
998
1294
  // Rotate expand icon
999
1295
  const icon = card.querySelector('.obs-expand-icon');
@@ -1158,24 +1158,113 @@ body {
1158
1158
  color: var(--text-secondary);
1159
1159
  }
1160
1160
 
1161
- /* Observation Detail Section */
1161
+ /* Observation Detail Section — smooth expand/collapse */
1162
1162
  .obs-detail {
1163
1163
  margin-top: 12px;
1164
1164
  padding-top: 12px;
1165
1165
  border-top: 1px solid var(--border-subtle);
1166
- animation: slideDown 0.2s ease;
1166
+ overflow: hidden;
1167
+ transition: max-height 0.3s ease, opacity 0.3s ease;
1167
1168
  }
1168
1169
 
1169
- @keyframes slideDown {
1170
- from {
1171
- opacity: 0;
1172
- transform: translateY(-8px);
1173
- }
1170
+ /* Batch action toolbar */
1171
+ .batch-toolbar {
1172
+ display: flex;
1173
+ align-items: center;
1174
+ gap: 10px;
1175
+ padding: 10px 16px;
1176
+ background: var(--bg-card);
1177
+ border: 1px solid var(--border-subtle);
1178
+ border-radius: 10px;
1179
+ margin-bottom: 12px;
1180
+ animation: fadeIn 0.2s ease;
1181
+ }
1174
1182
 
1175
- to {
1176
- opacity: 1;
1177
- transform: translateY(0);
1178
- }
1183
+ .batch-toolbar .batch-count {
1184
+ font-size: 12px;
1185
+ color: var(--text-secondary);
1186
+ flex: 1;
1187
+ }
1188
+
1189
+ .batch-toolbar button {
1190
+ padding: 6px 14px;
1191
+ border-radius: 6px;
1192
+ font-size: 12px;
1193
+ font-family: var(--font-sans);
1194
+ cursor: pointer;
1195
+ transition: all 0.2s;
1196
+ border: 1px solid;
1197
+ }
1198
+
1199
+ .batch-toolbar .batch-delete-btn {
1200
+ background: rgba(239, 68, 68, 0.1);
1201
+ border-color: rgba(239, 68, 68, 0.3);
1202
+ color: var(--accent-red);
1203
+ }
1204
+
1205
+ .batch-toolbar .batch-delete-btn:hover {
1206
+ background: rgba(239, 68, 68, 0.2);
1207
+ border-color: var(--accent-red);
1208
+ }
1209
+
1210
+ .batch-toolbar .batch-cancel-btn {
1211
+ background: transparent;
1212
+ border-color: var(--border-medium);
1213
+ color: var(--text-secondary);
1214
+ }
1215
+
1216
+ .batch-toolbar .batch-cancel-btn:hover {
1217
+ border-color: var(--text-secondary);
1218
+ color: var(--text-primary);
1219
+ }
1220
+
1221
+ /* Low quality indicator */
1222
+ .obs-card.low-quality {
1223
+ border-left: 3px solid var(--accent-amber);
1224
+ opacity: 0.7;
1225
+ }
1226
+
1227
+ .obs-card.low-quality:hover {
1228
+ opacity: 1;
1229
+ }
1230
+
1231
+ .low-quality-badge {
1232
+ font-size: 9px;
1233
+ padding: 1px 6px;
1234
+ border-radius: 4px;
1235
+ background: rgba(245, 158, 11, 0.15);
1236
+ color: var(--accent-amber);
1237
+ font-weight: 600;
1238
+ text-transform: uppercase;
1239
+ letter-spacing: 0.5px;
1240
+ }
1241
+
1242
+ /* Batch select checkbox */
1243
+ .obs-checkbox {
1244
+ appearance: none;
1245
+ width: 16px;
1246
+ height: 16px;
1247
+ border: 1.5px solid var(--border-medium);
1248
+ border-radius: 4px;
1249
+ background: transparent;
1250
+ cursor: pointer;
1251
+ transition: all 0.2s;
1252
+ flex-shrink: 0;
1253
+ }
1254
+
1255
+ .obs-checkbox:checked {
1256
+ background: var(--accent-cyan);
1257
+ border-color: var(--accent-cyan);
1258
+ }
1259
+
1260
+ .obs-checkbox:checked::after {
1261
+ content: '✓';
1262
+ display: block;
1263
+ text-align: center;
1264
+ font-size: 11px;
1265
+ color: #000;
1266
+ line-height: 14px;
1267
+ font-weight: bold;
1179
1268
  }
1180
1269
 
1181
1270
  .obs-detail-section {
@@ -1194,6 +1283,7 @@ body {
1194
1283
 
1195
1284
  .obs-detail-section .obs-card-narrative {
1196
1285
  -webkit-line-clamp: unset;
1286
+ line-clamp: unset;
1197
1287
  white-space: pre-wrap;
1198
1288
  max-height: none;
1199
1289
  }
@@ -1242,4 +1332,17 @@ body {
1242
1332
  background: rgba(239, 68, 68, 0.1);
1243
1333
  border-color: var(--accent-red);
1244
1334
  opacity: 1;
1335
+ }
1336
+
1337
+ /* Search highlight */
1338
+ mark {
1339
+ background: rgba(245, 158, 11, 0.25);
1340
+ color: inherit;
1341
+ padding: 1px 2px;
1342
+ border-radius: 2px;
1343
+ font-weight: 600;
1344
+ }
1345
+
1346
+ [data-theme="light"] mark {
1347
+ background: rgba(245, 158, 11, 0.35);
1245
1348
  }