sanjang 0.3.2 → 0.3.3

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
  // ---------------------------------------------------------------------------
@@ -1280,7 +1303,7 @@ function renderBlocks(files) {
1280
1303
  }
1281
1304
  container.innerHTML = files.map(f => {
1282
1305
  const type = f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del';
1283
- return `<div class="ws-block ws-block-${type}" title="${f.path}"></div>`;
1306
+ return `<div class="ws-block ws-block-${type}" title="${escHtml(f.path)}"></div>`;
1284
1307
  }).join('');
1285
1308
  container.classList.toggle('ws-blocks-wobble', files.length >= 5);
1286
1309
  }
@@ -1614,48 +1637,6 @@ window.togglePanel = function() {
1614
1637
  // ---------------------------------------------------------------------------
1615
1638
 
1616
1639
 
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
1640
  async function loadPortal() {
1660
1641
  const workList = document.getElementById('portal-work');
1661
1642
  if (!workList) return;
@@ -1863,6 +1844,284 @@ function showOnboarding() {
1863
1844
  show();
1864
1845
  }
1865
1846
 
1847
+ // ---------------------------------------------------------------------------
1848
+ // Sherpa Quote Rotation
1849
+ // ---------------------------------------------------------------------------
1850
+
1851
+ function startSherpaQuotes() {
1852
+ const el = document.getElementById('sherpa-quote');
1853
+ if (!el) return;
1854
+
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
+ }
1861
+
1862
+ let idx = 0;
1863
+ // Set initial random quote
1864
+ el.textContent = quotes[idx];
1865
+
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);
1881
+ }, 8000);
1882
+ }
1883
+
1884
+ // ---------------------------------------------------------------------------
1885
+ // Activity Trail
1886
+ // ---------------------------------------------------------------------------
1887
+
1888
+ async function loadActivityTrail() {
1889
+ const container = document.getElementById('activity-trail');
1890
+ const section = document.getElementById('activity-trail-section');
1891
+ if (!container || !section) return;
1892
+
1893
+ try {
1894
+ const data = await api('GET', '/api/activity');
1895
+ if (!data || !data.daily || data.daily.length === 0) {
1896
+ section.style.display = 'none';
1897
+ return;
1898
+ }
1899
+
1900
+ section.style.display = '';
1901
+ const days = data.daily;
1902
+ const prs = data.mergedPrs || [];
1903
+ const streak = data.streak || 0;
1904
+ const totalCommits = days.reduce((s, d) => s + d.commits, 0);
1905
+
1906
+ // Calculate terrain heights
1907
+ const maxCommits = Math.max(...days.map(d => d.commits), 1);
1908
+ const svgW = 680, svgH = 180;
1909
+ const ground = 150, ceiling = 35;
1910
+ const dayW = (svgW - 40) / days.length; // 20px padding each side
1911
+
1912
+ // Map commits to Y coordinate (more commits = higher = lower Y)
1913
+ const heights = days.map(d => {
1914
+ if (d.commits === 0) return ground;
1915
+ return ground - ((d.commits / maxCommits) * (ground - ceiling));
1916
+ });
1917
+
1918
+ // Build stepped polyline points (pixel staircase)
1919
+ let terrainPoints = `20,${ground} `;
1920
+ heights.forEach((h, i) => {
1921
+ const x = 20 + i * dayW;
1922
+ const xEnd = 20 + (i + 1) * dayW;
1923
+ terrainPoints += `${x},${h} ${xEnd},${h} `;
1924
+ });
1925
+ terrainPoints += `${20 + days.length * dayW},${ground}`;
1926
+
1927
+ // PR merge dates set for lookup
1928
+ const prByDate = {};
1929
+ prs.forEach(pr => {
1930
+ const date = pr.mergedAt.split('T')[0];
1931
+ if (!prByDate[date]) prByDate[date] = [];
1932
+ prByDate[date].push(pr);
1933
+ });
1934
+
1935
+ // Build pixel decorations
1936
+ let decorations = '';
1937
+
1938
+ // Stars
1939
+ const starPositions = [[45,8],[130,14],[220,6],[350,10],[480,5],[560,16],[640,8]];
1940
+ starPositions.forEach(([x,y]) => {
1941
+ decorations += `<rect x="${x}" y="${y}" width="2" height="2" fill="#fff" opacity="${0.2 + Math.random() * 0.3}"/>`;
1942
+ });
1943
+
1944
+ // Snow on high peaks (top 20%)
1945
+ const threshold = ceiling + (ground - ceiling) * 0.3;
1946
+ heights.forEach((h, i) => {
1947
+ if (h < threshold) {
1948
+ const x = 20 + i * dayW + dayW / 2 - 3;
1949
+ decorations += `<rect x="${x}" y="${h}" width="6" height="2" fill="rgba(255,255,255,0.2)"/>`;
1950
+ }
1951
+ });
1952
+
1953
+ // Trees on low terrain
1954
+ heights.forEach((h, i) => {
1955
+ if (h > ground - 30 && h < ground && days[i].commits > 0 && Math.random() > 0.7) {
1956
+ const x = 20 + i * dayW + dayW / 2;
1957
+ decorations += `
1958
+ <rect x="${x}" y="${h - 8}" width="2" height="8" fill="#3d5a3d"/>
1959
+ <rect x="${x - 2}" y="${h - 12}" width="6" height="4" fill="#4a7a4a"/>
1960
+ <rect x="${x}" y="${h - 16}" width="2" height="4" fill="#5b8c5a"/>`;
1961
+ }
1962
+ });
1963
+
1964
+ // Tents on rest days (0 commits, not first/last)
1965
+ heights.forEach((h, i) => {
1966
+ if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() > 0.5) {
1967
+ const x = 20 + i * dayW + dayW / 2 - 4;
1968
+ decorations += `
1969
+ <rect x="${x + 3}" y="${ground - 8}" width="2" height="2" fill="#6b7394"/>
1970
+ <rect x="${x + 1}" y="${ground - 6}" width="6" height="2" fill="#6b7394"/>
1971
+ <rect x="${x}" y="${ground - 4}" width="8" height="2" fill="#4a5170"/>`;
1972
+ }
1973
+ });
1974
+
1975
+ // PR campfires + tooltip triggers
1976
+ let prMarkers = '';
1977
+ days.forEach((d, i) => {
1978
+ const datePrs = prByDate[d.date];
1979
+ if (datePrs && datePrs.length > 0) {
1980
+ const x = 20 + i * dayW + dayW / 2 - 4;
1981
+ const h = heights[i];
1982
+ const tooltipText = datePrs.map(p => `#${p.number} ${p.title}`).join('\n');
1983
+ prMarkers += `
1984
+ <g class="pr-marker" data-tooltip="${tooltipText.replace(/"/g, '&quot;')}">
1985
+ <rect x="${x}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
1986
+ <rect x="${x + 4}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
1987
+ <rect x="${x + 2}" y="${h - 8}" width="2" height="4" fill="#ff6600"/>
1988
+ <rect x="${x}" y="${h - 10}" width="2" height="2" fill="#ffcc00"/>
1989
+ <rect x="${x + 4}" y="${h - 12}" width="2" height="4" fill="#ff9900"/>
1990
+ <rect x="${x + 2}" y="${h - 14}" width="2" height="4" fill="#ffcc00"/>
1991
+ <circle cx="${x + 3}" cy="${h - 8}" r="6" fill="#ff9900" opacity="0.06"/>
1992
+ </g>`;
1993
+ }
1994
+ });
1995
+
1996
+ // Coins on top 3 peaks
1997
+ const peakIndices = heights
1998
+ .map((h, i) => ({ h, i }))
1999
+ .sort((a, b) => a.h - b.h)
2000
+ .slice(0, 3);
2001
+ peakIndices.forEach(({ h, i }) => {
2002
+ const x = 20 + i * dayW + dayW / 2 - 3;
2003
+ decorations += `
2004
+ <rect x="${x}" y="${h - 10}" width="6" height="6" fill="#f59e0b" opacity="0.7"/>
2005
+ <rect x="${x + 2}" y="${h - 8}" width="2" height="2" fill="#0a0c11"/>`;
2006
+ });
2007
+
2008
+ // Flag on highest peak
2009
+ const highest = peakIndices[0];
2010
+ if (highest) {
2011
+ const x = 20 + highest.i * dayW + dayW / 2;
2012
+ decorations += `
2013
+ <rect x="${x}" y="${highest.h}" width="2" height="14" fill="#f59e0b"/>
2014
+ <polygon points="${x + 2},${highest.h} ${x + 8},${highest.h + 3} ${x + 2},${highest.h + 6}" fill="#f59e0b"/>`;
2015
+ }
2016
+
2017
+ // Sherpa at today (last position)
2018
+ const lastX = 20 + (days.length - 1) * dayW + dayW / 2 - 4;
2019
+ const lastH = heights[heights.length - 1];
2020
+ const sherpaY = lastH - 16;
2021
+ const sherpa = `
2022
+ <g transform="translate(${lastX}, ${sherpaY})">
2023
+ <rect x="2" y="0" width="4" height="2" fill="#5b8c5a"/>
2024
+ <rect x="0" y="2" width="8" height="2" fill="#5b8c5a"/>
2025
+ <rect x="2" y="4" width="4" height="4" fill="#ffcc88"/>
2026
+ <rect x="2" y="8" width="4" height="4" fill="#e74c3c"/>
2027
+ <rect x="0" y="8" width="2" height="2" fill="#e74c3c"/>
2028
+ <rect x="6" y="8" width="2" height="4" fill="#8B6914"/>
2029
+ <rect x="2" y="12" width="2" height="2" fill="#5b4a3a"/>
2030
+ <rect x="4" y="12" width="2" height="2" fill="#5b4a3a"/>
2031
+ </g>`;
2032
+
2033
+ // Week labels
2034
+ let weekLabels = '';
2035
+ const weekSize = 7;
2036
+ for (let w = 0; w < Math.floor(days.length / weekSize); w++) {
2037
+ const x = 20 + (w * weekSize + 3) * dayW;
2038
+ const weeksAgo = Math.floor(days.length / weekSize) - w - 1;
2039
+ const label = weeksAgo === 0 ? '이번 주' : `${weeksAgo}주 전`;
2040
+ weekLabels += `<text x="${x}" y="170" font-size="8" fill="#4a5170" font-family="Outfit,sans-serif" text-anchor="middle">${label}</text>`;
2041
+ }
2042
+
2043
+ // Pixel clouds
2044
+ const clouds = `
2045
+ <g opacity="0.08">
2046
+ <rect x="80" y="20" width="4" height="4" fill="#fff"/>
2047
+ <rect x="84" y="18" width="8" height="4" fill="#fff"/>
2048
+ <rect x="88" y="16" width="4" height="4" fill="#fff"/>
2049
+ <rect x="92" y="18" width="4" height="4" fill="#fff"/>
2050
+ <rect x="76" y="22" width="24" height="4" fill="#fff"/>
2051
+ </g>
2052
+ <g opacity="0.06">
2053
+ <rect x="420" y="14" width="4" height="4" fill="#fff"/>
2054
+ <rect x="424" y="12" width="8" height="4" fill="#fff"/>
2055
+ <rect x="432" y="14" width="4" height="4" fill="#fff"/>
2056
+ <rect x="416" y="18" width="24" height="4" fill="#fff"/>
2057
+ </g>`;
2058
+
2059
+ // PR tooltip container (CSS positioned)
2060
+ 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>`;
2061
+
2062
+ const svg = `
2063
+ <svg viewBox="0 0 ${svgW} ${svgH}" style="display:block;width:100%;" xmlns="http://www.w3.org/2000/svg">
2064
+ <rect x="0" y="0" width="${svgW}" height="${svgH}" fill="#080a10"/>
2065
+ ${clouds}
2066
+ ${decorations}
2067
+ <defs>
2068
+ <linearGradient id="trailFill" x1="0" y1="0" x2="0" y2="1">
2069
+ <stop offset="0%" stop-color="#5b8c5a" stop-opacity="0.25"/>
2070
+ <stop offset="100%" stop-color="#5b8c5a" stop-opacity="0.03"/>
2071
+ </linearGradient>
2072
+ </defs>
2073
+ <polygon fill="url(#trailFill)" points="${terrainPoints}"/>
2074
+ <polyline fill="none" stroke="#5b8c5a" stroke-width="2" points="${terrainPoints.split(` ${20 + days.length * dayW},${ground}`)[0]}"/>
2075
+ <line x1="16" y1="${ground}" x2="${svgW - 16}" y2="${ground}" stroke="#1c2030" stroke-width="1"/>
2076
+ ${prMarkers}
2077
+ ${sherpa}
2078
+ ${weekLabels}
2079
+ </svg>`;
2080
+
2081
+ const streakText = streak > 0 ? `🔥 연속 ${streak}일째 등반 중` : '⛺ 오늘은 쉬는 날';
2082
+ const periodText = `최근 4주 · 커밋 ${totalCommits}개 · PR ${prs.length}개`;
2083
+
2084
+ container.innerHTML = `
2085
+ <div style="position:relative;">
2086
+ ${svg}
2087
+ ${tooltip}
2088
+ </div>
2089
+ <div class="activity-info">
2090
+ <div class="activity-streak">${streakText}</div>
2091
+ <div class="activity-period">${periodText}</div>
2092
+ </div>`;
2093
+
2094
+ // Add PR tooltip hover handlers
2095
+ container.querySelectorAll('.pr-marker').forEach(marker => {
2096
+ marker.style.cursor = 'pointer';
2097
+ marker.addEventListener('mouseenter', (e) => {
2098
+ const tip = document.getElementById('activity-tooltip');
2099
+ if (!tip) return;
2100
+ tip.innerHTML = marker.getAttribute('data-tooltip').split('\n').map(l => escHtml(l)).join('<br>');
2101
+ tip.style.display = 'block';
2102
+ const rect = marker.getBoundingClientRect();
2103
+ const containerRect = container.getBoundingClientRect();
2104
+ let left = rect.left - containerRect.left;
2105
+ // Prevent overflow on right edge
2106
+ const tipWidth = tip.offsetWidth;
2107
+ if (left + tipWidth > containerRect.width) {
2108
+ left = containerRect.width - tipWidth - 8;
2109
+ }
2110
+ if (left < 8) left = 8;
2111
+ tip.style.left = left + 'px';
2112
+ tip.style.top = (rect.top - containerRect.top - 40) + 'px';
2113
+ });
2114
+ marker.addEventListener('mouseleave', () => {
2115
+ const tip = document.getElementById('activity-tooltip');
2116
+ if (tip) tip.style.display = 'none';
2117
+ });
2118
+ });
2119
+
2120
+ } catch {
2121
+ if (section) section.style.display = 'none';
2122
+ }
2123
+ }
2124
+
1866
2125
  // ---------------------------------------------------------------------------
1867
2126
  // Init
1868
2127
  // ---------------------------------------------------------------------------
@@ -1878,7 +2137,8 @@ async function init() {
1878
2137
  }
1879
2138
  renderAll();
1880
2139
  loadPortal();
1881
- loadSuggestions();
2140
+ startSherpaQuotes();
2141
+ loadActivityTrail();
1882
2142
  connectWs();
1883
2143
 
1884
2144
  // Show onboarding for first-time users
@@ -17,6 +17,44 @@
17
17
 
18
18
  <!-- Portal Home -->
19
19
  <div id="portal">
20
+ <!-- 베이스캠프 씬 -->
21
+ <div class="portal-section" id="basecamp-scene">
22
+ <div class="bc-scene">
23
+ <!-- Stars -->
24
+ <div class="bc-star twinkle" style="top:8%;left:12%"></div>
25
+ <div class="bc-star twinkle-slow" style="top:15%;left:30%"></div>
26
+ <div class="bc-star twinkle" style="top:5%;left:48%"></div>
27
+ <div class="bc-star twinkle-slow" style="top:20%;left:65%"></div>
28
+ <div class="bc-star twinkle" style="top:10%;left:80%"></div>
29
+ <div class="bc-star twinkle-slow" style="top:25%;left:92%"></div>
30
+ <div class="bc-star twinkle" style="top:18%;left:5%"></div>
31
+ <div class="bc-star twinkle-slow" style="top:6%;left:72%"></div>
32
+ <div class="bc-star twinkle" style="top:22%;left:42%"></div>
33
+ <div class="bc-star twinkle-slow" style="top:12%;left:55%"></div>
34
+
35
+ <!-- Mountains -->
36
+ <div class="bc-mountain bc-mountain-back"></div>
37
+ <div class="bc-mountain bc-mountain-front"></div>
38
+
39
+ <!-- Snow caps -->
40
+ <div class="bc-snow" style="bottom:64px;left:48%;width:12px"></div>
41
+ <div class="bc-snow" style="bottom:60px;left:50%;width:8px"></div>
42
+ <div class="bc-snow" style="bottom:56px;left:46%;width:4px"></div>
43
+
44
+ <!-- Campfire glow + campfire -->
45
+ <div class="bc-glow"></div>
46
+ <div class="bc-campfire"></div>
47
+
48
+ <!-- Sherpa -->
49
+ <div class="bc-sherpa"></div>
50
+
51
+ <!-- Speech bubble -->
52
+ <div class="bc-speech fade-in" id="sherpa-speech">
53
+ <span id="sherpa-quote">요구사항 또 바뀌었댜... 뭐 그러려니 하쥬</span>
54
+ </div>
55
+ </div>
56
+ </div>
57
+
20
58
  <!-- 새로 시작 (핵심 액션, 맨 위) -->
21
59
  <div class="portal-section">
22
60
  <div class="portal-quickstart">
@@ -34,17 +72,16 @@
34
72
  <div id="portal-work" class="portal-work-list"></div>
35
73
  </div>
36
74
 
37
- <!-- 추천 작업 (최대 5개, 이어하기와 중복 제거) -->
38
- <div class="portal-section" id="portal-suggestions-section" style="display:none">
39
- <h2 class="portal-section-title">이런 걸 해볼 수 있어요</h2>
40
- <div id="portal-suggestions" class="portal-work-list"></div>
41
- </div>
42
-
43
75
  <!-- 기존 캠프가 있으면 아래에 카드 그리드 표시 -->
44
76
  <div id="portal-camps-section" class="portal-section hidden">
45
77
  <h2 class="portal-section-title">캠프</h2>
46
78
  <div class="grid" id="grid"></div>
47
79
  </div>
80
+
81
+ <!-- 활동 지형도 -->
82
+ <div class="portal-section" id="activity-trail-section" style="display:none">
83
+ <div id="activity-trail"></div>
84
+ </div>
48
85
  </div>
49
86
 
50
87
  <!-- Workspace View (full-screen preview + slide panel) -->
@@ -2110,3 +2110,309 @@ header.hidden {
2110
2110
  color: var(--text-muted, #666);
2111
2111
  flex: 1;
2112
2112
  }
2113
+
2114
+ /* ============================================================
2115
+ Basecamp Scene
2116
+ ============================================================ */
2117
+
2118
+ .bc-scene {
2119
+ position: relative;
2120
+ height: 200px;
2121
+ background: linear-gradient(180deg, #080a10 0%, #0c0e14 60%, #12151e 100%);
2122
+ border-radius: 12px;
2123
+ overflow: hidden;
2124
+ border: 1px solid #1c2030;
2125
+ }
2126
+
2127
+ /* Stars */
2128
+ .bc-star {
2129
+ position: absolute;
2130
+ width: 4px;
2131
+ height: 4px;
2132
+ background: #fff;
2133
+ image-rendering: pixelated;
2134
+ }
2135
+
2136
+ .bc-star.twinkle {
2137
+ animation: twinkle 2s steps(2) infinite;
2138
+ }
2139
+
2140
+ .bc-star.twinkle-slow {
2141
+ animation: twinkle 3.5s steps(2) infinite 0.8s;
2142
+ }
2143
+
2144
+ @keyframes twinkle {
2145
+ 0%, 100% { opacity: 0.6; }
2146
+ 50% { opacity: 0.1; }
2147
+ }
2148
+
2149
+ /* Mountains */
2150
+ .bc-mountain {
2151
+ position: absolute;
2152
+ bottom: 0;
2153
+ left: 0;
2154
+ right: 0;
2155
+ image-rendering: pixelated;
2156
+ }
2157
+
2158
+ .bc-mountain-back {
2159
+ height: 100px;
2160
+ background: #1a1d2a;
2161
+ clip-path: polygon(
2162
+ 0% 100%, 0% 70%, 5% 70%, 5% 60%, 10% 60%, 10% 50%, 15% 50%, 15% 40%,
2163
+ 20% 40%, 20% 35%, 25% 35%, 25% 45%, 30% 45%, 30% 55%, 35% 55%, 35% 50%,
2164
+ 40% 50%, 40% 35%, 45% 35%, 45% 25%, 50% 25%, 50% 20%, 55% 20%, 55% 30%,
2165
+ 60% 30%, 60% 40%, 65% 40%, 65% 50%, 70% 50%, 70% 40%, 75% 40%, 75% 50%,
2166
+ 80% 50%, 80% 60%, 85% 60%, 85% 70%, 90% 70%, 90% 80%, 95% 80%, 100% 80%,
2167
+ 100% 100%
2168
+ );
2169
+ }
2170
+
2171
+ .bc-mountain-front {
2172
+ height: 80px;
2173
+ background: #12151e;
2174
+ clip-path: polygon(
2175
+ 0% 100%, 0% 80%, 5% 80%, 5% 70%, 10% 70%, 10% 55%, 15% 55%, 15% 45%,
2176
+ 20% 45%, 20% 40%, 25% 40%, 25% 50%, 30% 50%, 30% 60%, 35% 60%, 35% 55%,
2177
+ 40% 55%, 40% 40%, 45% 40%, 45% 30%, 48% 30%, 48% 25%, 52% 25%, 52% 20%,
2178
+ 55% 20%, 55% 30%, 58% 30%, 58% 40%, 62% 40%, 62% 50%, 65% 50%, 65% 45%,
2179
+ 70% 45%, 70% 35%, 75% 35%, 75% 45%, 80% 45%, 80% 55%, 85% 55%, 85% 65%,
2180
+ 90% 65%, 90% 75%, 95% 75%, 95% 85%, 100% 85%, 100% 100%
2181
+ );
2182
+ }
2183
+
2184
+ /* Snow caps */
2185
+ .bc-snow {
2186
+ position: absolute;
2187
+ height: 4px;
2188
+ background: rgba(255, 255, 255, 0.25);
2189
+ image-rendering: pixelated;
2190
+ }
2191
+
2192
+ /* Campfire glow */
2193
+ .bc-glow {
2194
+ position: absolute;
2195
+ bottom: 0;
2196
+ left: 50%;
2197
+ transform: translateX(-50%);
2198
+ width: 80px;
2199
+ height: 40px;
2200
+ background: radial-gradient(ellipse at center bottom, rgba(255, 140, 50, 0.15) 0%, transparent 70%);
2201
+ animation: glow-pulse 2s ease infinite;
2202
+ }
2203
+
2204
+ @keyframes glow-pulse {
2205
+ 0%, 100% { opacity: 1; }
2206
+ 50% { opacity: 0.6; }
2207
+ }
2208
+
2209
+ /* Campfire pixel */
2210
+ .bc-campfire {
2211
+ position: absolute;
2212
+ bottom: 12px;
2213
+ left: 50%;
2214
+ transform: translateX(-50%);
2215
+ width: 4px;
2216
+ height: 4px;
2217
+ background: #8b4513;
2218
+ image-rendering: pixelated;
2219
+ box-shadow:
2220
+ -4px 0 0 #8b4513,
2221
+ 4px 0 0 #8b4513,
2222
+ 0 -4px 0 #ff6600,
2223
+ -4px -4px 0 #ff8800,
2224
+ 4px -4px 0 #ff8800,
2225
+ 0 -8px 0 #ffaa00,
2226
+ -4px -8px 0 #ff6600,
2227
+ 4px -8px 0 #ff6600,
2228
+ 0 -12px 0 #ffcc00;
2229
+ animation: bc-fire 0.4s steps(1) infinite;
2230
+ }
2231
+
2232
+ @keyframes bc-fire {
2233
+ 0% {
2234
+ box-shadow:
2235
+ -4px 0 0 #8b4513,
2236
+ 4px 0 0 #8b4513,
2237
+ 0 -4px 0 #ff6600,
2238
+ -4px -4px 0 #ff8800,
2239
+ 4px -4px 0 #ff8800,
2240
+ 0 -8px 0 #ffaa00,
2241
+ -4px -8px 0 #ff6600,
2242
+ 4px -8px 0 #ff6600,
2243
+ 0 -12px 0 #ffcc00;
2244
+ }
2245
+ 50% {
2246
+ box-shadow:
2247
+ -4px 0 0 #8b4513,
2248
+ 4px 0 0 #8b4513,
2249
+ 0 -4px 0 #ff8800,
2250
+ -4px -4px 0 #ff6600,
2251
+ 4px -4px 0 #ffaa00,
2252
+ 0 -8px 0 #ff6600,
2253
+ -4px -8px 0 #ffcc00,
2254
+ 4px -8px 0 #ff8800,
2255
+ 0 -12px 0 #ff6600;
2256
+ }
2257
+ }
2258
+
2259
+ /* Sherpa pixel character (idle pose) */
2260
+ .bc-sherpa {
2261
+ position: absolute;
2262
+ bottom: 16px;
2263
+ left: calc(50% + 24px);
2264
+ width: 4px;
2265
+ height: 4px;
2266
+ background: transparent;
2267
+ image-rendering: pixelated;
2268
+ box-shadow:
2269
+ /* Hat */
2270
+ 0 -20px 0 #e74c3c,
2271
+ -4px -20px 0 #e74c3c,
2272
+ 4px -20px 0 #e74c3c,
2273
+ -4px -24px 0 #e74c3c,
2274
+ 0 -24px 0 #e74c3c,
2275
+ 4px -24px 0 #e74c3c,
2276
+ /* Face */
2277
+ -4px -16px 0 #f5c6a0,
2278
+ 0 -16px 0 #f5c6a0,
2279
+ 4px -16px 0 #f5c6a0,
2280
+ /* Body */
2281
+ -4px -12px 0 #3498db,
2282
+ 0 -12px 0 #3498db,
2283
+ 4px -12px 0 #3498db,
2284
+ -4px -8px 0 #3498db,
2285
+ 0 -8px 0 #3498db,
2286
+ 4px -8px 0 #3498db,
2287
+ /* Backpack */
2288
+ 8px -12px 0 #8b6914,
2289
+ 8px -8px 0 #8b6914,
2290
+ /* Legs */
2291
+ -4px -4px 0 #2c3e50,
2292
+ 0 -4px 0 #2c3e50,
2293
+ -4px 0 0 #2c3e50,
2294
+ 4px 0 0 #2c3e50;
2295
+ animation: sherpa-idle 1s steps(1) infinite;
2296
+ }
2297
+
2298
+ @keyframes sherpa-idle {
2299
+ 0%, 100% {
2300
+ box-shadow:
2301
+ 0 -20px 0 #e74c3c,
2302
+ -4px -20px 0 #e74c3c,
2303
+ 4px -20px 0 #e74c3c,
2304
+ -4px -24px 0 #e74c3c,
2305
+ 0 -24px 0 #e74c3c,
2306
+ 4px -24px 0 #e74c3c,
2307
+ -4px -16px 0 #f5c6a0,
2308
+ 0 -16px 0 #f5c6a0,
2309
+ 4px -16px 0 #f5c6a0,
2310
+ -4px -12px 0 #3498db,
2311
+ 0 -12px 0 #3498db,
2312
+ 4px -12px 0 #3498db,
2313
+ -4px -8px 0 #3498db,
2314
+ 0 -8px 0 #3498db,
2315
+ 4px -8px 0 #3498db,
2316
+ 8px -12px 0 #8b6914,
2317
+ 8px -8px 0 #8b6914,
2318
+ -4px -4px 0 #2c3e50,
2319
+ 0 -4px 0 #2c3e50,
2320
+ -4px 0 0 #2c3e50,
2321
+ 4px 0 0 #2c3e50;
2322
+ }
2323
+ 50% {
2324
+ box-shadow:
2325
+ 0 -20px 0 #e74c3c,
2326
+ -4px -20px 0 #e74c3c,
2327
+ 4px -20px 0 #e74c3c,
2328
+ -4px -24px 0 #e74c3c,
2329
+ 0 -24px 0 #e74c3c,
2330
+ 4px -24px 0 #e74c3c,
2331
+ -4px -16px 0 #f5c6a0,
2332
+ 0 -16px 0 #f5c6a0,
2333
+ 4px -16px 0 #f5c6a0,
2334
+ -4px -12px 0 #3498db,
2335
+ 0 -12px 0 #3498db,
2336
+ 4px -12px 0 #3498db,
2337
+ -4px -8px 0 #3498db,
2338
+ 0 -8px 0 #3498db,
2339
+ 4px -8px 0 #3498db,
2340
+ 8px -12px 0 #8b6914,
2341
+ 8px -8px 0 #8b6914,
2342
+ -4px -4px 0 #2c3e50,
2343
+ 4px -4px 0 #2c3e50,
2344
+ -4px 0 0 #2c3e50,
2345
+ 0 0 0 #2c3e50;
2346
+ }
2347
+ }
2348
+
2349
+ /* Speech bubble */
2350
+ .bc-speech {
2351
+ position: absolute;
2352
+ bottom: 52px;
2353
+ left: calc(50% + 8px);
2354
+ background: #1c2030;
2355
+ border: 1px solid #2a2f42;
2356
+ border-radius: 8px;
2357
+ padding: 6px 10px;
2358
+ font-size: 11px;
2359
+ white-space: nowrap;
2360
+ color: var(--text-secondary);
2361
+ animation: bubble-float 3s ease-in-out infinite;
2362
+ }
2363
+
2364
+ .bc-speech::after {
2365
+ content: '';
2366
+ position: absolute;
2367
+ bottom: -6px;
2368
+ left: 20px;
2369
+ width: 0;
2370
+ height: 0;
2371
+ border-left: 6px solid transparent;
2372
+ border-right: 6px solid transparent;
2373
+ border-top: 6px solid #1c2030;
2374
+ }
2375
+
2376
+ @keyframes bubble-float {
2377
+ 0%, 100% { transform: translateY(0); }
2378
+ 50% { transform: translateY(-3px); }
2379
+ }
2380
+
2381
+ .bc-speech.fade-out {
2382
+ opacity: 0;
2383
+ transition: opacity 0.5s;
2384
+ }
2385
+
2386
+ .bc-speech.fade-in {
2387
+ opacity: 1;
2388
+ transition: opacity 0.5s;
2389
+ }
2390
+
2391
+ /* ============================================================
2392
+ Activity Trail
2393
+ ============================================================ */
2394
+
2395
+ #activity-trail {
2396
+ background: #0a0c11;
2397
+ border: 1px solid #1c2030;
2398
+ border-radius: 10px;
2399
+ overflow: hidden;
2400
+ }
2401
+
2402
+ .activity-info {
2403
+ padding: 12px 20px;
2404
+ display: flex;
2405
+ justify-content: space-between;
2406
+ align-items: center;
2407
+ }
2408
+
2409
+ .activity-streak {
2410
+ font-size: 13px;
2411
+ color: #8fbc8f;
2412
+ font-weight: 600;
2413
+ }
2414
+
2415
+ .activity-period {
2416
+ font-size: 11px;
2417
+ color: var(--text-muted);
2418
+ }
@@ -304,10 +304,13 @@ export async function createApp(projectRoot, options = {}) {
304
304
  if (!Number.isFinite(targetPort) || targetPort < 1000 || targetPort > 65535) {
305
305
  return res.status(400).send("Invalid port");
306
306
  }
307
- // Find camp name by port
307
+ // Only allow proxying to known camp ports (prevent SSRF to arbitrary local services)
308
308
  const camps = getAll();
309
309
  const camp = camps.find(c => c.fePort === targetPort);
310
- const campName = camp?.name ?? "unknown";
310
+ if (!camp) {
311
+ return res.status(403).send("이 포트는 활성 캠프가 아닙니다.");
312
+ }
313
+ const campName = camp.name;
311
314
  const targetPath = req.url || "/";
312
315
  const proxyReq = httpRequest({ hostname: "127.0.0.1", port: targetPort, path: targetPath, method: req.method, headers: { ...req.headers, host: `localhost:${targetPort}` } }, (proxyRes) => {
313
316
  const contentType = proxyRes.headers["content-type"] || "";
@@ -1357,6 +1360,70 @@ export async function createApp(projectRoot, options = {}) {
1357
1360
  }
1358
1361
  res.json({ fixed: false, description: fix?.description ?? "자동으로 고칠 수 있는 문제를 찾지 못했습니다." });
1359
1362
  });
1363
+ let activityCache = null;
1364
+ const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
1365
+ app.get("/api/activity", (_req, res) => {
1366
+ if (activityCache && Date.now() - activityCache.ts < ACTIVITY_CACHE_TTL) {
1367
+ return res.json(activityCache.data);
1368
+ }
1369
+ // --- daily commits (last 4 weeks) ---
1370
+ const gitLog = spawnSync("git", ["log", "--since=4 weeks ago", "--format=%ad", "--date=short"], {
1371
+ cwd: projectRoot,
1372
+ stdio: "pipe",
1373
+ encoding: "utf8",
1374
+ });
1375
+ const commitDates = (gitLog.status === 0 ? gitLog.stdout : "").trim().split("\n").filter(Boolean);
1376
+ const countByDate = new Map();
1377
+ for (const d of commitDates) {
1378
+ countByDate.set(d, (countByDate.get(d) ?? 0) + 1);
1379
+ }
1380
+ // Fill missing days in the 4-week window
1381
+ const daily = [];
1382
+ const now = new Date();
1383
+ for (let i = 27; i >= 0; i--) {
1384
+ const d = new Date(now);
1385
+ d.setDate(d.getDate() - i);
1386
+ const key = d.toISOString().slice(0, 10);
1387
+ daily.push({ date: key, commits: countByDate.get(key) ?? 0 });
1388
+ }
1389
+ // --- merged PRs ---
1390
+ let mergedPrs = [];
1391
+ const ghResult = spawnSync("gh", ["pr", "list", "--state", "merged", "--limit", "10", "--json", "number,title,mergedAt"], {
1392
+ cwd: projectRoot,
1393
+ stdio: "pipe",
1394
+ encoding: "utf8",
1395
+ });
1396
+ if (ghResult.status === 0 && ghResult.stdout) {
1397
+ try {
1398
+ const parsed = JSON.parse(ghResult.stdout);
1399
+ mergedPrs = parsed;
1400
+ }
1401
+ catch {
1402
+ // malformed JSON — keep empty
1403
+ }
1404
+ }
1405
+ // --- streak (consecutive days with commits, backward from today) ---
1406
+ let streak = 0;
1407
+ const todayStr = now.toISOString().slice(0, 10);
1408
+ for (let i = 0; i <= 27; i++) {
1409
+ const d = new Date(now);
1410
+ d.setDate(d.getDate() - i);
1411
+ const key = d.toISOString().slice(0, 10);
1412
+ if ((countByDate.get(key) ?? 0) > 0) {
1413
+ streak++;
1414
+ }
1415
+ else if (key === todayStr) {
1416
+ // Today having 0 commits is ok — streak can start from yesterday
1417
+ continue;
1418
+ }
1419
+ else {
1420
+ break;
1421
+ }
1422
+ }
1423
+ const data = { daily, mergedPrs, streak };
1424
+ activityCache = { data, ts: Date.now() };
1425
+ res.json(data);
1426
+ });
1360
1427
  // SPA fallback
1361
1428
  app.get("*", (_req, res) => {
1362
1429
  res.sendFile(join(dashboardDir, "index.html"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanjang",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Local dev environment manager for vibe coders",
5
5
  "type": "module",
6
6
  "bin": {