@oss-autopilot/core 0.42.0 → 0.42.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/cli.bundle.cjs +1026 -1018
  2. package/dist/cli.js +18 -30
  3. package/dist/commands/check-integration.js +5 -4
  4. package/dist/commands/comments.js +24 -24
  5. package/dist/commands/daily.d.ts +0 -1
  6. package/dist/commands/daily.js +18 -16
  7. package/dist/commands/dashboard-components.d.ts +33 -0
  8. package/dist/commands/dashboard-components.js +57 -0
  9. package/dist/commands/dashboard-data.js +7 -6
  10. package/dist/commands/dashboard-formatters.d.ts +20 -0
  11. package/dist/commands/dashboard-formatters.js +33 -0
  12. package/dist/commands/dashboard-scripts.d.ts +7 -0
  13. package/dist/commands/dashboard-scripts.js +281 -0
  14. package/dist/commands/dashboard-server.js +3 -2
  15. package/dist/commands/dashboard-styles.d.ts +5 -0
  16. package/dist/commands/dashboard-styles.js +765 -0
  17. package/dist/commands/dashboard-templates.d.ts +6 -18
  18. package/dist/commands/dashboard-templates.js +30 -1134
  19. package/dist/commands/dashboard.js +2 -1
  20. package/dist/commands/dismiss.d.ts +6 -6
  21. package/dist/commands/dismiss.js +13 -13
  22. package/dist/commands/local-repos.js +2 -1
  23. package/dist/commands/parse-list.js +2 -1
  24. package/dist/commands/startup.js +6 -16
  25. package/dist/commands/validation.d.ts +3 -1
  26. package/dist/commands/validation.js +12 -6
  27. package/dist/core/errors.d.ts +9 -0
  28. package/dist/core/errors.js +17 -0
  29. package/dist/core/github-stats.d.ts +14 -21
  30. package/dist/core/github-stats.js +84 -138
  31. package/dist/core/http-cache.d.ts +6 -0
  32. package/dist/core/http-cache.js +16 -4
  33. package/dist/core/index.d.ts +3 -2
  34. package/dist/core/index.js +3 -2
  35. package/dist/core/issue-conversation.js +4 -4
  36. package/dist/core/issue-discovery.d.ts +5 -0
  37. package/dist/core/issue-discovery.js +70 -93
  38. package/dist/core/issue-vetting.js +17 -17
  39. package/dist/core/logger.d.ts +5 -0
  40. package/dist/core/logger.js +8 -0
  41. package/dist/core/pr-monitor.d.ts +6 -20
  42. package/dist/core/pr-monitor.js +16 -52
  43. package/dist/core/review-analysis.js +8 -6
  44. package/dist/core/state.js +4 -5
  45. package/dist/core/test-utils.d.ts +14 -0
  46. package/dist/core/test-utils.js +125 -0
  47. package/dist/core/utils.d.ts +11 -0
  48. package/dist/core/utils.js +21 -0
  49. package/dist/formatters/json.d.ts +0 -1
  50. package/package.json +1 -1
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Client-side JavaScript for the dashboard: theme toggle, filtering, Chart.js charts.
3
+ */
4
+ /** Static client-side JS: theme toggle + filter/search logic. */
5
+ const THEME_AND_FILTER_SCRIPT = `
6
+ // === Theme Toggle ===
7
+ (function() {
8
+ var html = document.documentElement;
9
+ var toggle = document.getElementById('themeToggle');
10
+ var sunIcon = document.getElementById('themeIconSun');
11
+ var moonIcon = document.getElementById('themeIconMoon');
12
+ var label = document.getElementById('themeLabel');
13
+
14
+ function getEffectiveTheme() {
15
+ try {
16
+ var stored = localStorage.getItem('oss-dashboard-theme');
17
+ if (stored === 'light' || stored === 'dark') return stored;
18
+ } catch (e) { /* localStorage unavailable (private browsing) */ }
19
+ return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
20
+ }
21
+
22
+ function applyTheme(theme) {
23
+ html.setAttribute('data-theme', theme);
24
+ if (theme === 'light') {
25
+ sunIcon.style.display = 'none';
26
+ moonIcon.style.display = 'block';
27
+ label.textContent = 'Dark';
28
+ } else {
29
+ sunIcon.style.display = 'block';
30
+ moonIcon.style.display = 'none';
31
+ label.textContent = 'Light';
32
+ }
33
+ }
34
+
35
+ applyTheme(getEffectiveTheme());
36
+
37
+ toggle.addEventListener('click', function() {
38
+ var current = html.getAttribute('data-theme');
39
+ var next = current === 'dark' ? 'light' : 'dark';
40
+ try { localStorage.setItem('oss-dashboard-theme', next); } catch (e) { /* private browsing */ }
41
+ applyTheme(next);
42
+ });
43
+ })();
44
+
45
+ // === Filtering & Search ===
46
+ (function() {
47
+ var searchInput = document.getElementById('searchInput');
48
+ var statusFilter = document.getElementById('statusFilter');
49
+ var repoFilter = document.getElementById('repoFilter');
50
+ var filterCount = document.getElementById('filterCount');
51
+
52
+ function applyFilters() {
53
+ var query = searchInput.value.toLowerCase().trim();
54
+ var status = statusFilter.value;
55
+ var repo = repoFilter.value;
56
+ var allItems = document.querySelectorAll('.health-item[data-status], .pr-item[data-status]');
57
+ var visible = 0;
58
+ var total = allItems.length;
59
+
60
+ allItems.forEach(function(item) {
61
+ var itemStatus = item.getAttribute('data-status') || '';
62
+ var itemRepo = item.getAttribute('data-repo') || '';
63
+ var itemTitle = item.getAttribute('data-title') || '';
64
+
65
+ var matchesStatus = (status === 'all') || (itemStatus === status);
66
+ var matchesRepo = (repo === 'all') || (itemRepo === repo);
67
+ var matchesSearch = !query || itemTitle.indexOf(query) !== -1;
68
+
69
+ if (matchesStatus && matchesRepo && matchesSearch) {
70
+ item.setAttribute('data-hidden', 'false');
71
+ visible++;
72
+ } else {
73
+ item.setAttribute('data-hidden', 'true');
74
+ }
75
+ });
76
+
77
+ // Show/hide parent sections if all children are hidden
78
+ var sections = document.querySelectorAll('.health-section, .pr-list-section');
79
+ sections.forEach(function(section) {
80
+ var items = section.querySelectorAll('.health-item[data-status], .pr-item[data-status]');
81
+ if (items.length === 0) return; // sections without filterable items (e.g. empty state)
82
+ var anyVisible = false;
83
+ items.forEach(function(item) {
84
+ if (item.getAttribute('data-hidden') !== 'true') anyVisible = true;
85
+ });
86
+ section.style.display = anyVisible ? '' : 'none';
87
+ });
88
+
89
+ var isFiltering = (status !== 'all' || repo !== 'all' || query.length > 0);
90
+ filterCount.textContent = isFiltering ? (visible + ' of ' + total + ' items') : '';
91
+ }
92
+
93
+ searchInput.addEventListener('input', applyFilters);
94
+ statusFilter.addEventListener('change', applyFilters);
95
+ repoFilter.addEventListener('change', applyFilters);
96
+ })();
97
+ `;
98
+ /** Generate the Chart.js JavaScript for the dashboard. */
99
+ export function generateDashboardScripts(stats, monthlyMerged, monthlyClosed, monthlyOpened, digest, state) {
100
+ // === Status Doughnut ===
101
+ const statusChart = `
102
+ Chart.defaults.color = '#6e7681';
103
+ Chart.defaults.borderColor = 'rgba(48, 54, 61, 0.4)';
104
+ Chart.defaults.font.family = "'Geist', sans-serif";
105
+ Chart.defaults.font.size = 11;
106
+
107
+ // === Status Doughnut ===
108
+ new Chart(document.getElementById('statusChart'), {
109
+ type: 'doughnut',
110
+ data: {
111
+ labels: ['Active', 'Shelved', 'Merged', 'Closed'],
112
+ datasets: [{
113
+ data: [${stats.activePRs}, ${stats.shelvedPRs}, ${stats.mergedPRs}, ${stats.closedPRs}],
114
+ backgroundColor: ['#3fb950', '#6e7681', '#a855f7', '#484f58'],
115
+ borderColor: 'rgba(8, 11, 16, 0.8)',
116
+ borderWidth: 2,
117
+ hoverOffset: 8
118
+ }]
119
+ },
120
+ options: {
121
+ responsive: true,
122
+ maintainAspectRatio: false,
123
+ cutout: '65%',
124
+ plugins: {
125
+ legend: {
126
+ position: 'bottom',
127
+ labels: { padding: 16, usePointStyle: true, pointStyle: 'circle', font: { size: 11 } }
128
+ }
129
+ }
130
+ }
131
+ });`;
132
+ // === Repository Breakdown ===
133
+ const repoChart = (() => {
134
+ // Filter helper: exclude repos matching excludeRepos/excludeOrgs or below minStars (#216)
135
+ const { excludeRepos: exRepos = [], excludeOrgs: exOrgs, minStars } = state.config;
136
+ const starThreshold = minStars ?? 50;
137
+ const shouldExcludeRepo = (repo) => {
138
+ const repoLower = repo.toLowerCase();
139
+ if (exRepos.some((r) => r.toLowerCase() === repoLower))
140
+ return true;
141
+ if (exOrgs?.some((o) => o.toLowerCase() === repoLower.split('/')[0]))
142
+ return true;
143
+ const score = (state.repoScores || {})[repo];
144
+ // Fail-open: repos without cached star data are shown (not excluded).
145
+ // Unlike issue-discovery (fail-closed), the dashboard shows the user's own
146
+ // contribution history — hiding repos just because a star fetch failed would be confusing.
147
+ if (score?.stargazersCount !== undefined && score.stargazersCount < starThreshold)
148
+ return true;
149
+ return false;
150
+ };
151
+ // Sort repos by total PRs (merged + active + closed) and build "Other" bucket
152
+ const allRepoEntries = Object.entries(
153
+ // Rebuild from full prsByRepo to get all repos, not just top 10
154
+ (() => {
155
+ const all = {};
156
+ for (const pr of digest.openPRs || []) {
157
+ if (shouldExcludeRepo(pr.repo))
158
+ continue;
159
+ if (!all[pr.repo])
160
+ all[pr.repo] = { active: 0, merged: 0, closed: 0 };
161
+ all[pr.repo].active++;
162
+ }
163
+ for (const [repo, score] of Object.entries(state.repoScores || {})) {
164
+ if (shouldExcludeRepo(repo))
165
+ continue;
166
+ if (!all[repo])
167
+ all[repo] = { active: 0, merged: 0, closed: 0 };
168
+ all[repo].merged = score.mergedPRCount;
169
+ all[repo].closed = score.closedWithoutMergeCount;
170
+ }
171
+ return all;
172
+ })()).sort((a, b) => {
173
+ const totalA = a[1].merged + a[1].active + a[1].closed;
174
+ const totalB = b[1].merged + b[1].active + b[1].closed;
175
+ return totalB - totalA;
176
+ });
177
+ const displayRepos = allRepoEntries.slice(0, 10);
178
+ const otherRepos = allRepoEntries.slice(10);
179
+ const grandTotal = allRepoEntries.reduce((sum, [, d]) => sum + d.merged + d.active + d.closed, 0);
180
+ if (otherRepos.length > 0) {
181
+ const otherData = otherRepos.reduce((acc, [, d]) => ({
182
+ active: acc.active + d.active,
183
+ merged: acc.merged + d.merged,
184
+ closed: acc.closed + d.closed,
185
+ }), { active: 0, merged: 0, closed: 0 });
186
+ displayRepos.push(['Other', otherData]);
187
+ }
188
+ const repoLabels = displayRepos.map(([repo]) => (repo === 'Other' ? 'Other' : repo.split('/')[1] || repo));
189
+ const mergedData = displayRepos.map(([, d]) => d.merged);
190
+ const activeData = displayRepos.map(([, d]) => d.active);
191
+ const closedData = displayRepos.map(([, d]) => d.closed);
192
+ return `
193
+ new Chart(document.getElementById('reposChart'), {
194
+ type: 'bar',
195
+ data: {
196
+ labels: ${JSON.stringify(repoLabels)},
197
+ datasets: [
198
+ { label: 'Merged', data: ${JSON.stringify(mergedData)}, backgroundColor: '#a855f7', borderRadius: 3 },
199
+ { label: 'Active', data: ${JSON.stringify(activeData)}, backgroundColor: '#3fb950', borderRadius: 3 },
200
+ { label: 'Closed', data: ${JSON.stringify(closedData)}, backgroundColor: '#484f58', borderRadius: 3 }
201
+ ]
202
+ },
203
+ options: {
204
+ responsive: true,
205
+ maintainAspectRatio: false,
206
+ scales: {
207
+ x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 } } },
208
+ y: { stacked: true, grid: { color: 'rgba(48, 54, 61, 0.3)' }, ticks: { stepSize: 1 } }
209
+ },
210
+ plugins: {
211
+ legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true, pointStyle: 'circle', font: { size: 11 } } },
212
+ tooltip: {
213
+ callbacks: {
214
+ afterBody: function(context) {
215
+ const idx = context[0].dataIndex;
216
+ const total = ${JSON.stringify(mergedData)}[idx] + ${JSON.stringify(activeData)}[idx] + ${JSON.stringify(closedData)}[idx];
217
+ const pct = ${grandTotal} > 0 ? ((total / ${grandTotal}) * 100).toFixed(1) : '0.0';
218
+ return pct + '% of all PRs';
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
224
+ });`;
225
+ })();
226
+ // === Contribution Timeline ===
227
+ const timelineChart = (() => {
228
+ // Generate a contiguous range of the last 6 months from today
229
+ // This avoids gaps when historical data spans years (e.g. 2019 and 2026)
230
+ const now = new Date();
231
+ const allMonths = [];
232
+ for (let offset = 5; offset >= 0; offset--) {
233
+ const d = new Date(now.getFullYear(), now.getMonth() - offset, 1);
234
+ allMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
235
+ }
236
+ return `
237
+ const timelineMonths = ${JSON.stringify(allMonths)};
238
+ const openedData = ${JSON.stringify(monthlyOpened)};
239
+ const mergedData = ${JSON.stringify(monthlyMerged)};
240
+ const closedData = ${JSON.stringify(monthlyClosed)};
241
+ new Chart(document.getElementById('monthlyChart'), {
242
+ type: 'bar',
243
+ data: {
244
+ labels: timelineMonths,
245
+ datasets: [
246
+ {
247
+ label: 'Opened',
248
+ data: timelineMonths.map(m => openedData[m] || 0),
249
+ backgroundColor: '#58a6ff',
250
+ borderRadius: 3
251
+ },
252
+ {
253
+ label: 'Merged',
254
+ data: timelineMonths.map(m => mergedData[m] || 0),
255
+ backgroundColor: '#a855f7',
256
+ borderRadius: 3
257
+ },
258
+ {
259
+ label: 'Closed',
260
+ data: timelineMonths.map(m => closedData[m] || 0),
261
+ backgroundColor: '#484f58',
262
+ borderRadius: 3
263
+ }
264
+ ]
265
+ },
266
+ options: {
267
+ responsive: true,
268
+ maintainAspectRatio: false,
269
+ scales: {
270
+ x: { grid: { display: false } },
271
+ y: { grid: { color: 'rgba(48, 54, 61, 0.3)' }, beginAtZero: true, ticks: { stepSize: 1 } }
272
+ },
273
+ plugins: {
274
+ legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true, pointStyle: 'circle', font: { size: 11 } } }
275
+ },
276
+ interaction: { intersect: false, mode: 'index' }
277
+ }
278
+ });`;
279
+ })();
280
+ return THEME_AND_FILTER_SCRIPT + statusChart + '\n' + repoChart + '\n' + timelineChart;
281
+ }
@@ -9,6 +9,7 @@ import * as http from 'http';
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { getStateManager, getGitHubToken } from '../core/index.js';
12
+ import { errorMessage } from '../core/errors.js';
12
13
  import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData } from './dashboard-data.js';
