sanjang 0.3.2 → 0.3.4

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
@@ -22,6 +22,29 @@ let currentWorkspace = null;
22
22
  /** @type {number|null} polling interval for workspace changes */
23
23
  let wsPollingInterval = null;
24
24
 
25
+ const SHERPA_QUOTES = [
26
+ "요구사항 또 바뀌었댜... 뭐 그러려니 하쥬",
27
+ "'간단한 건데~' 그 말이 제일 무섭댜",
28
+ "CEO가 데모 전날 피드백 줬어유. 다 뜯으래유.",
29
+ "이거 금방 되쥬? 네... 산도 금방 오르쥬.",
30
+ "스펙 확정이랬는디... 또 바뀌었댜",
31
+ "우선순위 1이 열두 개여유. 산봉우리가 열두 개.",
32
+ "디자인 안 나왔는디 개발 먼저 하래유",
33
+ "어제 합의한 거 오늘 모른댜. 나도 모르겠댜.",
34
+ "v1인디 왜 v3 기능을 넣으래유",
35
+ "PRD 다섯 번째 바뀌는 중이여유. 난 여그 서있쥬.",
36
+ "아 그거 빼자고 한 거... 다시 넣는댜. 그러쥬 뭐.",
37
+ "고객이 원한댜. 근디 고객이 누구여.",
38
+ "MVP라며유. M이 자꾸 빠져유.",
39
+ "이번엔 진짜 마지막 수정이래유. 네 번째 마지막.",
40
+ "배포하면 쉬는 거 아니었어유?",
41
+ "'빨리 한번 해봐유' 3주째 하는 중이쥬.",
42
+ "피벗이래유. 하산 아니래유. 글쎄유.",
43
+ "개발 부채가 배낭보다 무겁댜. 그래도 가야쥬.",
44
+ "야근이여유? 셰르파는 원래 이러쥬 뭐.",
45
+ "롤백한 거 아무도 모르쥬? 나도 모른댜.",
46
+ ];
47
+
25
48
  // ---------------------------------------------------------------------------
26
49
  // Utility
27
50
  // ---------------------------------------------------------------------------
@@ -1218,9 +1241,22 @@ function renderWorkspace(data) {
1218
1241
  previewEl.querySelector('.ws-preview-fallback').style.display = 'flex';
1219
1242
  });
1220
1243
  } else {
1221
- previewEl.innerHTML = `<span style="color:var(--text-muted);font-size:13px">
1222
- 서버가 실행 중이 아닙니다. 먼저 시작해주세요.
1223
- </span>`;
1244
+ previewEl.innerHTML = `
1245
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;user-select:none;">
1246
+ <div style="width:4px;height:4px;image-rendering:pixelated;color:transparent;box-shadow:
1247
+ /* tent peak */
1248
+ 12px 0 0 #6b7394,
1249
+ 8px 4px 0 #6b7394, 12px 4px 0 #6b7394, 16px 4px 0 #6b7394,
1250
+ 4px 8px 0 #6b7394, 8px 8px 0 #6b7394, 12px 8px 0 #6b7394, 16px 8px 0 #6b7394, 20px 8px 0 #6b7394,
1251
+ 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,
1252
+ /* zzz */
1253
+ 36px 0 0 #4a5170, 40px 4px 0 #4a5170, 36px 8px 0 #4a5170;
1254
+ transform:scale(2);margin-bottom:8px;
1255
+ "></div>
1256
+ <div style="color:var(--text-muted);font-size:14px;text-align:center;margin-top:24px;">
1257
+ 캠프가 자고 있어유... zzZ
1258
+ </div>
1259
+ </div>`;
1224
1260
  }
1225
1261
 
1226
1262
  // Terminal button label
@@ -1280,7 +1316,7 @@ function renderBlocks(files) {
1280
1316
  }
1281
1317
  container.innerHTML = files.map(f => {
1282
1318
  const type = f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del';
1283
- return `<div class="ws-block ws-block-${type}" title="${f.path}"></div>`;
1319
+ return `<div class="ws-block ws-block-${type}" title="${escHtml(f.path)}"></div>`;
1284
1320
  }).join('');
1285
1321
  container.classList.toggle('ws-blocks-wobble', files.length >= 5);
1286
1322
  }
