ltcai 2.2.0 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,23 @@
1
1
  /* Lattice AI v2.0 — shared styling for the Agentic Workspace Platform pages
2
2
  (Plugin SDK, Workflow Designer, Multi-Agent Runtime, Realtime Activity). */
3
3
  :root {
4
- --bg: #0f1115;
5
- --panel: #16191f;
6
- --panel-2: #1c2027;
7
- --border: rgba(255, 255, 255, 0.08);
8
- --text: #e7ecf3;
9
- --muted: #94a3b8;
10
- --accent: #378ADD;
11
- --accent-2: #5ea7ec;
4
+ /* Consume tokens.css semantic tokens so these pages follow light & dark themes.
5
+ tokens.css is linked before platform.css, so var() resolves at runtime. */
6
+ --bg: var(--lt-bg, #0f1115);
7
+ --panel: var(--lt-surface, #16191f);
8
+ --panel-2: var(--lt-surface-2, #1c2027);
9
+ --border: var(--lt-line, rgba(255, 255, 255, 0.08));
10
+ --text: var(--lt-ink, #e7ecf3);
11
+ --muted: var(--lt-ink-soft, #64748b);
12
+ --accent: var(--lt-accent, #6E4AE6);
13
+ --accent-2: var(--lt-accent, #5ea7ec);
12
14
  --ok: #34d399;
13
15
  --warn: #fbbf24;
14
16
  --err: #f87171;
15
17
  }
16
18
  * { box-sizing: border-box; }
19
+ html, body { overflow-x: hidden; }
20
+ body { min-width: 320px; }
17
21
  body {
18
22
  margin: 0;
19
23
  background: var(--bg);
@@ -27,15 +31,15 @@ header.app {
27
31
  padding: 14px 24px; border-bottom: 1px solid var(--border);
28
32
  background: rgba(22, 25, 31, 0.8); position: sticky; top: 0; backdrop-filter: blur(8px); z-index: 5;
29
33
  }
30
- header.app .brand { font-weight: 700; font-size: 16px; color: #fff; letter-spacing: .3px; }
34
+ header.app .brand { font-weight: 700; font-size: 16px; color: var(--text); letter-spacing: .3px; }
31
35
  header.app .brand small { color: var(--accent); font-weight: 600; margin-left: 6px; }
32
36
  header.app nav { display: flex; gap: 14px; flex-wrap: wrap; }
33
- header.app nav a { color: var(--muted); font-size: 13px; padding: 4px 6px; border-radius: 6px; }
34
- header.app nav a:hover, header.app nav a.active { color: #fff; background: var(--panel-2); }
37
+ header.app nav a { color: var(--muted); font-size: 13px; padding: 4px 6px; border-radius: 6px; min-height: 44px; display: inline-flex; align-items: center; }
38
+ header.app nav a:hover, header.app nav a.active { color: var(--text); background: var(--panel-2); }
35
39
  main { max-width: 1080px; margin: 0 auto; padding: 28px 24px 80px; }
36
40
  h1 { font-size: 22px; margin: 0 0 4px; }
37
41
  .sub { color: var(--muted); font-size: 13px; margin: 0 0 24px; }
38
- .grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
42
+ .grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); }
39
43
  .card {
40
44
  background: var(--panel); border: 1px solid var(--border); border-radius: 14px;
41
45
  padding: 16px 18px;
@@ -54,22 +58,47 @@ h1 { font-size: 22px; margin: 0 0 4px; }
54
58
  button, .btn {
55
59
  background: var(--accent); color: #fff; border: none; border-radius: 8px;
56
60
  padding: 7px 14px; font-size: 13px; cursor: pointer; font-weight: 600;
61
+ min-height: 44px;
57
62
  }
58
63
  button.ghost { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
59
64
  button:hover { filter: brightness(1.08); }
60
65
  button:disabled { opacity: .5; cursor: not-allowed; }
61
66
  textarea, input, select {
62
67
  width: 100%; background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
63
- border-radius: 8px; padding: 9px 11px; font-size: 13px; font-family: inherit;
68
+ border-radius: 8px; padding: 9px 11px; font-size: 16px; font-family: inherit;
69
+ min-height: 44px;
64
70
  }
65
71
  textarea { min-height: 90px; resize: vertical; }
66
72
  label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
67
73
  pre {
68
- background: #0b0d11; border: 1px solid var(--border); border-radius: 10px;
69
- padding: 12px; overflow: auto; font-size: 12px; color: #cbd5e1; max-height: 360px;
74
+ background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;
75
+ padding: 12px; overflow: auto; font-size: 12px; color: var(--text); max-height: 360px;
76
+ max-width: 100%; overflow-wrap: anywhere;
70
77
  }
71
78
  .empty { color: var(--muted); text-align: center; padding: 50px 0; }
72
79
  .section { margin-top: 28px; }
73
80
  .timeline-item { border-left: 2px solid var(--border); padding: 6px 0 6px 14px; margin-left: 6px; font-size: 13px; }
74
81
  .timeline-item .t-meta { color: var(--muted); font-size: 11px; }
75
- .toast { position: fixed; bottom: 20px; right: 20px; background: var(--panel-2); border: 1px solid var(--border); padding: 12px 16px; border-radius: 10px; font-size: 13px; max-width: 360px; }
82
+ .toast {
83
+ position: fixed;
84
+ bottom: max(20px, env(safe-area-inset-bottom));
85
+ left: auto;
86
+ right: max(12px, env(safe-area-inset-right));
87
+ background: var(--panel-2); border: 1px solid var(--border);
88
+ padding: 12px 16px; border-radius: 10px; font-size: 13px;
89
+ max-width: min(360px, calc(100vw - 24px));
90
+ }
91
+
92
+ /* 키보드 포커스 링 (tokens.css 의 :focus-visible 와 별개로 이 페이지에서도 보장) */
93
+ :focus-visible {
94
+ outline: 2px solid var(--accent, #6E4AE6);
95
+ outline-offset: 2px;
96
+ }
97
+
98
+ /* 폰: 패딩 축소 + 단일 열 + 헤더 컴팩트 */
99
+ @media (max-width: 600px) {
100
+ header.app { padding: 12px 14px; }
101
+ main { padding: 18px 14px 64px; }
102
+ .grid { grid-template-columns: 1fr; }
103
+ h1 { font-size: 20px; }
104
+ }
@@ -2,9 +2,12 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
6
6
  <title>Plugin SDK — Lattice AI</title>
7
- <link rel="stylesheet" href="/static/platform.css" />
7
+ <script src="/static/scripts/ux.js?v=2.2.2"></script>
8
+ <link rel="stylesheet" href="/static/css/tokens.css?v=2.2.2" />
9
+ <link rel="stylesheet" href="/static/platform.css?v=2.2.2" />
10
+ <link rel="stylesheet" href="/static/css/responsive.css?v=2.2.2" />
8
11
  </head>
9
12
  <body>
10
13
  <main>
@@ -587,12 +587,12 @@ function renderUsers(users) {
587
587
  <tbody>
588
588
  ${latestUsers.map(user => `
589
589
  <tr>
590
- <td>${esc(user.email)}</td>
591
- <td>${esc(user.name || '-')}</td>
592
- <td>${esc(user.nickname || '-')}</td>
593
- <td><span class="role">${esc(roleLabel(user.role))}</span></td>
594
- <td>${permissionTag(statusLabel(user), user.disabled ? 'medium' : 'low')}</td>
595
- <td>
590
+ <td data-label="${t('label_email')}">${esc(user.email)}</td>
591
+ <td data-label="${t('label_name')}">${esc(user.name || '-')}</td>
592
+ <td data-label="${t('label_nickname')}">${esc(user.nickname || '-')}</td>
593
+ <td data-label="${t('label_perm')}"><span class="role">${esc(roleLabel(user.role))}</span></td>
594
+ <td data-label="${t('label_status')}">${permissionTag(statusLabel(user), user.disabled ? 'medium' : 'low')}</td>
595
+ <td data-label="${t('label_actions')}">
596
596
  <div class="actions">
597
597
  <button class="table-btn" data-action="role" data-email="${esc(user.email)}" data-next-role="${user.role === 'admin' ? 'user' : 'admin'}">
598
598
  ${user.role === 'admin' ? t('btn_revoke_admin') : t('btn_grant_admin')}
@@ -635,15 +635,15 @@ function renderPermissions(users) {
635
635
  const isAdmin = user.role === 'admin';
636
636
  return `
637
637
  <tr>
638
- <td>
638
+ <td data-label="${t('label_user')}">
639
639
  <strong>${esc(user.nickname || user.name || user.email)}</strong>
640
640
  <div class="preview">${esc(user.email)}</div>
641
641
  </td>
642
- <td>${permissionTag(statusLabel(user), active ? 'low' : 'medium')}</td>
643
- <td>${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
644
- <td>${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
645
- <td>${permissionTag(isAdmin && active ? t('permission_granted') : t('permission_not_granted'), isAdmin && active ? 'low' : 'medium')}</td>
646
- <td>
642
+ <td data-label="${t('label_status')}">${permissionTag(statusLabel(user), active ? 'low' : 'medium')}</td>
643
+ <td data-label="${t('permission_default')}">${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
644
+ <td data-label="${t('permission_advanced')}">${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
645
+ <td data-label="${t('permission_admin')}">${permissionTag(isAdmin && active ? t('permission_granted') : t('permission_not_granted'), isAdmin && active ? 'low' : 'medium')}</td>
646
+ <td data-label="${t('label_actions')}">
647
647
  <div class="actions">
648
648
  <button class="table-btn" data-action="role" data-email="${esc(user.email)}" data-next-role="${isAdmin ? 'user' : 'admin'}">
649
649
  ${isAdmin ? t('btn_revoke_admin') : t('btn_grant_admin')}
@@ -773,12 +773,12 @@ function renderAudit(audit) {
773
773
  <tbody>
774
774
  ${users.map(user => `
775
775
  <tr>
776
- <td><strong>${esc(user.nickname || user.email || 'Unknown')}</strong><div class="preview">${esc(user.email || '')}</div></td>
777
- <td>${esc(user.user_messages || 0)} / ${esc(user.assistant_messages || 0)}</td>
778
- <td>${esc(user.document_uploads || 0)}</td>
779
- <td>${permissionTag(user.sensitive_events || 0, (user.high_sensitive_events || 0) ? 'high' : ((user.sensitive_events || 0) ? 'medium' : 'low'))}</td>
780
- <td>${esc(user.clear_events || 0)} / ${esc(user.delete_events || 0)}</td>
781
- <td>${esc(formatTime(user.last_activity_at))}</td>
776
+ <td data-label="${t('label_user')}"><strong>${esc(user.nickname || user.email || 'Unknown')}</strong><div class="preview">${esc(user.email || '')}</div></td>
777
+ <td data-label="AI Use">${esc(user.user_messages || 0)} / ${esc(user.assistant_messages || 0)}</td>
778
+ <td data-label="Uploads">${esc(user.document_uploads || 0)}</td>
779
+ <td data-label="Sensitive">${permissionTag(user.sensitive_events || 0, (user.high_sensitive_events || 0) ? 'high' : ((user.sensitive_events || 0) ? 'medium' : 'low'))}</td>
780
+ <td data-label="Clear/Delete">${esc(user.clear_events || 0)} / ${esc(user.delete_events || 0)}</td>
781
+ <td data-label="Last Active">${esc(formatTime(user.last_activity_at))}</td>
782
782
  </tr>
783
783
  `).join('')}
784
784
  </tbody>
@@ -800,11 +800,11 @@ function renderAudit(audit) {
800
800
  <tbody>
801
801
  ${events.map(event => `
802
802
  <tr>
803
- <td>${esc(formatTime(event.timestamp))}</td>
804
- <td>${esc(auditEventLabel(event))}</td>
805
- <td>${esc(event.user_nickname || event.user_email || 'Unknown')}</td>
806
- <td>${esc(auditTarget(event))}</td>
807
- <td>${permissionTag(event.sensitivity || 'none', event.sensitivity === 'high' ? 'high' : (event.sensitivity && event.sensitivity !== 'none' ? 'medium' : 'low'))}</td>
803
+ <td data-label="Time">${esc(formatTime(event.timestamp))}</td>
804
+ <td data-label="Event">${esc(auditEventLabel(event))}</td>
805
+ <td data-label="${t('label_user')}">${esc(event.user_nickname || event.user_email || 'Unknown')}</td>
806
+ <td data-label="Target/Data">${esc(auditTarget(event))}</td>
807
+ <td data-label="Risk">${permissionTag(event.sensitivity || 'none', event.sensitivity === 'high' ? 'high' : (event.sensitivity && event.sensitivity !== 'none' ? 'medium' : 'low'))}</td>
808
808
  </tr>
809
809
  `).join('')}
810
810
  </tbody>
@@ -1377,16 +1377,16 @@ function renderCcUsersTable(users) {
1377
1377
  }
1378
1378
  const rows = users.slice(0, 25).map(u => `
1379
1379
  <tr data-cc-user="${ccEscape(u.email)}" style="cursor:pointer">
1380
- <td>${ccEscape(u.user)}</td>
1381
- <td>${ccEscape(u.total_chats)}</td>
1382
- <td style="color:#2c8a3f">${ccEscape(u.compliant_chats)}</td>
1383
- <td style="color:#b13030">${ccEscape(u.risky_chats)}</td>
1384
- <td>${ccEscape(u.uploaded_files)}</td>
1385
- <td style="color:#2c8a3f">${ccEscape(u.compliant_files)}</td>
1386
- <td style="color:#b13030">${ccEscape(u.risky_files)}</td>
1387
- <td>${ccEscape(u.high_risk_events)}</td>
1388
- <td>${ccEscape(u.risk_rate)}%</td>
1389
- <td>${ccEscape((u.last_activity_at || '').slice(0, 19).replace('T', ' '))}</td>
1380
+ <td data-label="사용자">${ccEscape(u.user)}</td>
1381
+ <td data-label="총 채팅">${ccEscape(u.total_chats)}</td>
1382
+ <td data-label="준수 채팅" style="color:#2c8a3f">${ccEscape(u.compliant_chats)}</td>
1383
+ <td data-label="위험 채팅" style="color:#b13030">${ccEscape(u.risky_chats)}</td>
1384
+ <td data-label="총 파일">${ccEscape(u.uploaded_files)}</td>
1385
+ <td data-label="준수 파일" style="color:#2c8a3f">${ccEscape(u.compliant_files)}</td>
1386
+ <td data-label="위험 파일" style="color:#b13030">${ccEscape(u.risky_files)}</td>
1387
+ <td data-label="High">${ccEscape(u.high_risk_events)}</td>
1388
+ <td data-label="위험률">${ccEscape(u.risk_rate)}%</td>
1389
+ <td data-label="마지막 활동">${ccEscape((u.last_activity_at || '').slice(0, 19).replace('T', ' '))}</td>
1390
1390
  </tr>
1391
1391
  `).join('');
1392
1392
  wrap.innerHTML = `
@@ -1055,15 +1055,22 @@ const chatViewport = document.getElementById('chat-viewport');
1055
1055
  <div style="font-weight:700">⭐ Best for this PC — ${escapeHtml(top.name || top.id)} ${badge('recommended')}</div>
1056
1056
  <div style="font-size:12px;opacity:0.8;margin-top:3px">${escapeHtml(top.reason || '')}</div>
1057
1057
  <div style="font-size:12px;margin-top:4px">${escapeHtml(top.size || '')} · ${escapeHtml(ram(top))} · ${escapeHtml(nextStep(rec.engine))}</div>
1058
+ ${modelSourceLine(top) ? `<div style="font-size:11px;opacity:0.7;margin-top:3px">${escapeHtml(modelSourceLine(top))}</div>` : ''}
1058
1059
  </div>` : '';
1059
1060
 
1060
1061
  const rows = families.map((fam) => {
1061
1062
  const best = fam.best;
1062
- const items = (fam.models || []).map((m) => `
1063
- <div style="display:flex;justify-content:space-between;gap:8px;padding:3px 0;font-size:12px;opacity:${m.status === 'not_recommended' ? 0.55 : 1}">
1064
- <span>${escapeHtml(m.name || m.id)}</span>
1065
- <span style="white-space:nowrap">${escapeHtml(m.size || '')} · ${escapeHtml(ram(m))} ${badge(m.status)}</span>
1066
- </div>`).join('');
1063
+ const items = (fam.models || []).map((m) => {
1064
+ const src = modelSourceLine(m);
1065
+ return `
1066
+ <div style="padding:4px 0;font-size:12px;opacity:${m.status === 'not_recommended' ? 0.55 : 1}">
1067
+ <div style="display:flex;justify-content:space-between;gap:8px">
1068
+ <span>${escapeHtml(m.name || m.id)}</span>
1069
+ <span style="white-space:nowrap">${escapeHtml(m.size || '')} · ${escapeHtml(ram(m))} ${badge(m.status)}</span>
1070
+ </div>
1071
+ ${src ? `<div style="font-size:11px;opacity:0.65;margin-top:2px">${escapeHtml(src)}</div>` : ''}
1072
+ </div>`;
1073
+ }).join('');
1067
1074
  return `
1068
1075
  <details style="margin:6px 0;border:1px solid var(--border,#e5e7eb);border-radius:8px;padding:8px 10px">
1069
1076
  <summary style="cursor:pointer;font-weight:600">${escapeHtml(fam.family)} ${best ? badge(best.status) : ''}${best ? ` <span style="font-weight:400;opacity:0.7">${escapeHtml(best.name || '')}</span>` : ''}</summary>
@@ -1536,25 +1543,58 @@ const chatViewport = document.getElementById('chat-viewport');
1536
1543
  const action = isLocalEngine
1537
1544
  ? `selectModelByCard('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`
1538
1545
  : `loadSelectedModel('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`;
1539
- const sourceLine = [
1540
- model.source_country,
1541
- model.source_company,
1542
- model.execution_method,
1543
- model.internet_requirement,
1544
- model.model_name || model.name,
1545
- ].filter(Boolean).join(' · ');
1546
- const detailLine = sourceLine || `${model.id} · ${badge}`;
1546
+ const chipsHtml = modelSourceChipsHtml(model);
1547
+ const detailLine = chipsHtml ? `${model.id} · ${badge}` : `${model.id} · ${badge}`;
1547
1548
  return `
1548
1549
  <button class="model-option${cls}" ${isUnavailable ? 'disabled' : ''} onclick="${action}">
1549
1550
  <div>
1550
1551
  <strong>${escapeHtml(model.name || compactModelName(model.id))}</strong>
1551
- <span>${escapeHtml(detailLine)}${sourceLine ? `<br>${escapeHtml(model.id)} · ${escapeHtml(badge)}` : ''}</span>
1552
+ ${chipsHtml}
1553
+ <span>${escapeHtml(detailLine)}</span>
1552
1554
  </div>
1553
1555
  <i class="ti ${icon}"></i>
1554
1556
  </button>
1555
1557
  `;
1556
1558
  }
1557
1559
 
1560
+ // 모델 출처 정보를 비전문가도 읽기 쉬운 라벨 칩으로 렌더링한다.
1561
+ // 필드: source_country / source_company / execution_method / internet_requirement / model_name
1562
+ // 누락된 필드는 자동으로 생략(graceful degrade)한다.
1563
+ function modelSourceChips(model) {
1564
+ if (!model) return [];
1565
+ return [
1566
+ ['국가', model.source_country],
1567
+ ['회사', model.source_company],
1568
+ ['실행', model.execution_method],
1569
+ ['인터넷', model.internet_requirement],
1570
+ ['모델명', model.model_name || model.name],
1571
+ ].filter(([, value]) => value != null && String(value).trim() !== '');
1572
+ }
1573
+
1574
+ function modelSourceChipsHtml(model) {
1575
+ const chips = modelSourceChips(model);
1576
+ if (!chips.length) return '';
1577
+ const chipStyle = 'display:inline-flex;align-items:center;gap:3px;padding:2px 8px;'
1578
+ + 'border:1px solid var(--border,#e5e7eb);border-radius:999px;'
1579
+ + 'background:var(--surface-2,#f3f4f6);color:var(--text,#111);'
1580
+ + 'font-size:11px;line-height:1.4;white-space:nowrap;';
1581
+ const inner = chips.map(([label, value]) =>
1582
+ `<span class="model-source-chip" style="${chipStyle}"><b style="font-weight:700;opacity:0.7">${escapeHtml(label)}:</b> ${escapeHtml(String(value))}</span>`
1583
+ ).join('');
1584
+ return `<span class="model-source-chips" style="display:flex;flex-wrap:wrap;gap:4px;margin:4px 0 2px">${inner}</span>`;
1585
+ }
1586
+
1587
+ // 한 줄짜리 평문 출처(국가 · 회사 · 실행 · 인터넷) — 좁은 행/요약용.
1588
+ function modelSourceLine(model) {
1589
+ if (!model) return '';
1590
+ return [
1591
+ model.source_country,
1592
+ model.source_company,
1593
+ model.execution_method,
1594
+ model.internet_requirement,
1595
+ ].filter(value => value != null && String(value).trim() !== '').join(' · ');
1596
+ }
1597
+
1558
1598
  function normalizedFamily(model) {
1559
1599
  const raw = `${model?.family || ''} ${model?.name || ''} ${model?.id || ''}`.toLowerCase();
1560
1600
  if (raw.includes('gpt')) return 'GPT';
@@ -3568,10 +3608,18 @@ const chatViewport = document.getElementById('chat-viewport');
3568
3608
 
3569
3609
  function attachDocument(input) {
3570
3610
  const file = input.files[0];
3611
+ if (!file) return;
3612
+ attachDocumentFile(file);
3613
+ input.value = '';
3614
+ }
3615
+
3616
+ // 파일을 직접 첨부 (드래그앤드롭 / 붙여넣기 / 파일 선택 공용 경로)
3617
+ function attachDocumentFile(file) {
3571
3618
  if (!file) return;
3572
3619
  attachedDocFile = file;
3573
3620
  attachedDocContent = null;
3574
3621
  const row = document.getElementById('attach-preview-row');
3622
+ if (!row) return;
3575
3623
  row.style.display = 'flex';
3576
3624
  row.innerHTML = `
3577
3625
  <div class="attach-chip">
@@ -3580,9 +3628,30 @@ const chatViewport = document.getElementById('chat-viewport');
3580
3628
  <button onclick="removeAttachedDoc()" title="제거">×</button>
3581
3629
  </div>
3582
3630
  <span style="font-size:11px;color:var(--muted);align-self:center">첨부됨 — 전송 시 AI가 파일을 읽습니다</span>`;
3583
- input.value = '';
3584
3631
  }
3585
3632
 
3633
+ // 채팅 영역에 파일을 끌어다 놓으면 첨부 (Drag & Drop)
3634
+ (function setupChatDropZone() {
3635
+ const zone = document.querySelector('.main-chat') || document.body;
3636
+ if (!zone) return;
3637
+ const hasFiles = (e) => e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types || [], 'Files') !== -1;
3638
+ ['dragenter', 'dragover'].forEach(ev => zone.addEventListener(ev, (e) => {
3639
+ if (!hasFiles(e)) return;
3640
+ e.preventDefault();
3641
+ zone.classList.add('drag-over');
3642
+ }));
3643
+ zone.addEventListener('dragleave', (e) => {
3644
+ if (e.target === zone) zone.classList.remove('drag-over');
3645
+ });
3646
+ zone.addEventListener('drop', (e) => {
3647
+ zone.classList.remove('drag-over');
3648
+ if (!hasFiles(e)) return;
3649
+ e.preventDefault();
3650
+ const file = e.dataTransfer.files && e.dataTransfer.files[0];
3651
+ if (file) attachDocumentFile(file);
3652
+ });
3653
+ })();
3654
+
3586
3655
  function removeAttachedDoc() {
3587
3656
  attachedDocFile = null;
3588
3657
  attachedDocContent = null;
@@ -181,6 +181,7 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
181
181
  let rawGraph = { nodes: [], edges: [] };
182
182
  let graph = { nodes: [], edges: [] };
183
183
  let hiddenTypes = new Set();
184
+ let hiddenEdgeTypes = new Set();
184
185
  let selected = null;
185
186
  let hovered = null;
186
187
  let dragging = null;
@@ -500,6 +501,21 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
500
501
  window.runLocalIndex = runLocalIndex;
501
502
  window.approveLocalPermission = approveLocalPermission;
502
503
 
504
+ /* 테마 색상 — CSS 변수에서 캔버스 배경/텍스트를 읽어 다크모드 대응 */
505
+ let themeColors = { bg: '#ffffff', text: '#14162c', surface: '#ffffff' };
506
+ function refreshThemeColors() {
507
+ const cs = getComputedStyle(document.documentElement);
508
+ const read = (name, fallback) => {
509
+ const v = (cs.getPropertyValue(name) || '').trim();
510
+ return v || fallback;
511
+ };
512
+ themeColors = {
513
+ bg: read('--bg', '#ffffff'),
514
+ text: read('--text', '#14162c'),
515
+ surface: read('--surface', read('--surface-2', '#ffffff')),
516
+ };
517
+ }
518
+
503
519
  function nodeColor(type) {
504
520
  return (TYPE_CONFIG[type] || {}).color || '#8fa8bb';
505
521
  }
@@ -641,6 +657,7 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
641
657
  const byId = Object.fromEntries(rawGraph.nodes.map(node => [node.id, node]));
642
658
  graph.edges = rawGraph.edges
643
659
  .filter(edge => nodeSet.has(edge.from) && nodeSet.has(edge.to))
660
+ .filter(edge => !hiddenEdgeTypes.has(edge.type))
644
661
  .map(edge => ({ ...edge, source: byId[edge.from], target: byId[edge.to] }));
645
662
  renderFocusChip();
646
663
  }
@@ -808,16 +825,26 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
808
825
  }
809
826
  container.innerHTML = ordered.map(type => {
810
827
  const style = edgeStyle(type);
828
+ const checked = hiddenEdgeTypes.has(type) ? '' : 'checked';
811
829
  return `
812
- <div class="legend-item">
830
+ <label class="filter-item">
831
+ <input type="checkbox" ${checked} onchange="toggleEdgeType(decodeURIComponent('${encodeURIComponent(type)}'), this.checked)">
813
832
  <span class="legend-line" style="border-top-color:${style.color}; border-top-width:${Math.max(2, style.width)}px;"></span>
814
- <span class="legend-name">${escapeHtml(style.label || type)}</span>
815
- <span class="legend-meta">${edgeCounts[type] || 0}</span>
816
- </div>
833
+ <span class="filter-name">${escapeHtml(style.label || type)}</span>
834
+ <span class="filter-count">${edgeCounts[type] || 0}</span>
835
+ </label>
817
836
  `;
818
837
  }).join('');
819
838
  }
820
839
 
840
+ function toggleEdgeType(type, visible) {
841
+ if (visible) hiddenEdgeTypes.delete(type);
842
+ else hiddenEdgeTypes.add(type);
843
+ applyFilter();
844
+ wakeUp();
845
+ }
846
+ window.toggleEdgeType = toggleEdgeType;
847
+
821
848
  function toggleType(type, visible) {
822
849
  if (visible) hiddenTypes.delete(type);
823
850
  else hiddenTypes.add(type);
@@ -929,6 +956,9 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
929
956
  ctx.translate(cam.tx, cam.ty);
930
957
  ctx.scale(cam.scale, cam.scale);
931
958
 
959
+ // LOD: 줌이 너무 작거나 노드가 많으면 레이블 생략 (모바일 성능)
960
+ const showLabels = cam.scale >= 0.5 && graph.nodes.length <= 220;
961
+
932
962
  const active = hovered || selected;
933
963
  const neighborSet = active ? neighborIds(active) : null;
934
964
 
@@ -997,8 +1027,8 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
997
1027
  ctx.globalAlpha = alpha;
998
1028
  }
999
1029
 
1000
- // 레이블 항상 노드 아래에 표시
1001
- {
1030
+ // 레이블 표시 (LOD: 줌이 작거나 노드가 많으면 생략 — 모바일 성능)
1031
+ if (showLabels || isSelected || isHovered || isSearchHit) {
1002
1032
  const label = node.title.slice(0, 24);
1003
1033
  const fs = Math.max(9.5, 12 / cam.scale);
1004
1034
  ctx.font = `600 ${fs}px "SF Pro Display","Inter",system-ui`;
@@ -1008,8 +1038,9 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
1008
1038
  const ly = node.y + gap + fs;
1009
1039
  const pad = 4 / cam.scale;
1010
1040
  const br = 5 / cam.scale;
1011
- // 배경 pill
1012
- ctx.fillStyle = alpha > 0.5 ? 'rgba(255,255,255,0.88)' : 'rgba(255,255,255,0.22)';
1041
+ // 테마 대응 배경 pill (라이트=흰색, 다크=surface)
1042
+ ctx.globalAlpha = alpha > 0.5 ? alpha * 0.88 : alpha * 0.22;
1043
+ ctx.fillStyle = themeColors.surface;
1013
1044
  ctx.beginPath();
1014
1045
  if (ctx.roundRect) {
1015
1046
  ctx.roundRect(lx - pad, ly - fs, lw + pad * 2, fs + pad * 1.6, br);
@@ -1017,14 +1048,17 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
1017
1048
  ctx.rect(lx - pad, ly - fs, lw + pad * 2, fs + pad * 1.6);
1018
1049
  }
1019
1050
  ctx.fill();
1020
- ctx.fillStyle = alpha > 0.5 ? '#14162c' : 'rgba(20,22,44,0.3)';
1051
+ ctx.globalAlpha = alpha > 0.5 ? alpha : alpha * 0.3;
1052
+ ctx.fillStyle = themeColors.text;
1021
1053
  ctx.fillText(label, lx, ly);
1054
+ ctx.globalAlpha = alpha;
1022
1055
  }
1023
1056
 
1024
1057
  ctx.globalAlpha = 1;
1025
1058
  });
1026
1059
 
1027
1060
  ctx.restore();
1061
+ drawMinimap();
1028
1062
  if (kineticEnergy > 0.04 || dragging) animFrameId = requestAnimationFrame(draw);
1029
1063
  }
1030
1064
 
@@ -1627,10 +1661,134 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
1627
1661
  });
1628
1662
  });
1629
1663
 
1630
- window.addEventListener('resize', () => {
1664
+ // 리사이즈/회전/키보드(visualViewport) 캔버스 재측정 + 자동 재맞춤
1665
+ // (기존엔 backing store만 리사이즈해서 모바일에서 그래프가 화면 밖으로 나갔음)
1666
+ let resizeFitTimer = null;
1667
+ function handleViewportChange() {
1631
1668
  resize();
1632
1669
  wakeUp();
1633
- });
1670
+ clearTimeout(resizeFitTimer);
1671
+ resizeFitTimer = setTimeout(() => { resize(); fitToScreen(); }, 180);
1672
+ }
1673
+ window.addEventListener('resize', handleViewportChange);
1674
+ window.addEventListener('orientationchange', handleViewportChange);
1675
+ if (window.visualViewport) {
1676
+ window.visualViewport.addEventListener('resize', handleViewportChange);
1677
+ }
1678
+
1679
+ /* ──────────────────────────────────────────────────────────────────
1680
+ v2.2.1 그래프 1급 UI: 줌 버튼 · 전체화면 · 미니맵 · 카드뷰 · 테마대응
1681
+ ────────────────────────────────────────────────────────────────── */
1682
+ // 캔버스가 터치를 직접 소유 (브라우저 기본 제스처와 충돌 방지)
1683
+ if (canvas && canvas.style) canvas.style.touchAction = 'none';
1684
+
1685
+ function zoomBy(factor) {
1686
+ const px = width / 2, py = height / 2;
1687
+ const next = clamp(cam.scale * factor, 0.07, 6);
1688
+ cam.tx = px - (px - cam.tx) * (next / cam.scale);
1689
+ cam.ty = py - (py - cam.ty) * (next / cam.scale);
1690
+ cam.scale = next;
1691
+ wakeUp();
1692
+ }
1693
+
1694
+ const stageEl = document.querySelector('.stage');
1695
+ function toggleFullscreen() {
1696
+ const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
1697
+ if (!fsEl && stageEl) {
1698
+ (stageEl.requestFullscreen || stageEl.webkitRequestFullscreen || function () {}).call(stageEl);
1699
+ } else {
1700
+ (document.exitFullscreen || document.webkitExitFullscreen || function () {}).call(document);
1701
+ }
1702
+ }
1703
+ document.addEventListener('fullscreenchange', handleViewportChange);
1704
+ document.addEventListener('webkitfullscreenchange', handleViewportChange);
1705
+
1706
+ // 미니맵 — 전체 노드 개요 + 현재 뷰포트 사각형 (클릭 시 그 지점으로 이동)
1707
+ const minimap = document.getElementById('minimap');
1708
+ const mmCtx = minimap ? minimap.getContext('2d') : null;
1709
+ function drawMinimap() {
1710
+ if (!mmCtx || !minimap || minimap.offsetParent === null) return;
1711
+ const W = minimap.width, H = minimap.height;
1712
+ mmCtx.clearRect(0, 0, W, H);
1713
+ if (!graph.nodes.length) return;
1714
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
1715
+ graph.nodes.forEach(n => { x0 = Math.min(x0, n.x); x1 = Math.max(x1, n.x); y0 = Math.min(y0, n.y); y1 = Math.max(y1, n.y); });
1716
+ const pad = 8, gw = Math.max(1, x1 - x0), gh = Math.max(1, y1 - y0);
1717
+ const s = Math.min((W - pad * 2) / gw, (H - pad * 2) / gh);
1718
+ const ox = pad - x0 * s + (W - pad * 2 - gw * s) / 2;
1719
+ const oy = pad - y0 * s + (H - pad * 2 - gh * s) / 2;
1720
+ graph.nodes.forEach(n => {
1721
+ mmCtx.fillStyle = nodeColor(n.type);
1722
+ mmCtx.beginPath();
1723
+ mmCtx.arc(ox + n.x * s, oy + n.y * s, 1.6, 0, Math.PI * 2);
1724
+ mmCtx.fill();
1725
+ });
1726
+ const vx0 = (0 - cam.tx) / cam.scale, vy0 = (0 - cam.ty) / cam.scale;
1727
+ const vx1 = (width - cam.tx) / cam.scale, vy1 = (height - cam.ty) / cam.scale;
1728
+ mmCtx.strokeStyle = 'rgba(110,74,230,0.95)';
1729
+ mmCtx.lineWidth = 1.2;
1730
+ mmCtx.strokeRect(ox + vx0 * s, oy + vy0 * s, (vx1 - vx0) * s, (vy1 - vy0) * s);
1731
+ minimap._map = { ox, oy, s };
1732
+ }
1733
+ if (minimap) {
1734
+ minimap.addEventListener('click', (event) => {
1735
+ const m = minimap._map; if (!m) return;
1736
+ const rect = minimap.getBoundingClientRect();
1737
+ const mx = (event.clientX - rect.left) * (minimap.width / rect.width);
1738
+ const my = (event.clientY - rect.top) * (minimap.height / rect.height);
1739
+ cam.tx = width / 2 - ((mx - m.ox) / m.s) * cam.scale;
1740
+ cam.ty = height / 2 - ((my - m.oy) / m.s) * cam.scale;
1741
+ wakeUp();
1742
+ });
1743
+ }
1744
+
1745
+ // 모바일 카드 뷰 — 노드를 탭 가능한 카드 목록으로 (캔버스가 너무 빽빽할 때)
1746
+ const graphCardList = document.getElementById('graph-card-list');
1747
+ function renderGraphCards() {
1748
+ if (!graphCardList) return;
1749
+ if (!graph.nodes.length) {
1750
+ graphCardList.innerHTML = `<p class="search-empty">${t('search_empty')}</p>`;
1751
+ return;
1752
+ }
1753
+ graphCardList.innerHTML = '<div class="search-list">' + graph.nodes.slice(0, 400).map(n => `
1754
+ <button class="search-item" data-node-id="${escapeHtml(n.id)}">
1755
+ <div class="search-item-top">
1756
+ <span class="type-badge" style="background:${nodeColor(n.type)}">${escapeHtml(n.type || '')}</span>
1757
+ <span class="search-item-title">${escapeHtml(n.title || n.id)}</span>
1758
+ </div>
1759
+ ${n.summary ? `<p class="search-item-summary">${escapeHtml(n.summary)}</p>` : ''}
1760
+ </button>
1761
+ `).join('') + '</div>';
1762
+ }
1763
+ function toggleGraphCardView() {
1764
+ document.body.classList.toggle('graph-card-view');
1765
+ if (document.body.classList.contains('graph-card-view')) renderGraphCards();
1766
+ }
1767
+ if (graphCardList) {
1768
+ graphCardList.addEventListener('click', (event) => {
1769
+ const target = event.target.closest('[data-node-id]');
1770
+ if (!target) return;
1771
+ const node = graph.nodes.find(n => n.id === target.dataset.nodeId);
1772
+ if (!node) return;
1773
+ document.body.classList.remove('graph-card-view');
1774
+ selected = node;
1775
+ showDetail(node);
1776
+ centerOnNode(node, Math.max(cam.scale, 1));
1777
+ });
1778
+ }
1779
+
1780
+ // 테마(라이트/다크) 변경 시 캔버스 색상 갱신
1781
+ refreshThemeColors();
1782
+ try {
1783
+ const themeObserver = new MutationObserver(() => { refreshThemeColors(); wakeUp(); });
1784
+ themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-lt-theme'] });
1785
+ } catch (e) { /* noop */ }
1786
+
1787
+ const bindClick = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('click', fn); };
1788
+ bindClick('zoom-in-btn', () => zoomBy(1.25));
1789
+ bindClick('zoom-out-btn', () => zoomBy(1 / 1.25));
1790
+ bindClick('fullscreen-btn', toggleFullscreen);
1791
+ bindClick('view-toggle-btn', toggleGraphCardView);
1634
1792
 
1635
1793
  resize();
1636
1794
  applyI18n();