13
14
  import { buildDashboardStats } from './dashboard-templates.js';
14
15
  // ── Constants ────────────────────────────────────────────────────────────────
@@ -203,7 +204,7 @@ export async function startDashboardServer(options) {
203
204
  }
204
205
  catch (error) {
205
206
  console.error('Action failed:', body.action, body.url, error);
206
- sendError(res, 500, `Action failed: ${error instanceof Error ? error.message : String(error)}`);
207
+ sendError(res, 500, `Action failed: ${errorMessage(error)}`);
207
208
  return;
208
209
  }
209
210
  // Rebuild dashboard data from cached digest + updated state
@@ -229,7 +230,7 @@ export async function startDashboardServer(options) {
229
230
  }
230
231
  catch (error) {
231
232
  console.error('Dashboard refresh failed:', error);
232
- sendError(res, 500, `Refresh failed: ${error instanceof Error ? error.message : String(error)}`);
233
+ sendError(res, 500, `Refresh failed: ${errorMessage(error)}`);
233
234
  }
234
235
  }
235
236
  // ── Static file serving ──────────────────────────────────────────────────
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Dashboard CSS styles: theme variables, layout, component styles.
3
+ * Extracted from dashboard-templates.ts for maintainability.
4
+ */
5
+ export declare const DASHBOARD_CSS = "\n :root, [data-theme=\"dark\"] {\n --bg-base: #080b10;\n --bg-surface: rgba(22, 27, 34, 0.65);\n --bg-elevated: rgba(28, 33, 40, 0.8);\n --border: rgba(48, 54, 61, 0.6);\n --border-muted: rgba(33, 38, 45, 0.5);\n --text-primary: #e6edf3;\n --text-secondary: #8b949e;\n --text-muted: #6e7681;\n --accent-merged: #a855f7;\n --accent-merged-dim: rgba(168, 85, 247, 0.12);\n --accent-open: #3fb950;\n --accent-open-dim: rgba(63, 185, 80, 0.12);\n --accent-warning: #d29922;\n --accent-warning-dim: rgba(210, 153, 34, 0.12);\n --accent-error: #f85149;\n --accent-error-dim: rgba(248, 81, 73, 0.10);\n --accent-conflict: #da3633;\n --accent-info: #58a6ff;\n --accent-info-dim: rgba(88, 166, 255, 0.08);\n --chart-border: rgba(8, 11, 16, 0.8);\n --chart-grid: rgba(48, 54, 61, 0.3);\n --scrollbar-track: rgba(28, 33, 40, 0.8);\n --scrollbar-thumb: rgba(48, 54, 61, 0.6);\n }\n\n [data-theme=\"light\"] {\n --bg-base: #f6f8fa;\n --bg-surface: rgba(255, 255, 255, 0.85);\n --bg-elevated: rgba(246, 248, 250, 0.95);\n --border: rgba(208, 215, 222, 0.6);\n --border-muted: rgba(216, 222, 228, 0.5);\n --text-primary: #1f2328;\n --text-secondary: #656d76;\n --text-muted: #8b949e;\n --accent-merged: #8250df;\n --accent-merged-dim: rgba(130, 80, 223, 0.1);\n --accent-open: #1a7f37;\n --accent-open-dim: rgba(26, 127, 55, 0.1);\n --accent-warning: #9a6700;\n --accent-warning-dim: rgba(154, 103, 0, 0.1);\n --accent-error: #cf222e;\n --accent-error-dim: rgba(207, 34, 46, 0.08);\n --accent-conflict: #cf222e;\n --accent-info: #0969da;\n --accent-info-dim: rgba(9, 105, 218, 0.08);\n --chart-border: rgba(255, 255, 255, 0.8);\n --chart-grid: rgba(208, 215, 222, 0.4);\n --scrollbar-track: rgba(246, 248, 250, 0.95);\n --scrollbar-thumb: rgba(208, 215, 222, 0.6);\n }\n\n @media (prefers-color-scheme: light) {\n :root:not([data-theme=\"dark\"]) {\n --bg-base: #f6f8fa;\n --bg-surface: rgba(255, 255, 255, 0.85);\n --bg-elevated: rgba(246, 248, 250, 0.95);\n --border: rgba(208, 215, 222, 0.6);\n --border-muted: rgba(216, 222, 228, 0.5);\n --text-primary: #1f2328;\n --text-secondary: #656d76;\n --text-muted: #8b949e;\n --accent-merged: #8250df;\n --accent-merged-dim: rgba(130, 80, 223, 0.1);\n --accent-open: #1a7f37;\n --accent-open-dim: rgba(26, 127, 55, 0.1);\n --accent-warning: #9a6700;\n --accent-warning-dim: rgba(154, 103, 0, 0.1);\n --accent-error: #cf222e;\n --accent-error-dim: rgba(207, 34, 46, 0.08);\n --accent-conflict: #cf222e;\n --accent-info: #0969da;\n --accent-info-dim: rgba(9, 105, 218, 0.08);\n --chart-border: rgba(255, 255, 255, 0.8);\n --chart-grid: rgba(208, 215, 222, 0.4);\n --scrollbar-track: rgba(246, 248, 250, 0.95);\n --scrollbar-thumb: rgba(208, 215, 222, 0.6);\n }\n }\n\n * { margin: 0; padding: 0; box-sizing: border-box; }\n\n body {\n font-family: 'Geist', -apple-system, BlinkMacSystemFont, sans-serif;\n background: var(--bg-base);\n color: var(--text-primary);\n min-height: 100vh;\n line-height: 1.5;\n overflow-x: hidden;\n }\n\n body::before {\n content: '';\n position: fixed;\n top: -20%; left: -10%;\n width: 60%; height: 60%;\n background: radial-gradient(ellipse, rgba(88, 166, 255, 0.06) 0%, transparent 70%);\n pointer-events: none;\n z-index: 0;\n }\n\n body::after {\n content: '';\n position: fixed;\n bottom: -20%; right: -10%;\n width: 50%; height: 50%;\n background: radial-gradient(ellipse, rgba(168, 85, 247, 0.05) 0%, transparent 70%);\n pointer-events: none;\n z-index: 0;\n }\n\n [data-theme=\"light\"] body::before,\n [data-theme=\"light\"] body::after {\n display: none;\n }\n\n .container {\n max-width: 1400px;\n margin: 0 auto;\n padding: 2rem;\n position: relative;\n z-index: 1;\n }\n\n .header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 1.5rem;\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--border-muted);\n }\n\n .header-left {\n display: flex;\n align-items: center;\n gap: 1rem;\n }\n\n .logo {\n width: 44px;\n height: 44px;\n background: linear-gradient(135deg, var(--accent-info) 0%, var(--accent-merged) 50%, #f778ba 100%);\n border-radius: 12px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 1.5rem;\n box-shadow: 0 0 24px rgba(168, 85, 247, 0.3), 0 0 48px rgba(88, 166, 255, 0.15);\n }\n\n .header h1 {\n font-size: 1.75rem;\n font-weight: 600;\n letter-spacing: -0.02em;\n background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);\n -webkit-background-clip: text;\n -webkit-text-fill-color: transparent;\n background-clip: text;\n }\n\n .header-subtitle {\n font-family: 'Geist Mono', monospace;\n font-size: 0.75rem;\n color: var(--text-muted);\n text-transform: uppercase;\n letter-spacing: 0.1em;\n }\n\n .timestamp {\n font-family: 'Geist Mono', monospace;\n font-size: 0.8rem;\n color: var(--text-muted);\n display: flex;\n align-items: center;\n gap: 0.5rem;\n }\n\n .timestamp::before {\n content: '';\n width: 8px;\n height: 8px;\n background: var(--accent-open);\n border-radius: 50%;\n animation: pulse 2s ease-in-out infinite;\n }\n\n @keyframes pulse {\n 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(35, 134, 54, 0.4); }\n 50% { opacity: 0.8; box-shadow: 0 0 0 8px rgba(35, 134, 54, 0); }\n }\n\n .stats-grid {\n display: flex;\n background: var(--bg-surface);\n border: 1px solid var(--border-muted);\n border-radius: 12px;\n margin-bottom: 1.5rem;\n overflow: hidden;\n }\n\n @media (max-width: 768px) {\n .stats-grid { flex-wrap: wrap; }\n .stat-card { flex: 1 1 33%; }\n }\n\n .stat-card {\n flex: 1;\n padding: 1rem 1.25rem;\n position: relative;\n transition: background 0.2s ease;\n }\n\n .stat-card + .stat-card {\n border-left: 1px solid var(--border-muted);\n }\n\n .stat-card:hover {\n background: rgba(255, 255, 255, 0.02);\n }\n\n .stat-card::after {\n content: '';\n position: absolute;\n bottom: 0; left: 0.75rem; right: 0.75rem;\n height: 2px;\n background: var(--accent-color, var(--border));\n border-radius: 2px;\n opacity: 0.7;\n }\n\n .stat-card.active { --accent-color: var(--accent-open); }\n .stat-card.merged { --accent-color: var(--accent-merged); }\n .stat-card.closed { --accent-color: var(--text-muted); }\n .stat-card.rate { --accent-color: var(--accent-info); }\n\n .stat-value {\n font-family: 'Geist Mono', monospace;\n font-size: 1.75rem;\n font-weight: 600;\n line-height: 1;\n margin-bottom: 0.25rem;\n }\n\n .stat-card.active .stat-value { color: var(--accent-open); }\n .stat-card.merged .stat-value { color: var(--accent-merged); }\n .stat-card.closed .stat-value { color: var(--text-muted); }\n .stat-card.rate .stat-value { color: var(--accent-info); }\n\n .stat-label {\n font-size: 0.7rem;\n color: var(--text-secondary);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n .health-section {\n background: var(--bg-surface);\n border: 1px solid var(--border-muted);\n border-radius: 10px;\n padding: 1.25rem;\n margin-bottom: 1.25rem;\n }\n\n .health-header {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n margin-bottom: 1rem;\n }\n\n .health-header h2 {\n font-size: 1rem;\n font-weight: 600;\n color: var(--text-primary);\n }\n\n .health-badge {\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n padding: 0.25rem 0.5rem;\n border-radius: 4px;\n background: var(--accent-error-dim);\n color: var(--accent-error);\n }\n\n .health-items {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));\n gap: 0.75rem;\n }\n\n .health-item {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n padding: 0.75rem 1rem;\n background: var(--bg-elevated);\n border-radius: 8px;\n border-left: 3px solid;\n transition: transform 0.15s ease;\n }\n\n .health-item:hover { transform: translateX(4px); }\n\n .health-item.ci-failing {\n border-left-color: var(--accent-error);\n background: var(--accent-error-dim);\n }\n\n .health-item.conflict {\n border-left-color: var(--accent-conflict);\n background: rgba(218, 54, 51, 0.1);\n }\n\n .health-item.incomplete-checklist {\n border-left-color: var(--accent-info);\n }\n .health-item.needs-response,\n .health-item.needs-changes {\n border-left-color: var(--accent-warning);\n background: var(--accent-warning-dim);\n }\n\n .health-item.changes-addressed,\n .health-item.waiting-maintainer {\n border-left-color: var(--accent-info);\n background: var(--accent-info-dim);\n }\n\n .health-item.ci-not-running {\n border-left-color: var(--text-muted);\n background: rgba(110, 118, 129, 0.1);\n }\n\n .health-item.missing-files {\n border-left-color: var(--accent-warning);\n background: var(--accent-warning-dim);\n }\n\n .health-item.ci-blocked {\n border-left-color: var(--text-muted);\n background: rgba(110, 118, 129, 0.1);\n }\n\n .health-item.needs-rebase {\n border-left-color: var(--accent-warning);\n background: var(--accent-warning-dim);\n }\n\n .health-item.shelved {\n border-left-color: var(--text-muted);\n background: rgba(110, 118, 129, 0.06);\n opacity: 0.6;\n }\n\n .health-item.shelved .health-icon { background: rgba(110, 118, 129, 0.12); color: var(--text-muted); }\n\n .health-item.auto-unshelved {\n border-left-color: var(--accent-info);\n background: var(--accent-info-dim);\n }\n\n .health-item.auto-unshelved .health-icon { background: var(--accent-info-dim); color: var(--accent-info); }\n\n .stat-card.shelved { --accent-color: var(--text-muted); }\n .stat-card.shelved .stat-value { color: var(--text-muted); }\n\n .waiting-section {\n border-color: rgba(88, 166, 255, 0.2);\n }\n\n .health-icon {\n width: 32px;\n height: 32px;\n border-radius: 8px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 1rem;\n flex-shrink: 0;\n }\n\n .health-item.ci-failing .health-icon { background: var(--accent-error-dim); color: var(--accent-error); }\n .health-item.conflict .health-icon { background: rgba(218, 54, 51, 0.15); color: var(--accent-conflict); }\n .health-item.incomplete-checklist .health-icon { background: var(--accent-info-dim); color: var(--accent-info); }\n .health-item.needs-response .health-icon,\n .health-item.needs-changes .health-icon { background: var(--accent-warning-dim); color: var(--accent-warning); }\n .health-item.changes-addressed .health-icon,\n .health-item.waiting-maintainer .health-icon { background: var(--accent-info-dim); color: var(--accent-info); }\n .health-item.ci-not-running .health-icon { background: rgba(110, 118, 129, 0.15); color: var(--text-muted); }\n .health-item.missing-files .health-icon { background: var(--accent-warning-dim); color: var(--accent-warning); }\n .health-item.ci-blocked .health-icon { background: rgba(110, 118, 129, 0.15); color: var(--text-muted); }\n .health-item.needs-rebase .health-icon { background: var(--accent-warning-dim); color: var(--accent-warning); }\n\n .health-content { flex: 1; min-width: 0; }\n\n .health-title {\n font-size: 0.85rem;\n font-weight: 500;\n color: var(--text-primary);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .health-title a { color: inherit; text-decoration: none; }\n .health-title a:hover { color: var(--accent-info); }\n\n .health-meta {\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n color: var(--text-muted);\n }\n\n .health-empty {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 2rem;\n color: var(--text-muted);\n font-size: 0.9rem;\n }\n\n .health-empty::before {\n content: '\\2713';\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 24px;\n background: var(--accent-open-dim);\n color: var(--accent-open);\n border-radius: 50%;\n margin-right: 0.75rem;\n font-weight: bold;\n }\n\n .main-grid {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 1.25rem;\n margin-bottom: 1.25rem;\n }\n\n @media (max-width: 1024px) { .main-grid { grid-template-columns: 1fr; } }\n\n .card {\n background: var(--bg-surface);\n border: 1px solid var(--border-muted);\n border-radius: 10px;\n overflow: hidden;\n }\n\n .card-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0.75rem 1.125rem;\n border-bottom: 1px solid var(--border-muted);\n }\n\n .card-title {\n font-size: 0.75rem;\n font-weight: 600;\n color: var(--text-secondary);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n\n .card-body { padding: 1rem 1.125rem; }\n\n .chart-container {\n position: relative;\n height: 260px;\n }\n\n .pr-list-section {\n background: var(--bg-surface);\n border: 1px solid var(--border-muted);\n border-radius: 10px;\n overflow: hidden;\n }\n\n .pr-list-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0.75rem 1.125rem;\n border-bottom: 1px solid var(--border-muted);\n }\n\n .pr-list-title {\n font-size: 0.75rem;\n font-weight: 600;\n color: var(--text-secondary);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n\n .pr-count {\n font-family: 'Geist Mono', monospace;\n font-size: 0.75rem;\n padding: 0.25rem 0.5rem;\n background: var(--accent-open-dim);\n color: var(--accent-open);\n border-radius: 4px;\n }\n\n .pr-list {\n max-height: 600px;\n overflow-y: auto;\n }\n\n .pr-list::-webkit-scrollbar { width: 6px; }\n .pr-list::-webkit-scrollbar-track { background: var(--scrollbar-track, var(--bg-elevated)); }\n .pr-list::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb, var(--border)); border-radius: 3px; }\n\n .pr-item {\n display: flex;\n align-items: flex-start;\n gap: 1rem;\n padding: 1rem 1.25rem;\n border-bottom: 1px solid var(--border-muted);\n transition: background 0.15s ease;\n }\n\n .pr-item:last-child { border-bottom: none; }\n .pr-item:hover { background: var(--bg-elevated); }\n\n .pr-status-indicator {\n width: 40px;\n height: 40px;\n border-radius: 10px;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n font-size: 1.1rem;\n background: var(--accent-open-dim);\n color: var(--accent-open);\n }\n\n .pr-item.has-issues .pr-status-indicator {\n background: var(--accent-error-dim);\n color: var(--accent-error);\n animation: attention-pulse 2s ease-in-out infinite;\n }\n\n .pr-item.stale .pr-status-indicator {\n background: var(--accent-warning-dim);\n color: var(--accent-warning);\n }\n\n @keyframes attention-pulse {\n 0%, 100% { box-shadow: 0 0 0 0 rgba(248, 81, 73, 0.4); }\n 50% { box-shadow: 0 0 0 6px rgba(248, 81, 73, 0); }\n }\n\n .pr-content { flex: 1; min-width: 0; }\n\n .pr-title-row {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n margin-bottom: 0.25rem;\n }\n\n .pr-title {\n font-size: 0.9rem;\n font-weight: 500;\n color: var(--text-primary);\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .pr-title:hover { color: var(--accent-info); }\n\n .pr-repo {\n font-family: 'Geist Mono', monospace;\n font-size: 0.75rem;\n color: var(--text-muted);\n flex-shrink: 0;\n }\n\n .pr-badges {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n }\n\n .badge {\n font-family: 'Geist Mono', monospace;\n font-size: 0.65rem;\n font-weight: 500;\n padding: 0.2rem 0.5rem;\n border-radius: 4px;\n text-transform: uppercase;\n letter-spacing: 0.03em;\n }\n\n .badge-ci-failing { background: var(--accent-error-dim); color: var(--accent-error); }\n .badge-conflict { background: rgba(218, 54, 51, 0.15); color: var(--accent-conflict); }\n .badge-needs-response { background: var(--accent-warning-dim); color: var(--accent-warning); }\n .badge-stale { background: var(--accent-warning-dim); color: var(--accent-warning); }\n .badge-passing { background: var(--accent-open-dim); color: var(--accent-open); }\n .badge-pending { background: var(--accent-info-dim); color: var(--accent-info); }\n .badge-days { background: var(--bg-elevated); color: var(--text-muted); }\n .badge-changes-requested { background: var(--accent-warning-dim); color: var(--accent-warning); }\n .badge-changes-addressed { background: var(--accent-info-dim); color: var(--accent-info); }\n\n .pr-activity {\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n color: var(--text-muted);\n margin-left: auto;\n text-align: right;\n flex-shrink: 0;\n }\n\n .empty-state {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 3rem;\n color: var(--text-muted);\n }\n\n .empty-state-icon {\n font-size: 2.5rem;\n margin-bottom: 1rem;\n opacity: 0.5;\n }\n\n .footer {\n text-align: center;\n padding-top: 1.5rem;\n border-top: 1px solid var(--border-muted);\n margin-top: 1.5rem;\n }\n\n .footer p {\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n color: var(--text-muted);\n }\n\n @keyframes fadeInUp {\n from { opacity: 0; transform: translateY(12px); }\n to { opacity: 1; transform: translateY(0); }\n }\n\n .stats-grid, .health-section, .pr-list-section {\n animation: fadeInUp 0.35s ease;\n }\n\n .theme-toggle {\n background: var(--bg-elevated);\n border: 1px solid var(--border-muted);\n border-radius: 8px;\n padding: 0.4rem 0.6rem;\n cursor: pointer;\n color: var(--text-secondary);\n display: flex;\n align-items: center;\n gap: 0.4rem;\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n transition: background 0.2s ease, color 0.2s ease;\n }\n\n .theme-toggle:hover {\n background: var(--bg-surface);\n color: var(--text-primary);\n }\n\n .theme-toggle svg { flex-shrink: 0; }\n\n .header-controls {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n }\n\n .filter-toolbar {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n padding: 0.75rem 1rem;\n background: var(--bg-surface);\n border: 1px solid var(--border-muted);\n border-radius: 10px;\n margin-bottom: 1.25rem;\n flex-wrap: wrap;\n }\n\n .filter-toolbar label {\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n color: var(--text-muted);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n flex-shrink: 0;\n }\n\n .filter-search {\n flex: 1;\n min-width: 180px;\n padding: 0.4rem 0.75rem;\n background: var(--bg-elevated);\n border: 1px solid var(--border-muted);\n border-radius: 6px;\n color: var(--text-primary);\n font-family: 'Geist', sans-serif;\n font-size: 0.8rem;\n outline: none;\n transition: border-color 0.2s ease;\n }\n\n .filter-search:focus {\n border-color: var(--accent-info);\n }\n\n .filter-search::placeholder {\n color: var(--text-muted);\n }\n\n .filter-select {\n padding: 0.4rem 0.75rem;\n background: var(--bg-elevated);\n border: 1px solid var(--border-muted);\n border-radius: 6px;\n color: var(--text-primary);\n font-family: 'Geist', sans-serif;\n font-size: 0.8rem;\n outline: none;\n cursor: pointer;\n transition: border-color 0.2s ease;\n }\n\n .filter-select:focus {\n border-color: var(--accent-info);\n }\n\n .filter-count {\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n color: var(--text-muted);\n margin-left: auto;\n flex-shrink: 0;\n }\n\n .pr-item[data-hidden=\"true\"],\n .health-item[data-hidden=\"true\"] {\n display: none;\n }\n";