sanjang 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard/app.js CHANGED
@@ -12,6 +12,62 @@ const playgrounds = new Map();
12
12
  /** @type {Map<string, Array<{text: string, source: string}>>} logs keyed by playground name */
13
13
  const logs = new Map();
14
14
 
15
+ // ---------------------------------------------------------------------------
16
+ // Route inference — map file paths to preview routes
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Infer a preview route from a file path.
21
+ * Only works for file-based routing patterns (pages/, app/, views/).
22
+ * Returns null if no route can be inferred.
23
+ */
24
+ function inferRouteFromPath(filePath) {
25
+ const lower = filePath.toLowerCase();
26
+
27
+ // Match pages/xxx.tsx, app/xxx/page.tsx, views/xxx.vue etc.
28
+ const patterns = [
29
+ // Next.js app router: app/dashboard/page.tsx → /dashboard
30
+ { regex: /(?:^|[/\\])app[/\\](.+?)[/\\]page\.[^.]+$/, transform: m => '/' + m },
31
+ // Next.js app router: app/page.tsx → /
32
+ { regex: /(?:^|[/\\])app[/\\]page\.[^.]+$/, transform: () => '/' },
33
+ // Next.js/Nuxt pages router: pages/login.tsx → /login
34
+ { regex: /(?:^|[/\\])pages[/\\](.+?)(?:\.[^.]+)$/, transform: m => '/' + m },
35
+ // views/: views/Login.vue → /login
36
+ { regex: /(?:^|[/\\])views[/\\](.+?)(?:\.[^.]+)$/, transform: m => '/' + m },
37
+ ];
38
+
39
+ for (const { regex, transform } of patterns) {
40
+ const match = regex.exec(lower);
41
+ if (match) {
42
+ let route = transform(match[1] || '');
43
+ // Clean up: remove index, trailing slash dupes
44
+ route = route.replace(/\/index$/, '/').replace(/\/+/g, '/');
45
+ // Remove dynamic route brackets for navigation: [id] → placeholder
46
+ route = route.replace(/\[([^\]]+)\]/g, '1');
47
+ return route || '/';
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Navigate the preview iframe to a given route.
55
+ */
56
+ function navigatePreview(route) {
57
+ const iframe = document.querySelector('#ws-preview iframe');
58
+ if (!iframe) return;
59
+ try {
60
+ const base = new URL(iframe.src);
61
+ base.pathname = route;
62
+ iframe.contentWindow.location.href = base.toString();
63
+ toast(`${route} 로 이동`, 'info');
64
+ } catch {
65
+ // cross-origin — reload with new path
66
+ const src = iframe.src.replace(/\/preview\/(\d+)\/.*/, `/preview/$1${route}`);
67
+ iframe.src = src;
68
+ }
69
+ }
70
+
15
71
  /** @type {Map<string, Array>} diagnostics keyed by playground name */
16
72
  const diagnostics = new Map();
17
73
 
@@ -22,6 +78,11 @@ let currentWorkspace = null;
22
78
  /** @type {number|null} polling interval for workspace changes */
23
79
  let wsPollingInterval = null;
24
80
 
81
+ /** @type {object|null} 마지막 리포트 캐시 — 세이브 후 축소 표시용 */
82
+ let lastReport = null;
83
+
84
+ let compareMode = false;
85
+
25
86
  const SHERPA_QUOTES = [
26
87
  "요구사항 또 바뀌었댜... 뭐 그러려니 하쥬",
27
88
  "'간단한 건데~' 그 말이 제일 무섭댜",
@@ -218,6 +279,7 @@ function handleWsMessage(msg) {
218
279
  case 'playground-saved': {
219
280
  if (!name) break;
220
281
  toast(`💾 세이브됨: ${data?.message || ''}`, 'success');
282
+ if (currentWorkspace === name) transitionReportToSaved();
221
283
  break;
222
284
  }
223
285
 
@@ -225,6 +287,7 @@ function handleWsMessage(msg) {
225
287
  if (!name) break;
226
288
  toast('💾 오토세이브 완료', 'success');
227
289
  if (currentWorkspace === name) {
290
+ transitionReportToSaved();
228
291
  api('POST', `/api/playgrounds/${name}/enter`).then(renderWorkspace).catch(() => {});
229
292
  }
230
293
  break;
@@ -263,8 +326,8 @@ function handleWsMessage(msg) {
263
326
  </div>`;
264
327
  }).join('');
265
328
  renderBlocks(data.files);
266
- // Debounced AI summary fetch
267
- debounceSummaryFetch(name);
329
+ // Debounced AI report fetch
330
+ debounceReportFetch(name);
268
331
  }
269
332
 
270
333
  updateChangeSummary(data.count, data.ts);
@@ -272,6 +335,19 @@ function handleWsMessage(msg) {
272
335
  debouncePreviewRefresh();
273
336
  break;
274
337
  }
338
+
339
+ case 'compare-ready': {
340
+ if (!compareMode) break;
341
+ const mainPreview = document.getElementById('ws-preview-main');
342
+ if (mainPreview && data?.port) {
343
+ const proxyUrl = `/preview/${data.port}/`;
344
+ mainPreview.innerHTML = `
345
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
346
+ <iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>`;
347
+ toast('원본 프리뷰 준비 완료!', 'success');
348
+ }
349
+ break;
350
+ }
275
351
  }
276
352
  }
277
353
 
@@ -1153,6 +1229,14 @@ function enterWorkspace(name) {
1153
1229
  }
1154
1230
 
1155
1231
  function exitWorkspace() {
1232
+ compareMode = false;
1233
+ const compareBtn = document.getElementById('ws-compare-btn');
1234
+ if (compareBtn) compareBtn.classList.remove('btn-active');
1235
+ const mainPreview = document.getElementById('ws-preview-main');
1236
+ if (mainPreview) mainPreview.classList.add('hidden');
1237
+ const container = document.getElementById('ws-preview-container');
1238
+ if (container) container.classList.remove('ws-split-view');
1239
+ lastReport = null;
1156
1240
  currentWorkspace = null;
1157
1241
  if (wsPollingInterval) { clearInterval(wsPollingInterval); wsPollingInterval = null; }
1158
1242
  document.getElementById('workspace').classList.add('hidden');
@@ -1164,6 +1248,108 @@ function exitWorkspace() {
1164
1248
  }
1165
1249
  window.exitWorkspace = exitWorkspace;
1166
1250
 
1251
+ async function fetchAndRenderReport(campName, withAi = false) {
1252
+ const section = document.getElementById('ws-report-section');
1253
+ if (!section) return;
1254
+
1255
+ try {
1256
+ const report = await api('GET', `/api/playgrounds/${campName}/change-report${withAi ? '?ai=true' : ''}`);
1257
+
1258
+ if (report.totalCount === 0) {
1259
+ if (lastReport && lastReport.summary) {
1260
+ section.style.display = '';
1261
+ section.classList.add('ws-report-saved');
1262
+ document.getElementById('ws-report-summary').innerHTML =
1263
+ `<div class="ws-report-desc ws-report-saved-desc">✅ 마지막 세이브: ${escHtml(lastReport.summary)}</div>`;
1264
+ document.getElementById('ws-report-warnings').innerHTML = '';
1265
+ document.getElementById('ws-report-categories').innerHTML = '';
1266
+ } else {
1267
+ section.style.display = 'none';
1268
+ }
1269
+ return;
1270
+ }
1271
+
1272
+ lastReport = report;
1273
+ section.style.display = '';
1274
+ section.classList.remove('ws-report-saved');
1275
+
1276
+ const summaryEl = document.getElementById('ws-report-summary');
1277
+ const changeSummaryText = document.getElementById('ws-changes-summary-text');
1278
+ if (report.humanDescription) {
1279
+ summaryEl.innerHTML = `<div class="ws-report-desc">${escHtml(report.humanDescription)}</div>`;
1280
+ } else if (report.summary) {
1281
+ summaryEl.innerHTML = `<div class="ws-report-desc">${escHtml(report.summary)}</div>`;
1282
+ } else {
1283
+ summaryEl.innerHTML = `<div class="ws-report-desc">${report.totalCount}개 파일 변경됨</div>`;
1284
+ }
1285
+
1286
+ if (changeSummaryText && report.summary) {
1287
+ changeSummaryText.textContent = `⚠️ 저장 안 됨 — ${report.summary}`;
1288
+ }
1289
+
1290
+ const warningsEl = document.getElementById('ws-report-warnings');
1291
+ if (report.warnings.length > 0) {
1292
+ warningsEl.innerHTML = report.warnings.map(w =>
1293
+ `<div class="ws-report-warning">
1294
+ <span class="ws-report-warning-icon">⚠️</span>
1295
+ <span>${escHtml(w.message)}</span>
1296
+ </div>`
1297
+ ).join('');
1298
+ } else {
1299
+ warningsEl.innerHTML = '';
1300
+ }
1301
+
1302
+ const categoryNames = { ui: '🎨 화면', api: '⚙️ 서버', config: '🔧 설정', test: '🧪 테스트', docs: '📝 문서', other: '📦 기타' };
1303
+ const categoriesEl = document.getElementById('ws-report-categories');
1304
+ const details = report.categoryDetails || {};
1305
+ categoriesEl.innerHTML = Object.entries(report.byCategory).map(([cat, files]) => {
1306
+ const items = details[cat];
1307
+ const hasDetails = items && items.length > 0;
1308
+ return `<div class="ws-report-cat-group">
1309
+ <div class="ws-report-cat-header">
1310
+ <span class="ws-report-cat-label">${categoryNames[cat] || cat}</span>
1311
+ <span class="ws-report-cat-count">${files.length}</span>
1312
+ </div>
1313
+ ${hasDetails
1314
+ ? `<ul class="ws-report-cat-items">${items.map((item, idx) => {
1315
+ const file = files[idx];
1316
+ const route = cat === 'ui' && file ? inferRouteFromPath(file.path) : null;
1317
+ return route
1318
+ ? `<li class="ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(file.path)} → ${escHtml(route)}">${escHtml(item)} <span class="ws-report-nav-hint">→ 보기</span></li>`
1319
+ : `<li>${escHtml(item)}</li>`;
1320
+ }).join('')}</ul>`
1321
+ : `<ul class="ws-report-cat-items">${files.map(f => {
1322
+ const route = cat === 'ui' ? inferRouteFromPath(f.path) : null;
1323
+ const label = `${escHtml(f.path.split('/').pop() || f.path)} ${f.status === '새 파일' ? '추가됨' : '수정됨'}`;
1324
+ return route
1325
+ ? `<li class="ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(f.path)} → ${escHtml(route)}">${label} <span class="ws-report-nav-hint">→ 보기</span></li>`
1326
+ : `<li>${label}</li>`;
1327
+ }).join('')}</ul>`
1328
+ }
1329
+ </div>`;
1330
+ }).join('');
1331
+
1332
+ } catch {
1333
+ section.style.display = 'none';
1334
+ }
1335
+ }
1336
+
1337
+ function transitionReportToSaved() {
1338
+ const section = document.getElementById('ws-report-section');
1339
+ if (!section || !lastReport) return;
1340
+
1341
+ if (lastReport.summary) {
1342
+ section.style.display = '';
1343
+ section.classList.add('ws-report-saved');
1344
+ document.getElementById('ws-report-summary').innerHTML =
1345
+ `<div class="ws-report-desc ws-report-saved-desc">✅ 마지막 세이브: ${escHtml(lastReport.summary)}</div>`;
1346
+ document.getElementById('ws-report-warnings').innerHTML = '';
1347
+ document.getElementById('ws-report-categories').innerHTML = '';
1348
+ } else {
1349
+ section.style.display = 'none';
1350
+ }
1351
+ }
1352
+
1167
1353
  function renderWorkspace(data) {
1168
1354
  const { camp, changes, warpInstalled, previewUrl, autosave } = data;
1169
1355
 
@@ -1187,6 +1373,7 @@ function renderWorkspace(data) {
1187
1373
  saveBtn.style.display = 'none';
1188
1374
  changesEl.innerHTML = '';
1189
1375
  renderBlocks([]);
1376
+ fetchAndRenderReport(camp.name);
1190
1377
  } else {
1191
1378
  unsavedSection.classList.remove('ws-no-changes');
1192
1379
  summaryTextEl.textContent = `⚠️ 저장 안 됨 — ${changes.count}개 파일 수정 중`;
@@ -1200,10 +1387,9 @@ function renderWorkspace(data) {
1200
1387
  </div>`
1201
1388
  ).join('');
1202
1389
  renderBlocks(changes.files);
1203
- // Fetch AI summary
1204
- api('GET', `/api/playgrounds/${camp.name}/changes-summary`).then(data => {
1205
- if (data.summary) summaryTextEl.textContent = `⚠️ 저장 안 됨 — ${data.summary}`;
1206
- }).catch(() => {});
1390
+ // 먼저 fallback으로 빠르게 렌더, 이어서 AI 업그레이드
1391
+ fetchAndRenderReport(camp.name);
1392
+ fetchAndRenderReport(camp.name, true);
1207
1393
  }
1208
1394
 
1209
1395
  // Actions — show commits as work history
@@ -1211,12 +1397,22 @@ function renderWorkspace(data) {
1211
1397
  const commitList = data.commits || [];
1212
1398
  if (commitList.length > 0) {
1213
1399
  actionsEl.innerHTML = commitList.map(c =>
1214
- `<div class="ws-commit-item">
1215
- <span class="ws-commit-msg">${escHtml(c.message)}</span>
1216
- <span class="ws-commit-date">${escHtml(c.date)}</span>
1217
- <button class="btn btn-ghost btn-sm ws-revert-btn" onclick="revertCommit('${escHtml(c.hash)}')" title="이 세이브 되돌리기">↩</button>
1218
- </div>`
1400
+ `<details class="ws-commit-item" data-hash="${escHtml(c.hash)}">
1401
+ <summary class="ws-commit-summary">
1402
+ <span class="ws-commit-arrow">▶</span>
1403
+ <span class="ws-commit-msg">${escHtml(c.message)}</span>
1404
+ <span class="ws-commit-date">${escHtml(c.date)}</span>
1405
+ <button class="btn btn-ghost btn-sm ws-revert-btn" onclick="event.stopPropagation();event.preventDefault();revertCommit('${escHtml(c.hash)}')" title="이 세이브 되돌리기">↩</button>
1406
+ </summary>
1407
+ <div class="ws-commit-report"></div>
1408
+ </details>`
1219
1409
  ).join('');
1410
+ // 펼칠 때 자동으로 리포트 로드
1411
+ actionsEl.querySelectorAll('.ws-commit-item').forEach(el => {
1412
+ el.addEventListener('toggle', function() {
1413
+ if (this.open) loadCommitReport(this, this.dataset.hash);
1414
+ });
1415
+ });
1220
1416
  } else if (changes.count > 0) {
1221
1417
  actionsEl.innerHTML = '<span style="color:var(--text-muted);font-size:13px">아직 커밋 없음 (작업 중)</span>';
1222
1418
  } else {
@@ -1241,9 +1437,22 @@ function renderWorkspace(data) {
1241
1437
  previewEl.querySelector('.ws-preview-fallback').style.display = 'flex';
1242
1438
  });
1243
1439
  } else {
1244
- previewEl.innerHTML = `<span style="color:var(--text-muted);font-size:13px">
1245
- 서버가 실행 중이 아닙니다. 먼저 시작해주세요.
1246
- </span>`;
1440
+ previewEl.innerHTML = `
1441
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;user-select:none;">
1442
+ <div style="width:4px;height:4px;image-rendering:pixelated;color:transparent;box-shadow:
1443
+ /* tent peak */
1444
+ 12px 0 0 #6b7394,
1445
+ 8px 4px 0 #6b7394, 12px 4px 0 #6b7394, 16px 4px 0 #6b7394,
1446
+ 4px 8px 0 #6b7394, 8px 8px 0 #6b7394, 12px 8px 0 #6b7394, 16px 8px 0 #6b7394, 20px 8px 0 #6b7394,
1447
+ 0px 12px 0 #4a5170, 4px 12px 0 #4a5170, 8px 12px 0 #4a5170, 12px 12px 0 #4a5170, 16px 12px 0 #4a5170, 20px 12px 0 #4a5170, 24px 12px 0 #4a5170,
1448
+ /* zzz */
1449
+ 36px 0 0 #4a5170, 40px 4px 0 #4a5170, 36px 8px 0 #4a5170;
1450
+ transform:scale(2);margin-bottom:8px;
1451
+ "></div>
1452
+ <div style="color:var(--text-muted);font-size:14px;text-align:center;margin-top:24px;">
1453
+ 캠프가 자고 있어유... zzZ
1454
+ </div>
1455
+ </div>`;
1247
1456
  }
1248
1457
 
1249
1458
  // Terminal button label
@@ -1413,15 +1622,57 @@ function updateQuestProgress(hasChanges, hasSaves) {
1413
1622
  }
1414
1623
  }
1415
1624
 
1416
- let summaryFetchTimer = null;
1417
- function debounceSummaryFetch(campName) {
1418
- if (summaryFetchTimer) clearTimeout(summaryFetchTimer);
1419
- summaryFetchTimer = setTimeout(() => {
1420
- api('GET', `/api/playgrounds/${campName}/changes-summary`).then(data => {
1421
- const el = document.getElementById('ws-changes-summary-text');
1422
- if (data.summary && el) el.textContent = data.summary;
1423
- }).catch(() => {});
1424
- }, 3000);
1625
+ async function loadCommitReport(el, hash) {
1626
+ const reportEl = el.querySelector('.ws-commit-report');
1627
+ if (!reportEl || reportEl.dataset.loaded) return;
1628
+
1629
+ reportEl.innerHTML = '<div class="ws-commit-report-loading">불러오는 중...</div>';
1630
+
1631
+ try {
1632
+ const report = await api('GET', `/api/playgrounds/${currentWorkspace}/commit-report/${hash}?ai=true`);
1633
+ const categoryNames = { ui: '🎨 화면', api: '⚙️ 서버', config: '🔧 설정', test: '🧪 테스트', docs: '📝 문서', other: '📦 기타' };
1634
+ const details = report.categoryDetails || {};
1635
+
1636
+ if (report.totalCount === 0) {
1637
+ reportEl.innerHTML = '<div class="ws-commit-report-empty">변경 내용 없음</div>';
1638
+ return;
1639
+ }
1640
+
1641
+ reportEl.innerHTML = Object.entries(report.byCategory).map(([cat, files]) => {
1642
+ const items = details[cat];
1643
+ const hasDetails = items && items.length > 0;
1644
+ return `<div class="ws-commit-cat">
1645
+ <span class="ws-commit-cat-label">${categoryNames[cat] || cat}</span>
1646
+ ${hasDetails
1647
+ ? items.map((item, idx) => {
1648
+ const file = files[idx];
1649
+ const route = cat === 'ui' && file ? inferRouteFromPath(file.path) : null;
1650
+ return route
1651
+ ? `<div class="ws-commit-cat-item ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(file.path)} → ${escHtml(route)}">${escHtml(item)} <span class="ws-report-nav-hint">→ 보기</span></div>`
1652
+ : `<div class="ws-commit-cat-item">${escHtml(item)}</div>`;
1653
+ }).join('')
1654
+ : files.map(f => {
1655
+ const route = cat === 'ui' ? inferRouteFromPath(f.path) : null;
1656
+ const label = `${escHtml(f.path.split('/').pop() || f.path)} ${f.status === '새 파일' ? '추가됨' : '수정됨'}`;
1657
+ return route
1658
+ ? `<div class="ws-commit-cat-item ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(f.path)} → ${escHtml(route)}">${label} <span class="ws-report-nav-hint">→ 보기</span></div>`
1659
+ : `<div class="ws-commit-cat-item">${label}</div>`;
1660
+ }).join('')
1661
+ }
1662
+ </div>`;
1663
+ }).join('');
1664
+ reportEl.dataset.loaded = 'true';
1665
+ } catch {
1666
+ reportEl.innerHTML = '<div class="ws-commit-report-empty">불러오기 실패</div>';
1667
+ }
1668
+ }
1669
+
1670
+ let reportFetchTimer = null;
1671
+ function debounceReportFetch(campName) {
1672
+ if (reportFetchTimer) clearTimeout(reportFetchTimer);
1673
+ reportFetchTimer = setTimeout(() => {
1674
+ fetchAndRenderReport(campName, true);
1675
+ }, 5000);
1425
1676
  }
1426
1677
 
1427
1678
  let previewRefreshTimer = null;
@@ -1502,15 +1753,21 @@ function renderBrowserErrors() {
1502
1753
  const panel = document.getElementById('ws-browser-errors');
1503
1754
  if (!panel) return;
1504
1755
  const badge = document.getElementById('ws-browser-error-badge');
1756
+ const fixBtn = document.getElementById('ws-fix-btn');
1757
+ // Show fix button if there are browser errors OR server error logs
1758
+ const serverHasErrors = currentWorkspace && (logs.get(currentWorkspace) ?? [])
1759
+ .some(l => l.source !== 'frontend' && l.source !== 'task' && /error|ERR|ENOENT|ECONNREFUSED|TypeError|SyntaxError|Cannot find/i.test(l.text));
1505
1760
  if (browserErrors.length === 0) {
1506
1761
  panel.innerHTML = '<span style="color:var(--text-muted);font-size:12px">에러 없음</span>';
1507
1762
  if (badge) badge.style.display = 'none';
1763
+ if (fixBtn) fixBtn.style.display = serverHasErrors ? '' : 'none';
1508
1764
  return;
1509
1765
  }
1510
1766
  if (badge) {
1511
1767
  badge.style.display = '';
1512
1768
  badge.textContent = browserErrors.length;
1513
1769
  }
1770
+ if (fixBtn) fixBtn.style.display = '';
1514
1771
  panel.innerHTML = browserErrors.slice(-20).reverse().map(e => {
1515
1772
  const loc = e.source ? ` <span style="color:var(--text-muted)">${escHtml(e.source.split('/').pop())}:${e.line || ''}</span>` : '';
1516
1773
  return `<div class="ws-browser-error-item">
@@ -1543,13 +1800,99 @@ window.revertCommit = async function revertCommit(hash) {
1543
1800
  }
1544
1801
  };
1545
1802
 
1803
+ /**
1804
+ * Build a structured prompt from browser errors + server logs
1805
+ * that Claude Code can use to diagnose and fix the issue.
1806
+ */
1807
+ window.copyFixPrompt = async function copyFixPrompt() {
1808
+ const name = currentWorkspace;
1809
+ if (!name) return;
1810
+
1811
+ const sections = [];
1812
+
1813
+ // 1. Browser errors
1814
+ if (browserErrors.length > 0) {
1815
+ const errs = browserErrors.slice(-10).map(e => {
1816
+ let line = `[${e.level}] ${e.message}`;
1817
+ if (e.source) line += `\n 위치: ${e.source}${e.line ? ':' + e.line : ''}${e.col ? ':' + e.col : ''}`;
1818
+ return line;
1819
+ }).join('\n\n');
1820
+ sections.push(`## 브라우저 에러 (${browserErrors.length}개)\n\n${errs}`);
1821
+ }
1822
+
1823
+ // 2. Server logs (last 20 lines, stderr/error only)
1824
+ const serverLines = (logs.get(name) ?? [])
1825
+ .filter(l => l.source !== 'frontend' && l.source !== 'task')
1826
+ .slice(-20)
1827
+ .map(l => l.text.trim())
1828
+ .filter(Boolean);
1829
+ if (serverLines.length > 0) {
1830
+ sections.push(`## 서버 로그 (최근)\n\n${serverLines.join('\n')}`);
1831
+ }
1832
+
1833
+ // 3. Current changes context
1834
+ try {
1835
+ const changes = await api('GET', `/api/playgrounds/${name}/changes`);
1836
+ if (changes.files?.length > 0) {
1837
+ const fileList = changes.files.map(f => `- ${f.status} ${f.path}`).join('\n');
1838
+ sections.push(`## 현재 수정된 파일\n\n${fileList}`);
1839
+ }
1840
+ } catch { /* ignore */ }
1841
+
1842
+ if (sections.length === 0) {
1843
+ toast('복사할 에러가 없습니다', 'info');
1844
+ return;
1845
+ }
1846
+
1847
+ const prompt = `아래 에러를 분석하고 수정해줘.
1848
+
1849
+ 에러를 읽고 근본 원인을 먼저 파악한 다음, 최소한의 변경으로 고쳐줘.
1850
+ 추측하지 말고 에러 메시지와 스택 트레이스를 근거로 진단해.
1851
+ 수정 후 관련 파일만 변경하고, 변경 이유를 간단히 설명해줘.
1852
+
1853
+ ${sections.join('\n\n---\n\n')}`;
1854
+
1855
+ try {
1856
+ await navigator.clipboard.writeText(prompt);
1857
+ toast('📋 에러 프롬프트 복사 완료 — Claude Code에 붙여넣기', 'success');
1858
+ } catch {
1859
+ // Fallback for non-HTTPS
1860
+ const ta = document.createElement('textarea');
1861
+ ta.value = prompt;
1862
+ ta.style.cssText = 'position:fixed;left:-9999px';
1863
+ document.body.appendChild(ta);
1864
+ ta.select();
1865
+ document.execCommand('copy');
1866
+ ta.remove();
1867
+ toast('📋 에러 프롬프트 복사 완료 — Claude Code에 붙여넣기', 'success');
1868
+ }
1869
+ };
1870
+
1546
1871
  function clearBrowserErrors() {
1547
1872
  browserErrors.length = 0;
1548
1873
  renderBrowserErrors();
1549
1874
  }
1550
1875
 
1551
- window.wsShip = function() {
1876
+ window.wsShip = async function() {
1552
1877
  if (!currentWorkspace) return;
1878
+ // Fetch report for ship confirmation
1879
+ try {
1880
+ const report = await api('GET', `/api/playgrounds/${currentWorkspace}/change-report?ai=true`);
1881
+ const reportPreview = document.getElementById('ship-report-preview');
1882
+ if (reportPreview && report.totalCount > 0) {
1883
+ let html = '';
1884
+ if (report.humanDescription) {
1885
+ html += `<div class="ship-report-desc">${escHtml(report.humanDescription)}</div>`;
1886
+ }
1887
+ if (report.warnings.length > 0) {
1888
+ html += report.warnings.map(w =>
1889
+ `<div class="ws-report-warning"><span>⚠️</span> ${escHtml(w.message)}</div>`
1890
+ ).join('');
1891
+ }
1892
+ reportPreview.innerHTML = html;
1893
+ reportPreview.style.display = html ? '' : 'none';
1894
+ }
1895
+ } catch { /* non-blocking */ }
1553
1896
  openShipModal(currentWorkspace);
1554
1897
  };
1555
1898
 
@@ -1628,6 +1971,48 @@ window.togglePanel = function() {
1628
1971
  document.getElementById('ws-panel')?.classList.toggle('open');
1629
1972
  };
1630
1973
 
1974
+ window.toggleCompare = async function() {
1975
+ const container = document.getElementById('ws-preview-container');
1976
+ const mainPreview = document.getElementById('ws-preview-main');
1977
+ const compareBtn = document.getElementById('ws-compare-btn');
1978
+ if (!container || !mainPreview) return;
1979
+
1980
+ compareMode = !compareMode;
1981
+
1982
+ if (compareMode) {
1983
+ compareBtn.classList.add('btn-active');
1984
+ mainPreview.classList.remove('hidden');
1985
+ container.classList.add('ws-split-view');
1986
+
1987
+ toast('원본 서버를 준비하고 있어요...', 'info');
1988
+ try {
1989
+ const state = await api('POST', '/api/compare/start');
1990
+ if (state.status === 'running' && state.port) {
1991
+ const proxyUrl = `/preview/${state.port}/`;
1992
+ mainPreview.innerHTML = `
1993
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
1994
+ <iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>`;
1995
+ } else if (state.status === 'starting') {
1996
+ mainPreview.innerHTML = `
1997
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
1998
+ <div class="ws-preview-loading">준비 중...</div>`;
1999
+ } else {
2000
+ mainPreview.innerHTML = `
2001
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
2002
+ <div class="ws-preview-loading">원본 서버를 시작하지 못했어요</div>`;
2003
+ }
2004
+ } catch {
2005
+ mainPreview.innerHTML = `
2006
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
2007
+ <div class="ws-preview-loading">원본 서버를 시작하지 못했어요</div>`;
2008
+ }
2009
+ } else {
2010
+ compareBtn.classList.remove('btn-active');
2011
+ mainPreview.classList.add('hidden');
2012
+ container.classList.remove('ws-split-view');
2013
+ }
2014
+ };
2015
+
1631
2016
  // ---------------------------------------------------------------------------
1632
2017
  // Init
1633
2018
  // ---------------------------------------------------------------------------
@@ -1737,150 +2122,643 @@ window.autoFix = async function autoFix(name) {
1737
2122
  };
1738
2123
 
1739
2124
  // ---------------------------------------------------------------------------
1740
- // Onboarding Tutorial
2125
+ // Sherpa Guide Mode (replaces overlay onboarding)
1741
2126
  // ---------------------------------------------------------------------------
1742
2127
 
1743
2128
  const ONBOARDING_KEY = 'sanjang-onboarded';
1744
2129
 
1745
- const onboardingSteps = [
1746
- {
1747
- target: '#quickstart-input',
1748
- title: '캠프 만들기',
1749
- text: '하고 싶은 입력하면 AI가 캠프를 자동으로 만들어줘요.',
1750
- position: 'bottom',
1751
- },
1752
- {
1753
- target: '#ws-preview',
1754
- title: '프리뷰 확인',
1755
- text: '캠프에 들어가면 전체화면으로 프리뷰를 볼 수 있어요.',
1756
- position: 'center',
1757
- waitForWorkspace: true,
1758
- },
1759
- {
1760
- target: '#ws-save-btn',
1761
- title: '세이브하기',
1762
- text: '변경사항이 있으면 세이브 버튼으로 저장해요. 게임 세이브처럼요!',
1763
- position: 'left',
1764
- waitForWorkspace: true,
1765
- },
2130
+ const SHERPA_GUIDE = [
2131
+ "여기에 하고 싶은 거 적으면 되유. AI가 캠프 만들어줄겨.",
2132
+ "캠프 들어가면 프리뷰 전체화면으로 보여유. 편하쥬?",
2133
+ "세이브는 게임 세이브처럼 저장이여유. 💾 버튼 누르면 되유.",
2134
+ "팀에 보내기 누르면 PR 만들어주유. 셰르파가 해줄겨.",
2135
+ "그럼 이제 시작해봐유. 화이팅이여유~ 🏔️",
1766
2136
  ];
1767
2137
 
1768
- function showOnboarding() {
1769
- if (localStorage.getItem(ONBOARDING_KEY)) return;
1770
- let step = 0;
2138
+ // ---------------------------------------------------------------------------
2139
+ // Sherpa Mode System (guide ↔ grumpy toggle)
2140
+ // ---------------------------------------------------------------------------
1771
2141
 
1772
- function show() {
1773
- // Remove previous
1774
- document.querySelector('.onboarding-overlay')?.remove();
2142
+ let sherpaInterval = null;
2143
+ let sherpaMode = 'grumpy'; // 'guide' or 'grumpy'
2144
+ let sherpaQueue = [];
2145
+ let sherpaIdx = 0;
1775
2146
 
1776
- if (step >= onboardingSteps.length) {
1777
- localStorage.setItem(ONBOARDING_KEY, '1');
1778
- return;
1779
- }
2147
+ function shuffleArray(arr) {
2148
+ const a = [...arr];
2149
+ for (let i = a.length - 1; i > 0; i--) {
2150
+ const j = Math.floor(Math.random() * (i + 1));
2151
+ [a[i], a[j]] = [a[j], a[i]];
2152
+ }
2153
+ return a;
2154
+ }
1780
2155
 
1781
- const s = onboardingSteps[step];
2156
+ function setSherpaMode(mode) {
2157
+ sherpaMode = mode;
2158
+ sherpaIdx = 0;
2159
+ sherpaQueue = mode === 'guide' ? [...SHERPA_GUIDE] : shuffleArray(SHERPA_QUOTES);
1782
2160
 
1783
- // Skip workspace steps if not in workspace
1784
- if (s.waitForWorkspace && !currentWorkspace) {
1785
- step++;
1786
- show();
1787
- return;
1788
- }
2161
+ const el = document.getElementById('sherpa-quote');
2162
+ const speech = document.getElementById('sherpa-speech');
2163
+ if (!el || !speech) return;
1789
2164
 
1790
- const el = document.querySelector(s.target);
1791
- if (!el) { step++; show(); return; }
1792
-
1793
- const overlay = document.createElement('div');
1794
- overlay.className = 'onboarding-overlay';
1795
-
1796
- const rect = el.getBoundingClientRect();
1797
- const highlight = document.createElement('div');
1798
- highlight.className = 'onboarding-highlight';
1799
- highlight.style.top = `${rect.top - 4}px`;
1800
- highlight.style.left = `${rect.left - 4}px`;
1801
- highlight.style.width = `${rect.width + 8}px`;
1802
- highlight.style.height = `${rect.height + 8}px`;
1803
- overlay.appendChild(highlight);
1804
-
1805
- const tooltip = document.createElement('div');
1806
- tooltip.className = 'onboarding-tooltip';
1807
- tooltip.innerHTML = `
1808
- <div class="onboarding-title">${s.title}</div>
1809
- <div class="onboarding-text">${s.text}</div>
1810
- <div class="onboarding-actions">
1811
- <span class="onboarding-step">${step + 1}/${onboardingSteps.length}</span>
1812
- <button class="btn btn-ghost btn-sm" onclick="skipOnboarding()">건너뛰기</button>
1813
- <button class="btn btn-primary btn-sm" onclick="nextOnboardingStep()">${step === onboardingSteps.length - 1 ? '완료' : '다음'}</button>
1814
- </div>`;
2165
+ // Visual mode indicator
2166
+ speech.classList.toggle('guide-mode', mode === 'guide');
1815
2167
 
1816
- // Position tooltip near target
1817
- if (s.position === 'bottom') {
1818
- tooltip.style.top = `${rect.bottom + 12}px`;
1819
- tooltip.style.left = `${Math.max(12, rect.left)}px`;
1820
- } else if (s.position === 'left') {
1821
- tooltip.style.top = `${rect.top}px`;
1822
- tooltip.style.right = `${window.innerWidth - rect.left + 12}px`;
1823
- } else {
1824
- tooltip.style.top = '50%';
1825
- tooltip.style.left = '50%';
1826
- tooltip.style.transform = 'translate(-50%, -50%)';
2168
+ // Fade transition to first message
2169
+ el.style.opacity = '0';
2170
+ setTimeout(() => {
2171
+ el.textContent = sherpaQueue[0];
2172
+ el.style.opacity = '1';
2173
+ }, 300);
2174
+ }
2175
+
2176
+ function advanceSherpa() {
2177
+ const el = document.getElementById('sherpa-quote');
2178
+ if (!el) return;
2179
+
2180
+ el.style.opacity = '0';
2181
+ setTimeout(() => {
2182
+ sherpaIdx++;
2183
+ if (sherpaIdx >= sherpaQueue.length) {
2184
+ if (sherpaMode === 'guide') {
2185
+ // Guide done → switch to grumpy
2186
+ localStorage.setItem(ONBOARDING_KEY, '1');
2187
+ setSherpaMode('grumpy');
2188
+ return;
2189
+ }
2190
+ // Reshuffle grumpy quotes
2191
+ sherpaQueue = shuffleArray(SHERPA_QUOTES);
2192
+ sherpaIdx = 0;
1827
2193
  }
2194
+ el.textContent = sherpaQueue[sherpaIdx];
2195
+ el.style.opacity = '1';
2196
+ }, 500);
2197
+ }
2198
+
2199
+ // ---------------------------------------------------------------------------
2200
+ // Basecamp Scene — Time-based Himalaya SVG
2201
+ // ---------------------------------------------------------------------------
2202
+
2203
+ const SCENE_THEMES = {
2204
+ dawn: {
2205
+ skyGradient: [['0%','#050810'],['60%','#0a1028'],['100%','#141830']],
2206
+ farRange: '#1a2040',
2207
+ midRange: '#141a30',
2208
+ ground: '#12151e',
2209
+ snowColor: 'rgba(200,215,240,0.35)',
2210
+ snowHighlight: 'rgba(220,230,250,0.4)',
2211
+ },
2212
+ morning: {
2213
+ skyGradient: [['0%','#1a2540'],['40%','#2d3a5c'],['70%','#5c4a6e'],['100%','#c4785a']],
2214
+ farRange: '#2a3058',
2215
+ midRange: '#1e2444',
2216
+ ground: '#12151e',
2217
+ snowColor: 'rgba(255,220,180,0.45)',
2218
+ snowHighlight: 'rgba(255,200,150,0.55)',
2219
+ },
2220
+ day: {
2221
+ skyGradient: [['0%','#1a3050'],['50%','#2a4a6a'],['100%','#3a5a7a']],
2222
+ farRange: '#253a58',
2223
+ midRange: '#1a2e48',
2224
+ ground: '#12151e',
2225
+ snowColor: 'rgba(255,255,255,0.5)',
2226
+ snowHighlight: 'rgba(255,255,255,0.6)',
2227
+ },
2228
+ evening: {
2229
+ skyGradient: [['0%','#060810'],['40%','#0e1530'],['75%','#1a1535'],['100%','#12151e']],
2230
+ farRange: '#182040',
2231
+ midRange: '#121830',
2232
+ ground: '#12151e',
2233
+ snowColor: 'rgba(200,180,230,0.35)',
2234
+ snowHighlight: 'rgba(220,200,240,0.4)',
2235
+ },
2236
+ };
1828
2237
 
1829
- overlay.appendChild(tooltip);
1830
- document.body.appendChild(overlay);
2238
+ function renderBasecampScene() {
2239
+ const container = document.getElementById('bc-scene-container');
2240
+ if (!container) return;
2241
+
2242
+ const P = 4; // pixel size (4px grid)
2243
+ const hour = new Date().getHours();
2244
+ let period;
2245
+ if (hour >= 0 && hour < 6) period = 'dawn';
2246
+ else if (hour >= 6 && hour < 12) period = 'morning';
2247
+ else if (hour >= 12 && hour < 18) period = 'day';
2248
+ else period = 'evening';
2249
+
2250
+ const theme = SCENE_THEMES[period];
2251
+
2252
+ // Build gradient stops
2253
+ const stops = theme.skyGradient.map(([offset, color]) =>
2254
+ `<stop offset="${offset}" stop-color="${color}"/>`
2255
+ ).join('');
2256
+
2257
+ // --- Stars ---
2258
+ let starsHtml = '';
2259
+ if (period === 'dawn') {
2260
+ const starPositions = [
2261
+ [45,18],[120,30],[200,12],[280,25],[360,8],[440,22],[520,15],[590,28],[150,40],[400,35],[60,42],[500,38],[330,10]
2262
+ ];
2263
+ starsHtml = starPositions.map(([x,y]) =>
2264
+ `<rect x="${x}" y="${y}" width="2" height="2" fill="#fff" opacity="${0.4 + Math.random()*0.4}"><animate attributeName="opacity" values="${0.3};${0.8};${0.3}" dur="${1.5 + Math.random()*2}s" repeatCount="indefinite"/></rect>`
2265
+ ).join('');
2266
+ // Crescent moon (pixel)
2267
+ starsHtml += `
2268
+ <rect x="576" y="20" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
2269
+ <rect x="580" y="16" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
2270
+ <rect x="580" y="20" width="${P}" height="${P}" fill="#c8cee6" opacity="0.25"/>
2271
+ <rect x="584" y="16" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
2272
+ <rect x="584" y="20" width="${P}" height="${P}" fill="#c8cee6" opacity="0.15"/>
2273
+ <rect x="588" y="20" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
2274
+ <rect x="580" y="24" width="${P}" height="${P}" fill="#c8cee6" opacity="0.25"/>
2275
+ <rect x="584" y="24" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
2276
+ `;
2277
+ } else if (period === 'morning') {
2278
+ const starPositions = [[120,20],[400,15],[550,30]];
2279
+ starsHtml = starPositions.map(([x,y]) =>
2280
+ `<rect x="${x}" y="${y}" width="2" height="2" fill="#fff" opacity="0.2"/>`
2281
+ ).join('');
2282
+ } else if (period === 'day') {
2283
+ // Pixel clouds (4px grid)
2284
+ starsHtml = `
2285
+ <g opacity="0.12">
2286
+ <rect x="104" y="28" width="8" height="4" fill="#fff"/>
2287
+ <rect x="100" y="32" width="16" height="4" fill="#fff"/>
2288
+ <rect x="108" y="36" width="4" height="4" fill="#fff"/>
2289
+ <rect x="436" y="24" width="12" height="4" fill="#fff"/>
2290
+ <rect x="432" y="28" width="20" height="4" fill="#fff"/>
2291
+ <rect x="440" y="32" width="8" height="4" fill="#fff"/>
2292
+ </g>
2293
+ `;
2294
+ } else {
2295
+ const starPositions = [
2296
+ [80,15],[160,35],[250,10],[340,28],[430,18],[510,32],[590,12],[140,45],[380,40],[620,25]
2297
+ ];
2298
+ starsHtml = starPositions.map(([x,y]) =>
2299
+ `<rect x="${x}" y="${y}" width="2" height="2" fill="#fff" opacity="${0.3 + Math.random()*0.5}"><animate attributeName="opacity" values="${0.2};${0.7};${0.2}" dur="${2 + Math.random()*2}s" repeatCount="indefinite"/></rect>`
2300
+ ).join('');
1831
2301
  }
1832
2302
 
1833
- window.nextOnboardingStep = function() {
1834
- step++;
1835
- show();
1836
- };
2303
+ // --- Mountains ---
2304
+ const farRangePoly = `0,220 0,150 20,150 20,140 40,140 40,125 55,125 55,115 70,115 70,105 85,105 85,95
2305
+ 95,95 95,85 105,85 105,80 115,80 115,85 125,85 125,95 135,95 135,105
2306
+ 150,105 150,115 165,115 165,125 180,125 180,135 200,135 200,145 220,145
2307
+ 220,135 235,135 235,120 250,120 250,105 260,105 260,90 270,90 270,78 280,78
2308
+ 280,70 288,70 288,62 295,62 295,56 302,56 302,50 308,50 308,45 314,45
2309
+ 314,50 320,50 320,56 326,56 326,65 335,65 335,78 345,78 345,90
2310
+ 355,90 355,105 370,105 370,120 385,120 385,135 400,135
2311
+ 400,125 415,125 415,110 425,110 425,98 435,98 435,88 445,88 445,78
2312
+ 450,78 450,70 456,70 456,64 462,64 462,58 466,58 466,54 470,54
2313
+ 470,50 474,50 474,46 478,46 478,50 482,50 482,56 486,56
2314
+ 486,64 492,64 492,72 498,72 498,82 508,82 508,95 518,95
2315
+ 518,108 530,108 530,120 545,120 545,135 560,135
2316
+ 560,125 570,125 570,112 580,112 580,100 590,100 590,88 598,88 598,78
2317
+ 605,78 605,70 612,70 612,76 618,76 618,85 625,85 625,95
2318
+ 635,95 635,108 645,108 645,120 658,120 658,135 680,135 680,220`;
2319
+
2320
+ const midRangePoly = `0,220 0,170 30,170 30,160 60,160 60,152 80,152 80,160 110,160 110,168
2321
+ 140,168 140,158 160,158 160,148 175,148 175,140 188,140 188,135 198,135 198,140
2322
+ 210,140 210,150 230,150 230,162 260,162 260,155 280,155 280,145 295,145 295,138
2323
+ 310,138 310,145 330,145 330,155 350,155 350,165
2324
+ 380,165 380,155 400,155 400,148 415,148 415,140 425,140 425,135 432,135 432,140
2325
+ 440,140 440,150 460,150 460,160 480,160 480,168
2326
+ 510,168 510,158 530,158 530,148 545,148 545,142 555,142 555,148
2327
+ 565,148 565,158 585,158 585,165 610,165 610,158 630,158 630,165 660,165 660,170 680,170 680,220`;
2328
+
2329
+ // --- Snow caps (4px grid) ---
2330
+ const snowCaps = `
2331
+ <!-- Main peak snow -->
2332
+ <rect x="308" y="45" width="${P*2}" height="${P}" fill="${theme.snowHighlight}"/>
2333
+ <rect x="304" y="49" width="${P*4}" height="${P}" fill="${theme.snowColor}"/>
2334
+ <!-- Second peak snow -->
2335
+ <rect x="474" y="46" width="${P*2}" height="${P}" fill="${theme.snowHighlight}"/>
2336
+ <rect x="470" y="50" width="${P*4}" height="${P}" fill="${theme.snowColor}"/>
2337
+ <!-- Smaller peaks -->
2338
+ <rect x="104" y="80" width="${P*2}" height="${P}" fill="${theme.snowHighlight}"/>
2339
+ <rect x="604" y="70" width="${P*2}" height="${P}" fill="${theme.snowHighlight}"/>
2340
+ <!-- Mid-range snow -->
2341
+ <rect x="188" y="135" width="${P*2}" height="${P}" fill="${theme.snowColor}" opacity="0.5"/>
2342
+ <rect x="424" y="135" width="${P*2}" height="${P}" fill="${theme.snowColor}" opacity="0.5"/>
2343
+ <rect x="544" y="142" width="${P*2}" height="${P}" fill="${theme.snowColor}" opacity="0.5"/>
2344
+ `;
2345
+
2346
+ // --- Ground + texture ---
2347
+ const groundHtml = `
2348
+ <rect x="0" y="185" width="680" height="35" fill="${theme.ground}"/>
2349
+ <rect x="50" y="188" width="8" height="3" fill="#1a1d28" opacity="0.5"/>
2350
+ <rect x="200" y="190" width="6" height="2" fill="#1a1d28" opacity="0.4"/>
2351
+ <rect x="350" y="187" width="10" height="3" fill="#1a1d28" opacity="0.5"/>
2352
+ <rect x="500" y="191" width="7" height="2" fill="#1a1d28" opacity="0.4"/>
2353
+ <rect x="620" y="189" width="5" height="3" fill="#1a1d28" opacity="0.5"/>
2354
+ `;
2355
+
2356
+ // --- Shared basecamp elements (all 4px grid pixel art) ---
2357
+
2358
+ const tents = `
2359
+ <!-- Yellow expedition tent (pixel pyramid) -->
2360
+ <g>
2361
+ <rect x="88" y="172" width="${P}" height="${P}" fill="#c8a820"/>
2362
+ <rect x="84" y="176" width="${P}" height="${P}" fill="#c8a820"/>
2363
+ <rect x="88" y="176" width="${P}" height="${P}" fill="#a08818"/>
2364
+ <rect x="92" y="176" width="${P}" height="${P}" fill="#c8a820"/>
2365
+ <rect x="80" y="180" width="${P}" height="${P}" fill="#c8a820"/>
2366
+ <rect x="84" y="180" width="${P}" height="${P}" fill="#c8a820"/>
2367
+ <rect x="88" y="180" width="${P}" height="${P}" fill="#2c2210"/>
2368
+ <rect x="92" y="180" width="${P}" height="${P}" fill="#c8a820"/>
2369
+ <rect x="96" y="180" width="${P}" height="${P}" fill="#c8a820"/>
2370
+ </g>
2371
+ <!-- Blue dome tent (pixel) -->
2372
+ <g>
2373
+ <rect x="520" y="176" width="${P}" height="${P}" fill="#2855a0"/>
2374
+ <rect x="524" y="176" width="${P}" height="${P}" fill="#2855a0"/>
2375
+ <rect x="516" y="180" width="${P}" height="${P}" fill="#2855a0"/>
2376
+ <rect x="520" y="180" width="${P}" height="${P}" fill="#2855a0"/>
2377
+ <rect x="524" y="180" width="${P}" height="${P}" fill="#1a2040"/>
2378
+ <rect x="528" y="180" width="${P}" height="${P}" fill="#2855a0"/>
2379
+ <rect x="532" y="180" width="${P}" height="${P}" fill="#2855a0"/>
2380
+ </g>
2381
+ <!-- Green small tent (pixel) -->
2382
+ <g>
2383
+ <rect x="580" y="176" width="${P}" height="${P}" fill="#1e8040"/>
2384
+ <rect x="576" y="180" width="${P}" height="${P}" fill="#1e8040"/>
2385
+ <rect x="580" y="180" width="${P}" height="${P}" fill="#166030"/>
2386
+ <rect x="584" y="180" width="${P}" height="${P}" fill="#1e8040"/>
2387
+ </g>
2388
+ <!-- Red expedition tent (pixel) -->
2389
+ <g>
2390
+ <rect x="448" y="172" width="${P}" height="${P}" fill="#b83030"/>
2391
+ <rect x="444" y="176" width="${P}" height="${P}" fill="#b83030"/>
2392
+ <rect x="448" y="176" width="${P}" height="${P}" fill="#902020"/>
2393
+ <rect x="452" y="176" width="${P}" height="${P}" fill="#b83030"/>
2394
+ <rect x="440" y="180" width="${P}" height="${P}" fill="#b83030"/>
2395
+ <rect x="444" y="180" width="${P}" height="${P}" fill="#b83030"/>
2396
+ <rect x="448" y="180" width="${P}" height="${P}" fill="#401010"/>
2397
+ <rect x="452" y="180" width="${P}" height="${P}" fill="#b83030"/>
2398
+ <rect x="456" y="180" width="${P}" height="${P}" fill="#b83030"/>
2399
+ </g>
2400
+ `;
2401
+
2402
+ const flagColors = ['#e74c3c','#f39c12','#fff','#2ecc71','#3498db'];
2403
+ const prayerFlags1 = flagColors.map((c, i) =>
2404
+ `<rect x="${156 + i*8}" y="172" width="${P}" height="${P}" fill="${c}" opacity="0.7"/>`
2405
+ ).join('') + flagColors.map((c, i) =>
2406
+ `<rect x="${156 + i*8}" y="168" width="${P}" height="1" fill="#4a5170" opacity="0.5"/>`
2407
+ ).join('');
2408
+
2409
+ const prayerFlags2 = flagColors.map((c, i) =>
2410
+ `<rect x="${420 + i*8}" y="168" width="${P}" height="${P}" fill="${c}" opacity="0.6"/>`
2411
+ ).join('') + flagColors.map((c, i) =>
2412
+ `<rect x="${420 + i*8}" y="164" width="${P}" height="1" fill="#4a5170" opacity="0.4"/>`
2413
+ ).join('');
2414
+
2415
+ const supplies = `
2416
+ <!-- Supply crates (pixel) -->
2417
+ <rect x="128" y="180" width="${P*2}" height="${P}" fill="#6b4a28"/>
2418
+ <rect x="128" y="180" width="${P*2}" height="1" fill="#8b6a38"/>
2419
+ <rect x="128" y="176" width="${P*2}" height="${P}" fill="#5a3a20"/>
2420
+ <rect x="128" y="176" width="${P*2}" height="1" fill="#7a5a30"/>
2421
+ <rect x="136" y="180" width="${P}" height="${P}" fill="#5a3a20"/>
2422
+ <!-- Oxygen tanks (pixel) -->
2423
+ <rect x="472" y="180" width="${P}" height="${P}" fill="#4a6a8a"/>
2424
+ <rect x="472" y="176" width="${P}" height="${P}" fill="#6a8aaa"/>
2425
+ <rect x="476" y="180" width="${P}" height="${P}" fill="#4a6a8a"/>
2426
+ <rect x="476" y="176" width="${P}" height="${P}" fill="#6a8aaa"/>
2427
+ <!-- Signpost (pixel) -->
2428
+ <rect x="300" y="172" width="${P}" height="${P*3}" fill="#5a4a30"/>
2429
+ <rect x="296" y="172" width="${P*3}" height="${P}" fill="#6b5a38"/>
2430
+ <rect x="308" y="173" width="${P}" height="2" fill="#6b5a38"/>
2431
+ <!-- Rope coil (pixel) -->
2432
+ <rect x="600" y="176" width="${P}" height="${P}" fill="#8b7a50"/>
2433
+ <rect x="604" y="176" width="${P}" height="${P}" fill="#8b7a50"/>
2434
+ <rect x="596" y="180" width="${P}" height="${P}" fill="#8b7a50"/>
2435
+ <rect x="608" y="180" width="${P}" height="${P}" fill="#8b7a50"/>
2436
+ <rect x="600" y="184" width="${P}" height="${P}" fill="#8b7a50"/>
2437
+ <rect x="604" y="184" width="${P}" height="${P}" fill="#8b7a50"/>
2438
+ <!-- Ice axe (pixel) -->
2439
+ <rect x="144" y="168" width="${P}" height="${P}" fill="#8090b0"/>
2440
+ <rect x="144" y="172" width="${P}" height="${P}" fill="#6b5a38"/>
2441
+ <rect x="144" y="176" width="${P}" height="${P}" fill="#6b5a38"/>
2442
+ <rect x="140" y="168" width="${P}" height="${P}" fill="#aab0c0"/>
2443
+ `;
2444
+
2445
+ // --- Stone ring around campfire (4px grid) ---
2446
+ const stoneRing = `
2447
+ <rect x="320" y="184" width="${P}" height="${P}" fill="#3a3a40"/>
2448
+ <rect x="324" y="184" width="${P}" height="${P}" fill="#454550"/>
2449
+ <rect x="336" y="184" width="${P}" height="${P}" fill="#454550"/>
2450
+ <rect x="340" y="184" width="${P}" height="${P}" fill="#3a3a40"/>
2451
+ <rect x="318" y="180" width="${P}" height="${P}" fill="#454550"/>
2452
+ <rect x="342" y="180" width="${P}" height="${P}" fill="#3a3a40"/>
2453
+ `;
2454
+
2455
+ // --- Campfire (period-specific) ---
2456
+ let campfireHtml = '';
2457
+ if (period === 'dawn') {
2458
+ // Dim embers (pixel)
2459
+ campfireHtml = `
2460
+ ${stoneRing}
2461
+ <rect x="328" y="180" width="${P}" height="${P}" fill="#8b2200" opacity="0.5"/>
2462
+ <rect x="332" y="180" width="${P}" height="${P}" fill="#a03000" opacity="0.4"/>
2463
+ `;
2464
+ } else if (period === 'morning') {
2465
+ // Smoke only (pixel)
2466
+ campfireHtml = `
2467
+ ${stoneRing}
2468
+ <rect x="328" y="180" width="${P}" height="${P}" fill="#5a4a30"/>
2469
+ <rect x="332" y="180" width="${P}" height="${P}" fill="#5a4a30"/>
2470
+ <rect x="328" y="176" width="${P}" height="${P}" fill="#4a5170" opacity="0.2"/>
2471
+ <rect x="332" y="172" width="${P}" height="${P}" fill="#4a5170" opacity="0.15"/>
2472
+ <rect x="328" y="168" width="${P}" height="${P}" fill="#4a5170" opacity="0.1"/>
2473
+ `;
2474
+ } else if (period === 'day') {
2475
+ // No fire, just logs (pixel)
2476
+ campfireHtml = `
2477
+ ${stoneRing}
2478
+ <rect x="324" y="180" width="${P*3}" height="${P}" fill="#5a4a30"/>
2479
+ <rect x="328" y="176" width="${P*2}" height="${P}" fill="#6b4a28"/>
2480
+ `;
2481
+ } else {
2482
+ // Full fire with glow (all 4px pixel)
2483
+ campfireHtml = `
2484
+ ${stoneRing}
2485
+ <!-- Glow (rect-based) -->
2486
+ <rect x="316" y="168" width="${P*7}" height="${P*4}" fill="#ff8c32" opacity="0.04"/>
2487
+ <rect x="320" y="172" width="${P*5}" height="${P*3}" fill="#ff6600" opacity="0.06"/>
2488
+ <!-- Logs -->
2489
+ <rect x="324" y="180" width="${P*3}" height="${P}" fill="#5a3a20"/>
2490
+ <rect x="326" y="180" width="${P*3}" height="${P}" fill="#6b4a28"/>
2491
+ <!-- Flames (pixel, animated with steps) -->
2492
+ <rect x="328" y="176" width="${P}" height="${P}" fill="#ff6600">
2493
+ <animate attributeName="opacity" values="1;0.6;1" dur="0.4s" steps="2" repeatCount="indefinite"/>
2494
+ </rect>
2495
+ <rect x="332" y="172" width="${P}" height="${P}" fill="#ffcc00">
2496
+ <animate attributeName="opacity" values="0.8;1;0.6" dur="0.5s" steps="2" repeatCount="indefinite"/>
2497
+ </rect>
2498
+ <rect x="332" y="176" width="${P}" height="${P}" fill="#ff8800"/>
2499
+ <rect x="328" y="172" width="${P}" height="${P}" fill="#ff9900" opacity="0.7">
2500
+ <animate attributeName="opacity" values="0.7;0.3;0.7" dur="0.35s" steps="2" repeatCount="indefinite"/>
2501
+ </rect>
2502
+ <rect x="336" y="176" width="${P}" height="${P}" fill="#ff6600" opacity="0.6"/>
2503
+ <rect x="330" y="168" width="${P}" height="${P}" fill="#ff6600" opacity="0.4">
2504
+ <animate attributeName="opacity" values="0.4;0.1;0.4" dur="0.6s" steps="2" repeatCount="indefinite"/>
2505
+ </rect>
2506
+ <!-- Smoke (pixel) -->
2507
+ <rect x="332" y="164" width="${P}" height="${P}" fill="#4a5170" opacity="0.15">
2508
+ <animate attributeName="opacity" values="0.15;0.05;0.15" dur="2s" repeatCount="indefinite"/>
2509
+ </rect>
2510
+ <rect x="328" y="160" width="${P}" height="${P}" fill="#4a5170" opacity="0.08"/>
2511
+ `;
2512
+ }
1837
2513
 
1838
- window.skipOnboarding = function() {
1839
- document.querySelector('.onboarding-overlay')?.remove();
1840
- localStorage.setItem(ONBOARDING_KEY, '1');
1841
- };
2514
+ // --- Sherpa (period-specific) ---
2515
+ let sherpaHtml = '';
2516
+ if (period === 'dawn') {
2517
+ // Sleeping horizontally
2518
+ sherpaHtml = `
2519
+ <g transform="translate(345, 178)">
2520
+ <!-- Body lying flat -->
2521
+ <rect x="0" y="0" width="4" height="4" fill="#3498db"/>
2522
+ <rect x="4" y="0" width="4" height="4" fill="#3498db"/>
2523
+ <rect x="8" y="0" width="4" height="4" fill="#3498db"/>
2524
+ <rect x="12" y="0" width="4" height="4" fill="#2c3e50"/>
2525
+ <rect x="16" y="0" width="4" height="4" fill="#2c3e50"/>
2526
+ <!-- Head -->
2527
+ <rect x="-4" y="-1" width="4" height="4" fill="#f5c6a0"/>
2528
+ <!-- Hat flat -->
2529
+ <rect x="-8" y="-2" width="4" height="4" fill="#e74c3c"/>
2530
+ <!-- Zzz (pixel) -->
2531
+ <rect x="8" y="-8" width="${P}" height="${P}" fill="#8888aa" opacity="0.5"/>
2532
+ <rect x="12" y="-12" width="${P}" height="${P}" fill="#8888aa" opacity="0.4"/>
2533
+ <rect x="14" y="-16" width="${P}" height="${P}" fill="#8888aa" opacity="0.3"/>
2534
+ <rect x="16" y="-20" width="${P}" height="${P}" fill="#8888aa" opacity="0.2"/>
2535
+ </g>
2536
+ `;
2537
+ } else if (period === 'morning') {
2538
+ // Stretching (arms raised)
2539
+ sherpaHtml = `
2540
+ <g transform="translate(345, 170)">
2541
+ <!-- Hat -->
2542
+ <rect x="0" y="-8" width="4" height="4" fill="#e74c3c"/>
2543
+ <rect x="-4" y="-8" width="4" height="4" fill="#e74c3c"/>
2544
+ <rect x="4" y="-8" width="4" height="4" fill="#e74c3c"/>
2545
+ <!-- Face -->
2546
+ <rect x="-4" y="-4" width="4" height="4" fill="#f5c6a0"/>
2547
+ <rect x="0" y="-4" width="4" height="4" fill="#f5c6a0"/>
2548
+ <rect x="4" y="-4" width="4" height="4" fill="#f5c6a0"/>
2549
+ <!-- Body -->
2550
+ <rect x="-4" y="0" width="4" height="4" fill="#3498db"/>
2551
+ <rect x="0" y="0" width="4" height="4" fill="#3498db"/>
2552
+ <rect x="4" y="0" width="4" height="4" fill="#3498db"/>
2553
+ <!-- Arms raised -->
2554
+ <rect x="-8" y="-8" width="4" height="4" fill="#3498db"/>
2555
+ <rect x="8" y="-4" width="4" height="4" fill="#3498db"/>
2556
+ <!-- Lower body -->
2557
+ <rect x="-4" y="4" width="4" height="4" fill="#3498db"/>
2558
+ <rect x="0" y="4" width="4" height="4" fill="#3498db"/>
2559
+ <rect x="4" y="4" width="4" height="4" fill="#3498db"/>
2560
+ <!-- Legs -->
2561
+ <rect x="-4" y="8" width="4" height="4" fill="#2c3e50"/>
2562
+ <rect x="4" y="8" width="4" height="4" fill="#2c3e50"/>
2563
+ </g>
2564
+ `;
2565
+ } else if (period === 'day') {
2566
+ // Walking pose with backpack
2567
+ sherpaHtml = `
2568
+ <g transform="translate(355, 166)">
2569
+ <!-- Hat -->
2570
+ <rect x="0" y="-8" width="4" height="4" fill="#e74c3c"/>
2571
+ <rect x="-4" y="-8" width="4" height="4" fill="#e74c3c"/>
2572
+ <rect x="4" y="-8" width="4" height="4" fill="#e74c3c"/>
2573
+ <!-- Face -->
2574
+ <rect x="-4" y="-4" width="4" height="4" fill="#f5c6a0"/>
2575
+ <rect x="0" y="-4" width="4" height="4" fill="#f5c6a0"/>
2576
+ <rect x="4" y="-4" width="4" height="4" fill="#f5c6a0"/>
2577
+ <!-- Body -->
2578
+ <rect x="-4" y="0" width="4" height="4" fill="#3498db"/>
2579
+ <rect x="0" y="0" width="4" height="4" fill="#3498db"/>
2580
+ <rect x="4" y="0" width="4" height="4" fill="#3498db"/>
2581
+ <rect x="-4" y="4" width="4" height="4" fill="#3498db"/>
2582
+ <rect x="0" y="4" width="4" height="4" fill="#3498db"/>
2583
+ <rect x="4" y="4" width="4" height="4" fill="#3498db"/>
2584
+ <!-- Backpack -->
2585
+ <rect x="8" y="0" width="4" height="4" fill="#8b6914"/>
2586
+ <rect x="8" y="4" width="4" height="4" fill="#8b6914"/>
2587
+ <!-- Legs (walking) -->
2588
+ <rect x="-4" y="8" width="4" height="4" fill="#2c3e50"/>
2589
+ <rect x="0" y="8" width="4" height="4" fill="#2c3e50"/>
2590
+ <rect x="4" y="12" width="4" height="4" fill="#2c3e50"/>
2591
+ <rect x="-4" y="12" width="4" height="4" fill="#2c3e50"/>
2592
+ </g>
2593
+ `;
2594
+ } else {
2595
+ // Sitting with mug
2596
+ sherpaHtml = `
2597
+ <g transform="translate(345, 170)">
2598
+ <!-- Hat -->
2599
+ <rect x="0" y="-8" width="4" height="4" fill="#e74c3c"/>
2600
+ <rect x="-4" y="-8" width="4" height="4" fill="#e74c3c"/>
2601
+ <rect x="4" y="-8" width="4" height="4" fill="#e74c3c"/>
2602
+ <!-- Face -->
2603
+ <rect x="-4" y="-4" width="4" height="4" fill="#f5c6a0"/>
2604
+ <rect x="0" y="-4" width="4" height="4" fill="#f5c6a0"/>
2605
+ <rect x="4" y="-4" width="4" height="4" fill="#f5c6a0"/>
2606
+ <!-- Body -->
2607
+ <rect x="-4" y="0" width="4" height="4" fill="#3498db"/>
2608
+ <rect x="0" y="0" width="4" height="4" fill="#3498db"/>
2609
+ <rect x="4" y="0" width="4" height="4" fill="#3498db"/>
2610
+ <!-- Backpack -->
2611
+ <rect x="8" y="0" width="4" height="4" fill="#8b6914"/>
2612
+ <!-- Sitting legs -->
2613
+ <rect x="-4" y="4" width="4" height="4" fill="#2c3e50"/>
2614
+ <rect x="0" y="4" width="4" height="4" fill="#2c3e50"/>
2615
+ <!-- Mug (pixel) -->
2616
+ <rect x="-8" y="0" width="${P}" height="${P}" fill="#ddd"/>
2617
+ <rect x="-12" y="0" width="${P}" height="${P}" fill="#ddd" opacity="0.5"/>
2618
+ <!-- Steam (pixel) -->
2619
+ <rect x="-8" y="-4" width="${P}" height="${P}" fill="#4a5170" opacity="0.2">
2620
+ <animate attributeName="opacity" values="0.2;0.05;0.2" dur="2s" repeatCount="indefinite"/>
2621
+ </rect>
2622
+ </g>
2623
+ `;
2624
+ }
1842
2625
 
1843
- // Start first step (only if on portal/quickstart visible)
1844
- show();
1845
- }
2626
+ // --- Distant climber (day and evening only) ---
2627
+ let distantClimber = '';
2628
+ if (period === 'day' || period === 'evening') {
2629
+ const climbOpacity = period === 'day' ? 0.6 : 0.5;
2630
+ distantClimber = `
2631
+ <g transform="translate(612, 148)" opacity="${climbOpacity}">
2632
+ <rect x="0" y="0" width="${P}" height="${P}" fill="#f5c6a0"/>
2633
+ <rect x="0" y="4" width="${P}" height="${P}" fill="#c84040"/>
2634
+ <rect x="0" y="8" width="${P}" height="${P}" fill="#2c3e50"/>
2635
+ <rect x="4" y="0" width="${P}" height="${P}" fill="#8090b0" opacity="0.5"/>
2636
+ <rect x="4" y="4" width="${P}" height="${P}" fill="#8090b0" opacity="0.4"/>
2637
+ </g>
2638
+ `;
2639
+ }
1846
2640
 
1847
- // ---------------------------------------------------------------------------
1848
- // Sherpa Quote Rotation
1849
- // ---------------------------------------------------------------------------
2641
+ // --- Tent glow (dawn only) ---
2642
+ let tentGlow = '';
2643
+ if (period === 'dawn') {
2644
+ tentGlow = `<rect x="88" y="180" width="4" height="4" fill="#ffcc44" opacity="0.3"/>`;
2645
+ }
2646
+
2647
+ // --- Sunrise glow (morning only) ---
2648
+ let sunriseGlow = '';
2649
+ if (period === 'morning') {
2650
+ sunriseGlow = `<rect x="360" y="140" width="280" height="60" fill="#e8834a" opacity="0.06"/>
2651
+ <rect x="400" y="160" width="200" height="40" fill="#f5a060" opacity="0.05"/>`;
2652
+ }
2653
+
2654
+ // --- Assemble SVG ---
2655
+ const svgString = `
2656
+ <svg viewBox="0 0 680 220" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid slice" style="image-rendering:pixelated">
2657
+ <defs>
2658
+ <linearGradient id="bc-sky" x1="0" y1="0" x2="0" y2="1">
2659
+ ${stops}
2660
+ </linearGradient>
2661
+ </defs>
2662
+
2663
+ <!-- Sky -->
2664
+ <rect width="680" height="220" fill="url(#bc-sky)"/>
2665
+
2666
+ ${sunriseGlow}
2667
+
2668
+ <!-- Stars / clouds -->
2669
+ ${starsHtml}
2670
+
2671
+ <!-- Far range mountains -->
2672
+ <polygon points="${farRangePoly}" fill="${theme.farRange}"/>
2673
+
2674
+ <!-- Snow caps -->
2675
+ ${snowCaps}
2676
+
2677
+ <!-- Mid range mountains -->
2678
+ <polygon points="${midRangePoly}" fill="${theme.midRange}"/>
2679
+
2680
+ <!-- Ground -->
2681
+ ${groundHtml}
2682
+
2683
+ <!-- Tent glow -->
2684
+ ${tentGlow}
2685
+
2686
+ <!-- Tents -->
2687
+ ${tents}
2688
+
2689
+ <!-- Prayer flags -->
2690
+ ${prayerFlags1}
2691
+ ${prayerFlags2}
2692
+
2693
+ <!-- Supplies & gear -->
2694
+ ${supplies}
2695
+
2696
+ <!-- Campfire -->
2697
+ ${campfireHtml}
2698
+
2699
+ <!-- Sherpa -->
2700
+ ${sherpaHtml}
2701
+
2702
+ <!-- Distant climber -->
2703
+ ${distantClimber}
2704
+ </svg>
2705
+ `;
2706
+
2707
+ container.innerHTML = svgString;
2708
+ }
1850
2709
 
1851
2710
  function startSherpaQuotes() {
1852
2711
  const el = document.getElementById('sherpa-quote');
1853
2712
  if (!el) return;
1854
2713
 
1855
- // Shuffle array (Fisher-Yates)
1856
- let quotes = [...SHERPA_QUOTES];
1857
- for (let i = quotes.length - 1; i > 0; i--) {
1858
- const j = Math.floor(Math.random() * (i + 1));
1859
- [quotes[i], quotes[j]] = [quotes[j], quotes[i]];
1860
- }
2714
+ const isFirstVisit = !localStorage.getItem(ONBOARDING_KEY);
1861
2715
 
1862
- let idx = 0;
1863
- // Set initial random quote
1864
- el.textContent = quotes[idx];
2716
+ if (isFirstVisit) {
2717
+ // First visit: start with tap prompt, then guide on click
2718
+ sherpaMode = 'intro';
2719
+ el.textContent = '나를 눌러보세유~';
2720
+ } else {
2721
+ setSherpaMode('grumpy');
2722
+ }
1865
2723
 
1866
- setInterval(() => {
1867
- el.style.opacity = '0';
1868
- setTimeout(() => {
1869
- idx++;
1870
- if (idx >= quotes.length) {
1871
- // Reshuffle
1872
- for (let i = quotes.length - 1; i > 0; i--) {
1873
- const j = Math.floor(Math.random() * (i + 1));
1874
- [quotes[i], quotes[j]] = [quotes[j], quotes[i]];
1875
- }
1876
- idx = 0;
1877
- }
1878
- el.textContent = quotes[idx];
1879
- el.style.opacity = '1';
1880
- }, 500);
2724
+ // Start rotation timer
2725
+ if (sherpaInterval) clearInterval(sherpaInterval);
2726
+ sherpaInterval = setInterval(() => {
2727
+ if (sherpaMode === 'intro') return; // Don't rotate during intro
2728
+ advanceSherpa();
1881
2729
  }, 8000);
1882
2730
  }
1883
2731
 
2732
+ window.toggleSherpaMode = function() {
2733
+ const speech = document.getElementById('sherpa-speech');
2734
+ const el = document.getElementById('sherpa-quote');
2735
+ if (!el || !speech) return;
2736
+
2737
+ if (sherpaMode === 'intro') {
2738
+ // First click ever: enter guide mode
2739
+ setSherpaMode('guide');
2740
+ return;
2741
+ }
2742
+
2743
+ // Toggle between guide and grumpy
2744
+ const newMode = sherpaMode === 'guide' ? 'grumpy' : 'guide';
2745
+
2746
+ // Brief mode-switch message
2747
+ el.style.opacity = '0';
2748
+ setTimeout(() => {
2749
+ if (newMode === 'guide') {
2750
+ el.textContent = '가이드 모드여유~ 사용법 알려줄겨 📋';
2751
+ } else {
2752
+ el.textContent = '다시 푸념 모드여유... 😮‍💨';
2753
+ }
2754
+ el.style.opacity = '1';
2755
+
2756
+ setTimeout(() => {
2757
+ setSherpaMode(newMode);
2758
+ }, 2000);
2759
+ }, 300);
2760
+ };
2761
+
1884
2762
  // ---------------------------------------------------------------------------
1885
2763
  // Activity Trail
1886
2764
  // ---------------------------------------------------------------------------
@@ -1961,9 +2839,11 @@ async function loadActivityTrail() {
1961
2839
  }
1962
2840
  });
1963
2841
 
1964
- // Tents on rest days (0 commits, not first/last)
2842
+ // Tents on rest days limit density when most days are rest days
2843
+ const activeDays = days.filter(d => d.commits > 0).length;
2844
+ const tentChance = activeDays < 5 ? 0.85 : 0.5; // fewer active days → fewer tents
1965
2845
  heights.forEach((h, i) => {
1966
- if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() > 0.5) {
2846
+ if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() > tentChance) {
1967
2847
  const x = 20 + i * dayW + dayW / 2 - 4;
1968
2848
  decorations += `
1969
2849
  <rect x="${x + 3}" y="${ground - 8}" width="2" height="2" fill="#6b7394"/>
@@ -1979,9 +2859,9 @@ async function loadActivityTrail() {
1979
2859
  if (datePrs && datePrs.length > 0) {
1980
2860
  const x = 20 + i * dayW + dayW / 2 - 4;
1981
2861
  const h = heights[i];
1982
- const tooltipText = datePrs.map(p => `#${p.number} ${p.title}`).join('\n');
2862
+ const tooltipText = datePrs.map(p => `#${p.number} ${escHtml(p.title)}`).join('\n');
1983
2863
  prMarkers += `
1984
- <g class="pr-marker" data-tooltip="${tooltipText.replace(/"/g, '&quot;')}">
2864
+ <g class="pr-marker" data-tooltip="${escHtml(tooltipText)}">
1985
2865
  <rect x="${x}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
1986
2866
  <rect x="${x + 4}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
1987
2867
  <rect x="${x + 2}" y="${h - 8}" width="2" height="4" fill="#ff6600"/>
@@ -2014,8 +2894,8 @@ async function loadActivityTrail() {
2014
2894
  <polygon points="${x + 2},${highest.h} ${x + 8},${highest.h + 3} ${x + 2},${highest.h + 6}" fill="#f59e0b"/>`;
2015
2895
  }
2016
2896
 
2017
- // Sherpa at today (last position)
2018
- const lastX = 20 + (days.length - 1) * dayW + dayW / 2 - 4;
2897
+ // Sherpa at today (last position, clamped to SVG bounds)
2898
+ const lastX = Math.min(20 + (days.length - 1) * dayW + dayW / 2 - 4, svgW - 24);
2019
2899
  const lastH = heights[heights.length - 1];
2020
2900
  const sherpaY = lastH - 16;
2021
2901
  const sherpa = `
@@ -2137,12 +3017,11 @@ async function init() {
2137
3017
  }
2138
3018
  renderAll();
2139
3019
  loadPortal();
3020
+ renderBasecampScene();
2140
3021
  startSherpaQuotes();
2141
3022
  loadActivityTrail();
2142
3023
  connectWs();
2143
3024
 
2144
- // Show onboarding for first-time users
2145
- setTimeout(showOnboarding, 500);
2146
3025
  }
2147
3026
 
2148
3027
  document.addEventListener('DOMContentLoaded', init);