@@ -1614,48 +1650,6 @@ window.togglePanel = function() {
1614
1650
  // ---------------------------------------------------------------------------
1615
1651
 
1616
1652
 
1617
- async function loadSuggestions() {
1618
- const section = document.getElementById('portal-suggestions-section');
1619
- const list = document.getElementById('portal-suggestions');
1620
- if (!section || !list) return;
1621
-
1622
- try {
1623
- const suggestions = await api('GET', '/api/suggestions');
1624
- if (!suggestions || suggestions.length === 0) {
1625
- section.style.display = 'none';
1626
- return;
1627
- }
1628
-
1629
- // Exclude "recent" commits (noise) and deduplicate with "이어하기"
1630
- const workTitles = new Set([...playgrounds.values()].map(p => p.branch));
1631
- const filtered = suggestions
1632
- .filter(item => item.type !== 'recent')
1633
- .filter(item => !workTitles.has(item.action))
1634
- .slice(0, 5);
1635
-
1636
- if (filtered.length === 0) {
1637
- section.style.display = 'none';
1638
- return;
1639
- }
1640
-
1641
- const iconMap = { issue: '🔵', pr: '🟡' };
1642
- list.innerHTML = filtered.map(item => {
1643
- const icon = iconMap[item.type] || '⚪';
1644
- return `
1645
- <div class="portal-work-item">
1646
- <div class="portal-work-left">
1647
- <span class="portal-work-icon">${icon}</span>
1648
- <div>
1649
- <div class="portal-work-title">${escHtml(item.title)}</div>
1650
- </div>
1651
- </div>
1652
- </div>`;
1653
- }).join('');
1654
- section.style.display = '';
1655
- } catch {
1656
- section.style.display = 'none';
1657
- }
1658
- }
1659
1653
  async function loadPortal() {
1660
1654
  const workList = document.getElementById('portal-work');
1661
1655
  if (!workList) return;
@@ -1756,111 +1750,882 @@ window.autoFix = async function autoFix(name) {
1756
1750
  };
1757
1751
 
1758
1752
  // ---------------------------------------------------------------------------
1759
- // Onboarding Tutorial
1753
+ // Sherpa Guide Mode (replaces overlay onboarding)
1760
1754
  // ---------------------------------------------------------------------------
1761
1755
 
1762
1756
  const ONBOARDING_KEY = 'sanjang-onboarded';
1763
1757
 
1764
- const onboardingSteps = [
1765
- {
1766
- target: '#quickstart-input',
1767
- title: '캠프 만들기',
1768
- text: '하고 싶은 입력하면 AI가 캠프를 자동으로 만들어줘요.',
1769
- position: 'bottom',
1758
+ const SHERPA_GUIDE = [
1759
+ "여기에 하고 싶은 거 적으면 되유. AI가 캠프 만들어줄겨.",
1760
+ "캠프 들어가면 프리뷰 전체화면으로 보여유. 편하쥬?",
1761
+ "세이브는 게임 세이브처럼 저장이여유. 💾 버튼 누르면 되유.",
1762
+ "팀에 보내기 누르면 PR 만들어주유. 셰르파가 해줄겨.",
1763
+ "그럼 이제 시작해봐유. 화이팅이여유~ 🏔️",
1764
+ ];
1765
+
1766
+ // ---------------------------------------------------------------------------
1767
+ // Sherpa Mode System (guide ↔ grumpy toggle)
1768
+ // ---------------------------------------------------------------------------
1769
+
1770
+ let sherpaInterval = null;
1771
+ let sherpaMode = 'grumpy'; // 'guide' or 'grumpy'
1772
+ let sherpaQueue = [];
1773
+ let sherpaIdx = 0;
1774
+
1775
+ function shuffleArray(arr) {
1776
+ const a = [...arr];
1777
+ for (let i = a.length - 1; i > 0; i--) {
1778
+ const j = Math.floor(Math.random() * (i + 1));
1779
+ [a[i], a[j]] = [a[j], a[i]];
1780
+ }
1781
+ return a;
1782
+ }
1783
+
1784
+ function setSherpaMode(mode) {
1785
+ sherpaMode = mode;
1786
+ sherpaIdx = 0;
1787
+ sherpaQueue = mode === 'guide' ? [...SHERPA_GUIDE] : shuffleArray(SHERPA_QUOTES);
1788
+
1789
+ const el = document.getElementById('sherpa-quote');
1790
+ const speech = document.getElementById('sherpa-speech');
1791
+ if (!el || !speech) return;
1792
+
1793
+ // Visual mode indicator
1794
+ speech.classList.toggle('guide-mode', mode === 'guide');
1795
+
1796
+ // Fade transition to first message
1797
+ el.style.opacity = '0';
1798
+ setTimeout(() => {
1799
+ el.textContent = sherpaQueue[0];
1800
+ el.style.opacity = '1';
1801
+ }, 300);
1802
+ }
1803
+
1804
+ function advanceSherpa() {
1805
+ const el = document.getElementById('sherpa-quote');
1806
+ if (!el) return;
1807
+
1808
+ el.style.opacity = '0';
1809
+ setTimeout(() => {
1810
+ sherpaIdx++;
1811
+ if (sherpaIdx >= sherpaQueue.length) {
1812
+ if (sherpaMode === 'guide') {
1813
+ // Guide done → switch to grumpy
1814
+ localStorage.setItem(ONBOARDING_KEY, '1');
1815
+ setSherpaMode('grumpy');
1816
+ return;
1817
+ }
1818
+ // Reshuffle grumpy quotes
1819
+ sherpaQueue = shuffleArray(SHERPA_QUOTES);
1820
+ sherpaIdx = 0;
1821
+ }
1822
+ el.textContent = sherpaQueue[sherpaIdx];
1823
+ el.style.opacity = '1';
1824
+ }, 500);
1825
+ }
1826
+
1827
+ // ---------------------------------------------------------------------------
1828
+ // Basecamp Scene — Time-based Himalaya SVG
1829
+ // ---------------------------------------------------------------------------
1830
+
1831
+ const SCENE_THEMES = {
1832
+ dawn: {
1833
+ skyGradient: [['0%','#050810'],['60%','#0a1028'],['100%','#141830']],
1834
+ farRange: '#1a2040',
1835
+ midRange: '#141a30',
1836
+ ground: '#12151e',
1837
+ snowColor: 'rgba(200,215,240,0.35)',
1838
+ snowHighlight: 'rgba(220,230,250,0.4)',
1770
1839
  },
1771
- {
1772
- target: '#ws-preview',
1773
- title: '프리뷰 확인',
1774
- text: '캠프에 들어가면 전체화면으로 프리뷰를 볼 수 있어요.',
1775
- position: 'center',
1776
- waitForWorkspace: true,
1840
+ morning: {
1841
+ skyGradient: [['0%','#1a2540'],['40%','#2d3a5c'],['70%','#5c4a6e'],['100%','#c4785a']],
1842
+ farRange: '#2a3058',
1843
+ midRange: '#1e2444',
1844
+ ground: '#12151e',
1845
+ snowColor: 'rgba(255,220,180,0.45)',
1846
+ snowHighlight: 'rgba(255,200,150,0.55)',
1777
1847
  },
1778
- {
1779
- target: '#ws-save-btn',
1780
- title: '세이브하기',
1781
- text: '변경사항이 있으면 세이브 버튼으로 저장해요. 게임 세이브처럼요!',
1782
- position: 'left',
1783
- waitForWorkspace: true,
1848
+ day: {
1849
+ skyGradient: [['0%','#1a3050'],['50%','#2a4a6a'],['100%','#3a5a7a']],
1850
+ farRange: '#253a58',
1851
+ midRange: '#1a2e48',
1852
+ ground: '#12151e',
1853
+ snowColor: 'rgba(255,255,255,0.5)',
1854
+ snowHighlight: 'rgba(255,255,255,0.6)',
1784
1855
  },
1785
- ];
1856
+ evening: {
1857
+ skyGradient: [['0%','#060810'],['40%','#0e1530'],['75%','#1a1535'],['100%','#12151e']],
1858
+ farRange: '#182040',
1859
+ midRange: '#121830',
1860
+ ground: '#12151e',
1861
+ snowColor: 'rgba(200,180,230,0.35)',
1862
+ snowHighlight: 'rgba(220,200,240,0.4)',
1863
+ },
1864
+ };
1786
1865
 
1787
- function showOnboarding() {
1788
- if (localStorage.getItem(ONBOARDING_KEY)) return;
1789
- let step = 0;
1866
+ function renderBasecampScene() {
1867
+ const container = document.getElementById('bc-scene-container');
1868
+ if (!container) return;
1790
1869
 
1791
- function show() {
1792
- // Remove previous
1793
- document.querySelector('.onboarding-overlay')?.remove();
1870
+ const P = 4; // pixel size (4px grid)
1871
+ const hour = new Date().getHours();
1872
+ let period;
1873
+ if (hour >= 0 && hour < 6) period = 'dawn';
1874
+ else if (hour >= 6 && hour < 12) period = 'morning';
1875
+ else if (hour >= 12 && hour < 18) period = 'day';
1876
+ else period = 'evening';
1877
+
1878
+ const theme = SCENE_THEMES[period];
1879
+
1880
+ // Build gradient stops
1881
+ const stops = theme.skyGradient.map(([offset, color]) =>
1882
+ `<stop offset="${offset}" stop-color="${color}"/>`
1883
+ ).join('');
1884
+
1885
+ // --- Stars ---
1886
+ let starsHtml = '';
1887
+ if (period === 'dawn') {
1888
+ const starPositions = [
1889
+ [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]
1890
+ ];
1891
+ starsHtml = starPositions.map(([x,y]) =>
1892
+ `<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>`
1893
+ ).join('');
1894
+ // Crescent moon (pixel)
1895
+ starsHtml += `
1896
+ <rect x="576" y="20" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
1897
+ <rect x="580" y="16" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
1898
+ <rect x="580" y="20" width="${P}" height="${P}" fill="#c8cee6" opacity="0.25"/>
1899
+ <rect x="584" y="16" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
1900
+ <rect x="584" y="20" width="${P}" height="${P}" fill="#c8cee6" opacity="0.15"/>
1901
+ <rect x="588" y="20" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
1902
+ <rect x="580" y="24" width="${P}" height="${P}" fill="#c8cee6" opacity="0.25"/>
1903
+ <rect x="584" y="24" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
1904
+ `;
1905
+ } else if (period === 'morning') {
1906
+ const starPositions = [[120,20],[400,15],[550,30]];
1907
+ starsHtml = starPositions.map(([x,y]) =>
1908
+ `<rect x="${x}" y="${y}" width="2" height="2" fill="#fff" opacity="0.2"/>`
1909
+ ).join('');
1910
+ } else if (period === 'day') {
1911
+ // Pixel clouds (4px grid)
1912
+ starsHtml = `
1913
+ <g opacity="0.12">
1914
+ <rect x="104" y="28" width="8" height="4" fill="#fff"/>
1915
+ <rect x="100" y="32" width="16" height="4" fill="#fff"/>
1916
+ <rect x="108" y="36" width="4" height="4" fill="#fff"/>
1917
+ <rect x="436" y="24" width="12" height="4" fill="#fff"/>
1918
+ <rect x="432" y="28" width="20" height="4" fill="#fff"/>
1919
+ <rect x="440" y="32" width="8" height="4" fill="#fff"/>
1920
+ </g>
1921
+ `;
1922
+ } else {
1923
+ const starPositions = [
1924
+ [80,15],[160,35],[250,10],[340,28],[430,18],[510,32],[590,12],[140,45],[380,40],[620,25]
1925
+ ];
1926
+ starsHtml = starPositions.map(([x,y]) =>
1927
+ `<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>`
1928
+ ).join('');
1929
+ }
1794
1930
 
1795
- if (step >= onboardingSteps.length) {
1796
- localStorage.setItem(ONBOARDING_KEY, '1');
1797
- return;
1931
+ // --- Mountains ---
1932
+ 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
1933
+ 95,95 95,85 105,85 105,80 115,80 115,85 125,85 125,95 135,95 135,105
1934
+ 150,105 150,115 165,115 165,125 180,125 180,135 200,135 200,145 220,145
1935
+ 220,135 235,135 235,120 250,120 250,105 260,105 260,90 270,90 270,78 280,78
1936
+ 280,70 288,70 288,62 295,62 295,56 302,56 302,50 308,50 308,45 314,45
1937
+ 314,50 320,50 320,56 326,56 326,65 335,65 335,78 345,78 345,90
1938
+ 355,90 355,105 370,105 370,120 385,120 385,135 400,135
1939
+ 400,125 415,125 415,110 425,110 425,98 435,98 435,88 445,88 445,78
1940
+ 450,78 450,70 456,70 456,64 462,64 462,58 466,58 466,54 470,54
1941
+ 470,50 474,50 474,46 478,46 478,50 482,50 482,56 486,56
1942
+ 486,64 492,64 492,72 498,72 498,82 508,82 508,95 518,95
1943
+ 518,108 530,108 530,120 545,120 545,135 560,135
1944
+ 560,125 570,125 570,112 580,112 580,100 590,100 590,88 598,88 598,78
1945
+ 605,78 605,70 612,70 612,76 618,76 618,85 625,85 625,95
1946
+ 635,95 635,108 645,108 645,120 658,120 658,135 680,135 680,220`;
1947
+
1948
+ const midRangePoly = `0,220 0,170 30,170 30,160 60,160 60,152 80,152 80,160 110,160 110,168
1949
+ 140,168 140,158 160,158 160,148 175,148 175,140 188,140 188,135 198,135 198,140
1950
+ 210,140 210,150 230,150 230,162 260,162 260,155 280,155 280,145 295,145 295,138
1951
+ 310,138 310,145 330,145 330,155 350,155 350,165
1952
+ 380,165 380,155 400,155 400,148 415,148 415,140 425,140 425,135 432,135 432,140
1953
+ 440,140 440,150 460,150 460,160 480,160 480,168
1954
+ 510,168 510,158 530,158 530,148 545,148 545,142 555,142 555,148
1955
+ 565,148 565,158 585,158 585,165 610,165 610,158 630,158 630,165 660,165 660,170 680,170 680,220`;
1956
+
1957
+ // --- Snow caps (4px grid) ---
1958
+ const snowCaps = `
1959
+ <!-- Main peak snow -->
1960
+ <rect x="308" y="45" width="${P*2}" height="${P}" fill="${theme.snowHighlight}"/>
1961
+ <rect x="304" y="49" width="${P*4}" height="${P}" fill="${theme.snowColor}"/>
1962
+ <!-- Second peak snow -->
1963
+ <rect x="474" y="46" width="${P*2}" height="${P}" fill="${theme.snowHighlight}"/>
1964
+ <rect x="470" y="50" width="${P*4}" height="${P}" fill="${theme.snowColor}"/>
1965
+ <!-- Smaller peaks -->
1966
+ <rect x="104" y="80" width="${P*2}" height="${P}" fill="${theme.snowHighlight}"/>
1967
+ <rect x="604" y="70" width="${P*2}" height="${P}" fill="${theme.snowHighlight}"/>
1968
+ <!-- Mid-range snow -->
1969
+ <rect x="188" y="135" width="${P*2}" height="${P}" fill="${theme.snowColor}" opacity="0.5"/>
1970
+ <rect x="424" y="135" width="${P*2}" height="${P}" fill="${theme.snowColor}" opacity="0.5"/>
1971
+ <rect x="544" y="142" width="${P*2}" height="${P}" fill="${theme.snowColor}" opacity="0.5"/>
1972
+ `;
1973
+
1974
+ // --- Ground + texture ---
1975
+ const groundHtml = `
1976
+ <rect x="0" y="185" width="680" height="35" fill="${theme.ground}"/>
1977
+ <rect x="50" y="188" width="8" height="3" fill="#1a1d28" opacity="0.5"/>
1978
+ <rect x="200" y="190" width="6" height="2" fill="#1a1d28" opacity="0.4"/>
1979
+ <rect x="350" y="187" width="10" height="3" fill="#1a1d28" opacity="0.5"/>
1980
+ <rect x="500" y="191" width="7" height="2" fill="#1a1d28" opacity="0.4"/>
1981
+ <rect x="620" y="189" width="5" height="3" fill="#1a1d28" opacity="0.5"/>
1982
+ `;
1983
+
1984
+ // --- Shared basecamp elements (all 4px grid pixel art) ---
1985
+
1986
+ const tents = `
1987
+ <!-- Yellow expedition tent (pixel pyramid) -->
1988
+ <g>
1989
+ <rect x="88" y="172" width="${P}" height="${P}" fill="#c8a820"/>
1990
+ <rect x="84" y="176" width="${P}" height="${P}" fill="#c8a820"/>
1991
+ <rect x="88" y="176" width="${P}" height="${P}" fill="#a08818"/>
1992
+ <rect x="92" y="176" width="${P}" height="${P}" fill="#c8a820"/>
1993
+ <rect x="80" y="180" width="${P}" height="${P}" fill="#c8a820"/>
1994
+ <rect x="84" y="180" width="${P}" height="${P}" fill="#c8a820"/>
1995
+ <rect x="88" y="180" width="${P}" height="${P}" fill="#2c2210"/>
1996
+ <rect x="92" y="180" width="${P}" height="${P}" fill="#c8a820"/>
1997
+ <rect x="96" y="180" width="${P}" height="${P}" fill="#c8a820"/>
1998
+ </g>
1999
+ <!-- Blue dome tent (pixel) -->
2000
+ <g>
2001
+ <rect x="520" y="176" width="${P}" height="${P}" fill="#2855a0"/>
2002
+ <rect x="524" y="176" width="${P}" height="${P}" fill="#2855a0"/>
2003
+ <rect x="516" y="180" width="${P}" height="${P}" fill="#2855a0"/>
2004
+ <rect x="520" y="180" width="${P}" height="${P}" fill="#2855a0"/>
2005
+ <rect x="524" y="180" width="${P}" height="${P}" fill="#1a2040"/>
2006
+ <rect x="528" y="180" width="${P}" height="${P}" fill="#2855a0"/>
2007
+ <rect x="532" y="180" width="${P}" height="${P}" fill="#2855a0"/>
2008
+ </g>
2009
+ <!-- Green small tent (pixel) -->
2010
+ <g>
2011
+ <rect x="580" y="176" width="${P}" height="${P}" fill="#1e8040"/>
2012
+ <rect x="576" y="180" width="${P}" height="${P}" fill="#1e8040"/>
2013
+ <rect x="580" y="180" width="${P}" height="${P}" fill="#166030"/>
2014
+ <rect x="584" y="180" width="${P}" height="${P}" fill="#1e8040"/>
2015
+ </g>
2016
+ <!-- Red expedition tent (pixel) -->
2017
+ <g>
2018
+ <rect x="448" y="172" width="${P}" height="${P}" fill="#b83030"/>
2019
+ <rect x="444" y="176" width="${P}" height="${P}" fill="#b83030"/>
2020
+ <rect x="448" y="176" width="${P}" height="${P}" fill="#902020"/>
2021
+ <rect x="452" y="176" width="${P}" height="${P}" fill="#b83030"/>
2022
+ <rect x="440" y="180" width="${P}" height="${P}" fill="#b83030"/>
2023
+ <rect x="444" y="180" width="${P}" height="${P}" fill="#b83030"/>
2024
+ <rect x="448" y="180" width="${P}" height="${P}" fill="#401010"/>
2025
+ <rect x="452" y="180" width="${P}" height="${P}" fill="#b83030"/>
2026
+ <rect x="456" y="180" width="${P}" height="${P}" fill="#b83030"/>
2027
+ </g>
2028
+ `;
2029
+
2030
+ const flagColors = ['#e74c3c','#f39c12','#fff','#2ecc71','#3498db'];
2031
+ const prayerFlags1 = flagColors.map((c, i) =>
2032
+ `<rect x="${156 + i*8}" y="172" width="${P}" height="${P}" fill="${c}" opacity="0.7"/>`
2033
+ ).join('') + flagColors.map((c, i) =>
2034
+ `<rect x="${156 + i*8}" y="168" width="${P}" height="1" fill="#4a5170" opacity="0.5"/>`
2035
+ ).join('');
2036
+
2037
+ const prayerFlags2 = flagColors.map((c, i) =>
2038
+ `<rect x="${420 + i*8}" y="168" width="${P}" height="${P}" fill="${c}" opacity="0.6"/>`
2039
+ ).join('') + flagColors.map((c, i) =>
2040
+ `<rect x="${420 + i*8}" y="164" width="${P}" height="1" fill="#4a5170" opacity="0.4"/>`
2041
+ ).join('');
2042
+
2043
+ const supplies = `
2044
+ <!-- Supply crates (pixel) -->
2045
+ <rect x="128" y="180" width="${P*2}" height="${P}" fill="#6b4a28"/>
2046
+ <rect x="128" y="180" width="${P*2}" height="1" fill="#8b6a38"/>
2047
+ <rect x="128" y="176" width="${P*2}" height="${P}" fill="#5a3a20"/>
2048
+ <rect x="128" y="176" width="${P*2}" height="1" fill="#7a5a30"/>
2049
+ <rect x="136" y="180" width="${P}" height="${P}" fill="#5a3a20"/>
2050
+ <!-- Oxygen tanks (pixel) -->
2051
+ <rect x="472" y="180" width="${P}" height="${P}" fill="#4a6a8a"/>
2052
+ <rect x="472" y="176" width="${P}" height="${P}" fill="#6a8aaa"/>
2053
+ <rect x="476" y="180" width="${P}" height="${P}" fill="#4a6a8a"/>
2054
+ <rect x="476" y="176" width="${P}" height="${P}" fill="#6a8aaa"/>
2055
+ <!-- Signpost (pixel) -->
2056
+ <rect x="300" y="172" width="${P}" height="${P*3}" fill="#5a4a30"/>
2057
+ <rect x="296" y="172" width="${P*3}" height="${P}" fill="#6b5a38"/>
2058
+ <rect x="308" y="173" width="${P}" height="2" fill="#6b5a38"/>
2059
+ <!-- Rope coil (pixel) -->
2060
+ <rect x="600" y="176" width="${P}" height="${P}" fill="#8b7a50"/>
2061
+ <rect x="604" y="176" width="${P}" height="${P}" fill="#8b7a50"/>
2062
+ <rect x="596" y="180" width="${P}" height="${P}" fill="#8b7a50"/>
2063
+ <rect x="608" y="180" width="${P}" height="${P}" fill="#8b7a50"/>
2064
+ <rect x="600" y="184" width="${P}" height="${P}" fill="#8b7a50"/>
2065
+ <rect x="604" y="184" width="${P}" height="${P}" fill="#8b7a50"/>
2066
+ <!-- Ice axe (pixel) -->
2067
+ <rect x="144" y="168" width="${P}" height="${P}" fill="#8090b0"/>
2068
+ <rect x="144" y="172" width="${P}" height="${P}" fill="#6b5a38"/>
2069
+ <rect x="144" y="176" width="${P}" height="${P}" fill="#6b5a38"/>
2070
+ <rect x="140" y="168" width="${P}" height="${P}" fill="#aab0c0"/>
2071
+ `;
2072
+
2073
+ // --- Stone ring around campfire (4px grid) ---
2074
+ const stoneRing = `
2075
+ <rect x="320" y="184" width="${P}" height="${P}" fill="#3a3a40"/>
2076
+ <rect x="324" y="184" width="${P}" height="${P}" fill="#454550"/>
2077
+ <rect x="336" y="184" width="${P}" height="${P}" fill="#454550"/>
2078
+ <rect x="340" y="184" width="${P}" height="${P}" fill="#3a3a40"/>
2079
+ <rect x="318" y="180" width="${P}" height="${P}" fill="#454550"/>
2080
+ <rect x="342" y="180" width="${P}" height="${P}" fill="#3a3a40"/>
2081
+ `;
2082
+
2083
+ // --- Campfire (period-specific) ---
2084
+ let campfireHtml = '';
2085
+ if (period === 'dawn') {
2086
+ // Dim embers (pixel)
2087
+ campfireHtml = `
2088
+ ${stoneRing}
2089
+ <rect x="328" y="180" width="${P}" height="${P}" fill="#8b2200" opacity="0.5"/>
2090
+ <rect x="332" y="180" width="${P}" height="${P}" fill="#a03000" opacity="0.4"/>
2091
+ `;
2092
+ } else if (period === 'morning') {
2093
+ // Smoke only (pixel)
2094
+ campfireHtml = `
2095
+ ${stoneRing}
2096
+ <rect x="328" y="180" width="${P}" height="${P}" fill="#5a4a30"/>
2097
+ <rect x="332" y="180" width="${P}" height="${P}" fill="#5a4a30"/>
2098
+ <rect x="328" y="176" width="${P}" height="${P}" fill="#4a5170" opacity="0.2"/>
2099
+ <rect x="332" y="172" width="${P}" height="${P}" fill="#4a5170" opacity="0.15"/>
2100
+ <rect x="328" y="168" width="${P}" height="${P}" fill="#4a5170" opacity="0.1"/>
2101
+ `;
2102
+ } else if (period === 'day') {
2103
+ // No fire, just logs (pixel)
2104
+ campfireHtml = `
2105
+ ${stoneRing}
2106
+ <rect x="324" y="180" width="${P*3}" height="${P}" fill="#5a4a30"/>
2107
+ <rect x="328" y="176" width="${P*2}" height="${P}" fill="#6b4a28"/>
2108
+ `;
2109
+ } else {
2110
+ // Full fire with glow (all 4px pixel)
2111
+ campfireHtml = `
2112
+ ${stoneRing}
2113
+ <!-- Glow (rect-based) -->
2114
+ <rect x="316" y="168" width="${P*7}" height="${P*4}" fill="#ff8c32" opacity="0.04"/>
2115
+ <rect x="320" y="172" width="${P*5}" height="${P*3}" fill="#ff6600" opacity="0.06"/>
2116
+ <!-- Logs -->
2117
+ <rect x="324" y="180" width="${P*3}" height="${P}" fill="#5a3a20"/>
2118
+ <rect x="326" y="180" width="${P*3}" height="${P}" fill="#6b4a28"/>
2119
+ <!-- Flames (pixel, animated with steps) -->
2120
+ <rect x="328" y="176" width="${P}" height="${P}" fill="#ff6600">
2121
+ <animate attributeName="opacity" values="1;0.6;1" dur="0.4s" steps="2" repeatCount="indefinite"/>
2122
+ </rect>
2123
+ <rect x="332" y="172" width="${P}" height="${P}" fill="#ffcc00">
2124
+ <animate attributeName="opacity" values="0.8;1;0.6" dur="0.5s" steps="2" repeatCount="indefinite"/>
2125
+ </rect>
2126
+ <rect x="332" y="176" width="${P}" height="${P}" fill="#ff8800"/>
2127
+ <rect x="328" y="172" width="${P}" height="${P}" fill="#ff9900" opacity="0.7">
2128
+ <animate attributeName="opacity" values="0.7;0.3;0.7" dur="0.35s" steps="2" repeatCount="indefinite"/>
2129
+ </rect>
2130
+ <rect x="336" y="176" width="${P}" height="${P}" fill="#ff6600" opacity="0.6"/>
2131
+ <rect x="330" y="168" width="${P}" height="${P}" fill="#ff6600" opacity="0.4">
2132
+ <animate attributeName="opacity" values="0.4;0.1;0.4" dur="0.6s" steps="2" repeatCount="indefinite"/>
2133
+ </rect>
2134
+ <!-- Smoke (pixel) -->
2135
+ <rect x="332" y="164" width="${P}" height="${P}" fill="#4a5170" opacity="0.15">
2136
+ <animate attributeName="opacity" values="0.15;0.05;0.15" dur="2s" repeatCount="indefinite"/>
2137
+ </rect>
2138
+ <rect x="328" y="160" width="${P}" height="${P}" fill="#4a5170" opacity="0.08"/>
2139
+ `;
2140
+ }
2141
+
2142
+ // --- Sherpa (period-specific) ---
2143
+ let sherpaHtml = '';
2144
+ if (period === 'dawn') {
2145
+ // Sleeping horizontally
2146
+ sherpaHtml = `
2147
+ <g transform="translate(345, 178)">
2148
+ <!-- Body lying flat -->
2149
+ <rect x="0" y="0" width="4" height="4" fill="#3498db"/>
2150
+ <rect x="4" y="0" width="4" height="4" fill="#3498db"/>
2151
+ <rect x="8" y="0" width="4" height="4" fill="#3498db"/>
2152
+ <rect x="12" y="0" width="4" height="4" fill="#2c3e50"/>
2153
+ <rect x="16" y="0" width="4" height="4" fill="#2c3e50"/>
2154
+ <!-- Head -->
2155
+ <rect x="-4" y="-1" width="4" height="4" fill="#f5c6a0"/>
2156
+ <!-- Hat flat -->
2157
+ <rect x="-8" y="-2" width="4" height="4" fill="#e74c3c"/>
2158
+ <!-- Zzz (pixel) -->
2159
+ <rect x="8" y="-8" width="${P}" height="${P}" fill="#8888aa" opacity="0.5"/>
2160
+ <rect x="12" y="-12" width="${P}" height="${P}" fill="#8888aa" opacity="0.4"/>
2161
+ <rect x="14" y="-16" width="${P}" height="${P}" fill="#8888aa" opacity="0.3"/>
2162
+ <rect x="16" y="-20" width="${P}" height="${P}" fill="#8888aa" opacity="0.2"/>
2163
+ </g>
2164
+ `;
2165
+ } else if (period === 'morning') {
2166
+ // Stretching (arms raised)
2167
+ sherpaHtml = `
2168
+ <g transform="translate(345, 170)">
2169
+ <!-- Hat -->
2170
+ <rect x="0" y="-8" width="4" height="4" fill="#e74c3c"/>
2171
+ <rect x="-4" y="-8" width="4" height="4" fill="#e74c3c"/>
2172
+ <rect x="4" y="-8" width="4" height="4" fill="#e74c3c"/>
2173
+ <!-- Face -->
2174
+ <rect x="-4" y="-4" width="4" height="4" fill="#f5c6a0"/>
2175
+ <rect x="0" y="-4" width="4" height="4" fill="#f5c6a0"/>
2176
+ <rect x="4" y="-4" width="4" height="4" fill="#f5c6a0"/>
2177
+ <!-- Body -->
2178
+ <rect x="-4" y="0" width="4" height="4" fill="#3498db"/>
2179
+ <rect x="0" y="0" width="4" height="4" fill="#3498db"/>
2180
+ <rect x="4" y="0" width="4" height="4" fill="#3498db"/>
2181
+ <!-- Arms raised -->
2182
+ <rect x="-8" y="-8" width="4" height="4" fill="#3498db"/>
2183
+ <rect x="8" y="-4" width="4" height="4" fill="#3498db"/>
2184
+ <!-- Lower body -->
2185
+ <rect x="-4" y="4" width="4" height="4" fill="#3498db"/>
2186
+ <rect x="0" y="4" width="4" height="4" fill="#3498db"/>
2187
+ <rect x="4" y="4" width="4" height="4" fill="#3498db"/>
2188
+ <!-- Legs -->
2189
+ <rect x="-4" y="8" width="4" height="4" fill="#2c3e50"/>
2190
+ <rect x="4" y="8" width="4" height="4" fill="#2c3e50"/>
2191
+ </g>
2192
+ `;
2193
+ } else if (period === 'day') {
2194
+ // Walking pose with backpack
2195
+ sherpaHtml = `
2196
+ <g transform="translate(355, 166)">
2197
+ <!-- Hat -->
2198
+ <rect x="0" y="-8" width="4" height="4" fill="#e74c3c"/>
2199
+ <rect x="-4" y="-8" width="4" height="4" fill="#e74c3c"/>
2200
+ <rect x="4" y="-8" width="4" height="4" fill="#e74c3c"/>
2201
+ <!-- Face -->
2202
+ <rect x="-4" y="-4" width="4" height="4" fill="#f5c6a0"/>
2203
+ <rect x="0" y="-4" width="4" height="4" fill="#f5c6a0"/>
2204
+ <rect x="4" y="-4" width="4" height="4" fill="#f5c6a0"/>
2205
+ <!-- Body -->
2206
+ <rect x="-4" y="0" width="4" height="4" fill="#3498db"/>
2207
+ <rect x="0" y="0" width="4" height="4" fill="#3498db"/>
2208
+ <rect x="4" y="0" width="4" height="4" fill="#3498db"/>
2209
+ <rect x="-4" y="4" width="4" height="4" fill="#3498db"/>
2210
+ <rect x="0" y="4" width="4" height="4" fill="#3498db"/>
2211
+ <rect x="4" y="4" width="4" height="4" fill="#3498db"/>
2212
+ <!-- Backpack -->
2213
+ <rect x="8" y="0" width="4" height="4" fill="#8b6914"/>
2214
+ <rect x="8" y="4" width="4" height="4" fill="#8b6914"/>
2215
+ <!-- Legs (walking) -->
2216
+ <rect x="-4" y="8" width="4" height="4" fill="#2c3e50"/>
2217
+ <rect x="0" y="8" width="4" height="4" fill="#2c3e50"/>
2218
+ <rect x="4" y="12" width="4" height="4" fill="#2c3e50"/>
2219
+ <rect x="-4" y="12" width="4" height="4" fill="#2c3e50"/>
2220
+ </g>
2221
+ `;
2222
+ } else {
2223
+ // Sitting with mug
2224
+ sherpaHtml = `
2225
+ <g transform="translate(345, 170)">
2226
+ <!-- Hat -->
2227
+ <rect x="0" y="-8" width="4" height="4" fill="#e74c3c"/>
2228
+ <rect x="-4" y="-8" width="4" height="4" fill="#e74c3c"/>
2229
+ <rect x="4" y="-8" width="4" height="4" fill="#e74c3c"/>
2230
+ <!-- Face -->
2231
+ <rect x="-4" y="-4" width="4" height="4" fill="#f5c6a0"/>
2232
+ <rect x="0" y="-4" width="4" height="4" fill="#f5c6a0"/>
2233
+ <rect x="4" y="-4" width="4" height="4" fill="#f5c6a0"/>
2234
+ <!-- Body -->
2235
+ <rect x="-4" y="0" width="4" height="4" fill="#3498db"/>
2236
+ <rect x="0" y="0" width="4" height="4" fill="#3498db"/>
2237
+ <rect x="4" y="0" width="4" height="4" fill="#3498db"/>
2238
+ <!-- Backpack -->
2239
+ <rect x="8" y="0" width="4" height="4" fill="#8b6914"/>
2240
+ <!-- Sitting legs -->
2241
+ <rect x="-4" y="4" width="4" height="4" fill="#2c3e50"/>
2242
+ <rect x="0" y="4" width="4" height="4" fill="#2c3e50"/>
2243
+ <!-- Mug (pixel) -->
2244
+ <rect x="-8" y="0" width="${P}" height="${P}" fill="#ddd"/>
2245
+ <rect x="-12" y="0" width="${P}" height="${P}" fill="#ddd" opacity="0.5"/>
2246
+ <!-- Steam (pixel) -->
2247
+ <rect x="-8" y="-4" width="${P}" height="${P}" fill="#4a5170" opacity="0.2">
2248
+ <animate attributeName="opacity" values="0.2;0.05;0.2" dur="2s" repeatCount="indefinite"/>
2249
+ </rect>
2250
+ </g>
2251
+ `;
2252
+ }
2253
+
2254
+ // --- Distant climber (day and evening only) ---
2255
+ let distantClimber = '';
2256
+ if (period === 'day' || period === 'evening') {
2257
+ const climbOpacity = period === 'day' ? 0.6 : 0.5;
2258
+ distantClimber = `
2259
+ <g transform="translate(612, 148)" opacity="${climbOpacity}">
2260
+ <rect x="0" y="0" width="${P}" height="${P}" fill="#f5c6a0"/>
2261
+ <rect x="0" y="4" width="${P}" height="${P}" fill="#c84040"/>
2262
+ <rect x="0" y="8" width="${P}" height="${P}" fill="#2c3e50"/>
2263
+ <rect x="4" y="0" width="${P}" height="${P}" fill="#8090b0" opacity="0.5"/>
2264
+ <rect x="4" y="4" width="${P}" height="${P}" fill="#8090b0" opacity="0.4"/>
2265
+ </g>
2266
+ `;
2267
+ }
2268
+
2269
+ // --- Tent glow (dawn only) ---
2270
+ let tentGlow = '';
2271
+ if (period === 'dawn') {
2272
+ tentGlow = `<rect x="88" y="180" width="4" height="4" fill="#ffcc44" opacity="0.3"/>`;
2273
+ }
2274
+
2275
+ // --- Sunrise glow (morning only) ---
2276
+ let sunriseGlow = '';
2277
+ if (period === 'morning') {
2278
+ sunriseGlow = `<rect x="360" y="140" width="280" height="60" fill="#e8834a" opacity="0.06"/>
2279
+ <rect x="400" y="160" width="200" height="40" fill="#f5a060" opacity="0.05"/>`;
2280
+ }
2281
+
2282
+ // --- Assemble SVG ---
2283
+ const svgString = `
2284
+ <svg viewBox="0 0 680 220" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid slice" style="image-rendering:pixelated">
2285
+ <defs>
2286
+ <linearGradient id="bc-sky" x1="0" y1="0" x2="0" y2="1">
2287
+ ${stops}
2288
+ </linearGradient>
2289
+ </defs>
2290
+
2291
+ <!-- Sky -->
2292
+ <rect width="680" height="220" fill="url(#bc-sky)"/>
2293
+
2294
+ ${sunriseGlow}
2295
+
2296
+ <!-- Stars / clouds -->
2297
+ ${starsHtml}
2298
+
2299
+ <!-- Far range mountains -->
2300
+ <polygon points="${farRangePoly}" fill="${theme.farRange}"/>
2301
+
2302
+ <!-- Snow caps -->
2303
+ ${snowCaps}
2304
+
2305
+ <!-- Mid range mountains -->
2306
+ <polygon points="${midRangePoly}" fill="${theme.midRange}"/>
2307
+
2308
+ <!-- Ground -->
2309
+ ${groundHtml}
2310
+
2311
+ <!-- Tent glow -->
2312
+ ${tentGlow}
2313
+
2314
+ <!-- Tents -->
2315
+ ${tents}
2316
+
2317
+ <!-- Prayer flags -->
2318
+ ${prayerFlags1}
2319
+ ${prayerFlags2}
2320
+
2321
+ <!-- Supplies & gear -->
2322
+ ${supplies}
2323
+
2324
+ <!-- Campfire -->
2325
+ ${campfireHtml}
2326
+
2327
+ <!-- Sherpa -->
2328
+ ${sherpaHtml}
2329
+
2330
+ <!-- Distant climber -->
2331
+ ${distantClimber}
2332
+ </svg>
2333
+ `;
2334
+
2335
+ container.innerHTML = svgString;
2336
+ }
2337
+
2338
+ function startSherpaQuotes() {
2339
+ const el = document.getElementById('sherpa-quote');
2340
+ if (!el) return;
2341
+
2342
+ const isFirstVisit = !localStorage.getItem(ONBOARDING_KEY);
2343
+
2344
+ if (isFirstVisit) {
2345
+ // First visit: start with tap prompt, then guide on click
2346
+ sherpaMode = 'intro';
2347
+ el.textContent = '나를 눌러보세유~';
2348
+ } else {
2349
+ setSherpaMode('grumpy');
2350
+ }
2351
+
2352
+ // Start rotation timer
2353
+ if (sherpaInterval) clearInterval(sherpaInterval);
2354
+ sherpaInterval = setInterval(() => {
2355
+ if (sherpaMode === 'intro') return; // Don't rotate during intro
2356
+ advanceSherpa();
2357
+ }, 8000);
2358
+ }
2359
+
2360
+ window.toggleSherpaMode = function() {
2361
+ const speech = document.getElementById('sherpa-speech');
2362
+ const el = document.getElementById('sherpa-quote');
2363
+ if (!el || !speech) return;
2364
+
2365
+ if (sherpaMode === 'intro') {
2366
+ // First click ever: enter guide mode
2367
+ setSherpaMode('guide');
2368
+ return;
2369
+ }
2370
+
2371
+ // Toggle between guide and grumpy
2372
+ const newMode = sherpaMode === 'guide' ? 'grumpy' : 'guide';
2373
+
2374
+ // Brief mode-switch message
2375
+ el.style.opacity = '0';
2376
+ setTimeout(() => {
2377
+ if (newMode === 'guide') {
2378
+ el.textContent = '가이드 모드여유~ 사용법 알려줄겨 📋';
2379
+ } else {
2380
+ el.textContent = '다시 푸념 모드여유... 😮‍💨';
1798
2381
  }
2382
+ el.style.opacity = '1';
1799
2383
 
1800
- const s = onboardingSteps[step];
2384
+ setTimeout(() => {
2385
+ setSherpaMode(newMode);
2386
+ }, 2000);
2387
+ }, 300);
2388
+ };
1801
2389
 
1802
- // Skip workspace steps if not in workspace
1803
- if (s.waitForWorkspace && !currentWorkspace) {
1804
- step++;
1805
- show();
2390
+ // ---------------------------------------------------------------------------
2391
+ // Activity Trail
2392
+ // ---------------------------------------------------------------------------
2393
+
2394
+ async function loadActivityTrail() {
2395
+ const container = document.getElementById('activity-trail');
2396
+ const section = document.getElementById('activity-trail-section');
2397
+ if (!container || !section) return;
2398
+
2399
+ try {
2400
+ const data = await api('GET', '/api/activity');
2401
+ if (!data || !data.daily || data.daily.length === 0) {
2402
+ section.style.display = 'none';
1806
2403
  return;
1807
2404
  }
1808
2405
 
1809
- const el = document.querySelector(s.target);
1810
- if (!el) { step++; show(); return; }
1811
-
1812
- const overlay = document.createElement('div');
1813
- overlay.className = 'onboarding-overlay';
1814
-
1815
- const rect = el.getBoundingClientRect();
1816
- const highlight = document.createElement('div');
1817
- highlight.className = 'onboarding-highlight';
1818
- highlight.style.top = `${rect.top - 4}px`;
1819
- highlight.style.left = `${rect.left - 4}px`;
1820
- highlight.style.width = `${rect.width + 8}px`;
1821
- highlight.style.height = `${rect.height + 8}px`;
1822
- overlay.appendChild(highlight);
1823
-
1824
- const tooltip = document.createElement('div');
1825
- tooltip.className = 'onboarding-tooltip';
1826
- tooltip.innerHTML = `
1827
- <div class="onboarding-title">${s.title}</div>
1828
- <div class="onboarding-text">${s.text}</div>
1829
- <div class="onboarding-actions">
1830
- <span class="onboarding-step">${step + 1}/${onboardingSteps.length}</span>
1831
- <button class="btn btn-ghost btn-sm" onclick="skipOnboarding()">건너뛰기</button>
1832
- <button class="btn btn-primary btn-sm" onclick="nextOnboardingStep()">${step === onboardingSteps.length - 1 ? '완료' : '다음'}</button>
1833
- </div>`;
2406
+ section.style.display = '';
2407
+ const days = data.daily;
2408
+ const prs = data.mergedPrs || [];
2409
+ const streak = data.streak || 0;
2410
+ const totalCommits = days.reduce((s, d) => s + d.commits, 0);
2411
+
2412
+ // Calculate terrain heights
2413
+ const maxCommits = Math.max(...days.map(d => d.commits), 1);
2414
+ const svgW = 680, svgH = 180;
2415
+ const ground = 150, ceiling = 35;
2416
+ const dayW = (svgW - 40) / days.length; // 20px padding each side
2417
+
2418
+ // Map commits to Y coordinate (more commits = higher = lower Y)
2419
+ const heights = days.map(d => {
2420
+ if (d.commits === 0) return ground;
2421
+ return ground - ((d.commits / maxCommits) * (ground - ceiling));
2422
+ });
1834
2423
 
1835
- // Position tooltip near target
1836
- if (s.position === 'bottom') {
1837
- tooltip.style.top = `${rect.bottom + 12}px`;
1838
- tooltip.style.left = `${Math.max(12, rect.left)}px`;
1839
- } else if (s.position === 'left') {
1840
- tooltip.style.top = `${rect.top}px`;
1841
- tooltip.style.right = `${window.innerWidth - rect.left + 12}px`;
1842
- } else {
1843
- tooltip.style.top = '50%';
1844
- tooltip.style.left = '50%';
1845
- tooltip.style.transform = 'translate(-50%, -50%)';
2424
+ // Build stepped polyline points (pixel staircase)
2425
+ let terrainPoints = `20,${ground} `;
2426
+ heights.forEach((h, i) => {
2427
+ const x = 20 + i * dayW;
2428
+ const xEnd = 20 + (i + 1) * dayW;
2429
+ terrainPoints += `${x},${h} ${xEnd},${h} `;
2430
+ });
2431
+ terrainPoints += `${20 + days.length * dayW},${ground}`;
2432
+
2433
+ // PR merge dates set for lookup
2434
+ const prByDate = {};
2435
+ prs.forEach(pr => {
2436
+ const date = pr.mergedAt.split('T')[0];
2437
+ if (!prByDate[date]) prByDate[date] = [];
2438
+ prByDate[date].push(pr);
2439
+ });
2440
+
2441
+ // Build pixel decorations
2442
+ let decorations = '';
2443
+
2444
+ // Stars
2445
+ const starPositions = [[45,8],[130,14],[220,6],[350,10],[480,5],[560,16],[640,8]];
2446
+ starPositions.forEach(([x,y]) => {
2447
+ decorations += `<rect x="${x}" y="${y}" width="2" height="2" fill="#fff" opacity="${0.2 + Math.random() * 0.3}"/>`;
2448
+ });
2449
+
2450
+ // Snow on high peaks (top 20%)
2451
+ const threshold = ceiling + (ground - ceiling) * 0.3;
2452
+ heights.forEach((h, i) => {
2453
+ if (h < threshold) {
2454
+ const x = 20 + i * dayW + dayW / 2 - 3;
2455
+ decorations += `<rect x="${x}" y="${h}" width="6" height="2" fill="rgba(255,255,255,0.2)"/>`;
2456
+ }
2457
+ });
2458
+
2459
+ // Trees on low terrain
2460
+ heights.forEach((h, i) => {
2461
+ if (h > ground - 30 && h < ground && days[i].commits > 0 && Math.random() > 0.7) {
2462
+ const x = 20 + i * dayW + dayW / 2;
2463
+ decorations += `
2464
+ <rect x="${x}" y="${h - 8}" width="2" height="8" fill="#3d5a3d"/>
2465
+ <rect x="${x - 2}" y="${h - 12}" width="6" height="4" fill="#4a7a4a"/>
2466
+ <rect x="${x}" y="${h - 16}" width="2" height="4" fill="#5b8c5a"/>`;
2467
+ }
2468
+ });
2469
+
2470
+ // Tents on rest days (0 commits, not first/last)
2471
+ heights.forEach((h, i) => {
2472
+ if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() > 0.5) {
2473
+ const x = 20 + i * dayW + dayW / 2 - 4;
2474
+ decorations += `
2475
+ <rect x="${x + 3}" y="${ground - 8}" width="2" height="2" fill="#6b7394"/>
2476
+ <rect x="${x + 1}" y="${ground - 6}" width="6" height="2" fill="#6b7394"/>
2477
+ <rect x="${x}" y="${ground - 4}" width="8" height="2" fill="#4a5170"/>`;
2478
+ }
2479
+ });
2480
+
2481
+ // PR campfires + tooltip triggers
2482
+ let prMarkers = '';
2483
+ days.forEach((d, i) => {
2484
+ const datePrs = prByDate[d.date];
2485
+ if (datePrs && datePrs.length > 0) {
2486
+ const x = 20 + i * dayW + dayW / 2 - 4;
2487
+ const h = heights[i];
2488
+ const tooltipText = datePrs.map(p => `#${p.number} ${p.title}`).join('\n');
2489
+ prMarkers += `
2490
+ <g class="pr-marker" data-tooltip="${tooltipText.replace(/"/g, '&quot;')}">
2491
+ <rect x="${x}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
2492
+ <rect x="${x + 4}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
2493
+ <rect x="${x + 2}" y="${h - 8}" width="2" height="4" fill="#ff6600"/>
2494
+ <rect x="${x}" y="${h - 10}" width="2" height="2" fill="#ffcc00"/>
2495
+ <rect x="${x + 4}" y="${h - 12}" width="2" height="4" fill="#ff9900"/>
2496
+ <rect x="${x + 2}" y="${h - 14}" width="2" height="4" fill="#ffcc00"/>
2497
+ <circle cx="${x + 3}" cy="${h - 8}" r="6" fill="#ff9900" opacity="0.06"/>
2498
+ </g>`;
2499
+ }
2500
+ });
2501
+
2502
+ // Coins on top 3 peaks
2503
+ const peakIndices = heights
2504
+ .map((h, i) => ({ h, i }))
2505
+ .sort((a, b) => a.h - b.h)
2506
+ .slice(0, 3);
2507
+ peakIndices.forEach(({ h, i }) => {
2508
+ const x = 20 + i * dayW + dayW / 2 - 3;
2509
+ decorations += `
2510
+ <rect x="${x}" y="${h - 10}" width="6" height="6" fill="#f59e0b" opacity="0.7"/>
2511
+ <rect x="${x + 2}" y="${h - 8}" width="2" height="2" fill="#0a0c11"/>`;
2512
+ });
2513
+
2514
+ // Flag on highest peak
2515
+ const highest = peakIndices[0];
2516
+ if (highest) {
2517
+ const x = 20 + highest.i * dayW + dayW / 2;
2518
+ decorations += `
2519
+ <rect x="${x}" y="${highest.h}" width="2" height="14" fill="#f59e0b"/>
2520
+ <polygon points="${x + 2},${highest.h} ${x + 8},${highest.h + 3} ${x + 2},${highest.h + 6}" fill="#f59e0b"/>`;
1846
2521
  }
1847
2522
 
1848
- overlay.appendChild(tooltip);
1849
- document.body.appendChild(overlay);
1850
- }
2523
+ // Sherpa at today (last position)
2524
+ const lastX = 20 + (days.length - 1) * dayW + dayW / 2 - 4;
2525
+ const lastH = heights[heights.length - 1];
2526
+ const sherpaY = lastH - 16;
2527
+ const sherpa = `
2528
+ <g transform="translate(${lastX}, ${sherpaY})">
2529
+ <rect x="2" y="0" width="4" height="2" fill="#5b8c5a"/>
2530
+ <rect x="0" y="2" width="8" height="2" fill="#5b8c5a"/>
2531
+ <rect x="2" y="4" width="4" height="4" fill="#ffcc88"/>
2532
+ <rect x="2" y="8" width="4" height="4" fill="#e74c3c"/>
2533
+ <rect x="0" y="8" width="2" height="2" fill="#e74c3c"/>
2534
+ <rect x="6" y="8" width="2" height="4" fill="#8B6914"/>
2535
+ <rect x="2" y="12" width="2" height="2" fill="#5b4a3a"/>
2536
+ <rect x="4" y="12" width="2" height="2" fill="#5b4a3a"/>
2537
+ </g>`;
2538
+
2539
+ // Week labels
2540
+ let weekLabels = '';
2541
+ const weekSize = 7;
2542
+ for (let w = 0; w < Math.floor(days.length / weekSize); w++) {
2543
+ const x = 20 + (w * weekSize + 3) * dayW;
2544
+ const weeksAgo = Math.floor(days.length / weekSize) - w - 1;
2545
+ const label = weeksAgo === 0 ? '이번 주' : `${weeksAgo}주 전`;
2546
+ weekLabels += `<text x="${x}" y="170" font-size="8" fill="#4a5170" font-family="Outfit,sans-serif" text-anchor="middle">${label}</text>`;
2547
+ }
1851
2548
 
1852
- window.nextOnboardingStep = function() {
1853
- step++;
1854
- show();
1855
- };
2549
+ // Pixel clouds
2550
+ const clouds = `
2551
+ <g opacity="0.08">
2552
+ <rect x="80" y="20" width="4" height="4" fill="#fff"/>
2553
+ <rect x="84" y="18" width="8" height="4" fill="#fff"/>
2554
+ <rect x="88" y="16" width="4" height="4" fill="#fff"/>
2555
+ <rect x="92" y="18" width="4" height="4" fill="#fff"/>
2556
+ <rect x="76" y="22" width="24" height="4" fill="#fff"/>
2557
+ </g>
2558
+ <g opacity="0.06">
2559
+ <rect x="420" y="14" width="4" height="4" fill="#fff"/>
2560
+ <rect x="424" y="12" width="8" height="4" fill="#fff"/>
2561
+ <rect x="432" y="14" width="4" height="4" fill="#fff"/>
2562
+ <rect x="416" y="18" width="24" height="4" fill="#fff"/>
2563
+ </g>`;
2564
+
2565
+ // PR tooltip container (CSS positioned)
2566
+ const tooltip = `<div class="activity-tooltip" id="activity-tooltip" style="display:none;position:absolute;background:#1c2030;border:1px solid #2a2f42;border-radius:6px;padding:8px 12px;font-size:12px;color:#e4e8f0;pointer-events:none;white-space:nowrap;z-index:10;max-width:320px;box-shadow:0 4px 12px rgba(0,0,0,0.4);"></div>`;
2567
+
2568
+ const svg = `
2569
+ <svg viewBox="0 0 ${svgW} ${svgH}" style="display:block;width:100%;" xmlns="http://www.w3.org/2000/svg">
2570
+ <rect x="0" y="0" width="${svgW}" height="${svgH}" fill="#080a10"/>
2571
+ ${clouds}
2572
+ ${decorations}
2573
+ <defs>
2574
+ <linearGradient id="trailFill" x1="0" y1="0" x2="0" y2="1">
2575
+ <stop offset="0%" stop-color="#5b8c5a" stop-opacity="0.25"/>
2576
+ <stop offset="100%" stop-color="#5b8c5a" stop-opacity="0.03"/>
2577
+ </linearGradient>
2578
+ </defs>
2579
+ <polygon fill="url(#trailFill)" points="${terrainPoints}"/>
2580
+ <polyline fill="none" stroke="#5b8c5a" stroke-width="2" points="${terrainPoints.split(` ${20 + days.length * dayW},${ground}`)[0]}"/>
2581
+ <line x1="16" y1="${ground}" x2="${svgW - 16}" y2="${ground}" stroke="#1c2030" stroke-width="1"/>
2582
+ ${prMarkers}
2583
+ ${sherpa}
2584
+ ${weekLabels}
2585
+ </svg>`;
2586
+
2587
+ const streakText = streak > 0 ? `🔥 연속 ${streak}일째 등반 중` : '⛺ 오늘은 쉬는 날';
2588
+ const periodText = `최근 4주 · 커밋 ${totalCommits}개 · PR ${prs.length}개`;
2589
+
2590
+ container.innerHTML = `
2591
+ <div style="position:relative;">
2592
+ ${svg}
2593
+ ${tooltip}
2594
+ </div>
2595
+ <div class="activity-info">
2596
+ <div class="activity-streak">${streakText}</div>
2597
+ <div class="activity-period">${periodText}</div>
2598
+ </div>`;
1856
2599
 
1857
- window.skipOnboarding = function() {
1858
- document.querySelector('.onboarding-overlay')?.remove();
1859
- localStorage.setItem(ONBOARDING_KEY, '1');
1860
- };
2600
+ // Add PR tooltip hover handlers
2601
+ container.querySelectorAll('.pr-marker').forEach(marker => {
2602
+ marker.style.cursor = 'pointer';
2603
+ marker.addEventListener('mouseenter', (e) => {
2604
+ const tip = document.getElementById('activity-tooltip');
2605
+ if (!tip) return;
2606
+ tip.innerHTML = marker.getAttribute('data-tooltip').split('\n').map(l => escHtml(l)).join('<br>');
2607
+ tip.style.display = 'block';
2608
+ const rect = marker.getBoundingClientRect();
2609
+ const containerRect = container.getBoundingClientRect();
2610
+ let left = rect.left - containerRect.left;
2611
+ // Prevent overflow on right edge
2612
+ const tipWidth = tip.offsetWidth;
2613
+ if (left + tipWidth > containerRect.width) {
2614
+ left = containerRect.width - tipWidth - 8;
2615
+ }
2616
+ if (left < 8) left = 8;
2617
+ tip.style.left = left + 'px';
2618
+ tip.style.top = (rect.top - containerRect.top - 40) + 'px';
2619
+ });
2620
+ marker.addEventListener('mouseleave', () => {
2621
+ const tip = document.getElementById('activity-tooltip');
2622
+ if (tip) tip.style.display = 'none';
2623
+ });
2624
+ });
1861
2625
 
1862
- // Start first step (only if on portal/quickstart visible)
1863
- show();
2626
+ } catch {
2627
+ if (section) section.style.display = 'none';
2628
+ }
1864
2629
  }
1865
2630
 
1866
2631
  // ---------------------------------------------------------------------------
@@ -1878,11 +2643,11 @@ async function init() {
1878
2643
  }
1879
2644
  renderAll();
1880
2645
  loadPortal();
1881
- loadSuggestions();
2646
+ renderBasecampScene();
2647
+ startSherpaQuotes();
2648
+ loadActivityTrail();
1882
2649
  connectWs();
1883
2650
 
1884
- // Show onboarding for first-time users
1885
- setTimeout(showOnboarding, 500);
1886
2651
  }
1887
2652
 
1888
2653
  document.addEventListener('DOMContentLoaded', init);