@virtengine/openfleet 0.25.0

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 (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,331 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * VirtEngine Control Center – Telegram SDK Wrapper
3
+ * Enhanced Telegram Mini App SDK integration
4
+ * ────────────────────────────────────────────────────────────── */
5
+
6
+ import { signal } from "@preact/signals";
7
+
8
+ /* ─── Core Accessor ─── */
9
+
10
+ /** Get the Telegram WebApp instance, or null outside Telegram */
11
+ export function getTg() {
12
+ return globalThis.Telegram?.WebApp || null;
13
+ }
14
+
15
+ /** Whether the app is running inside a Telegram WebView */
16
+ export const isTelegramContext = !!getTg();
17
+
18
+ /** Reactive color scheme signal ('light' | 'dark') */
19
+ export const colorScheme = signal(getTg()?.colorScheme || "dark");
20
+
21
+ /* ─── Haptic Feedback ─── */
22
+
23
+ /**
24
+ * Trigger haptic feedback.
25
+ * @param {'light'|'medium'|'heavy'|'rigid'|'soft'} type
26
+ */
27
+ export function haptic(type = "light") {
28
+ try {
29
+ getTg()?.HapticFeedback?.impactOccurred(type);
30
+ } catch {
31
+ /* noop outside Telegram */
32
+ }
33
+ }
34
+
35
+ /* ─── Initialization ─── */
36
+
37
+ /**
38
+ * Full Telegram WebApp initialization – call once at app mount.
39
+ * Expands the viewport, enables fullscreen, disables vertical swipes,
40
+ * sets header/background/bottom-bar colors, etc.
41
+ */
42
+ export function initTelegramApp() {
43
+ const tg = getTg();
44
+ if (!tg) return;
45
+
46
+ tg.ready();
47
+ tg.expand();
48
+
49
+ // Bot API 8.0+ fullscreen — only on mobile (desktop Telegram doesn't need it)
50
+ const platform = (tg.platform || "").toLowerCase();
51
+ const isMobile = platform === "ios" || platform === "android" || platform === "android_x";
52
+ if (isMobile) {
53
+ try {
54
+ tg.requestFullscreen?.();
55
+ } catch {
56
+ /* not supported */
57
+ }
58
+ }
59
+
60
+ // Bot API 7.7+ disable vertical swipes for custom scroll
61
+ try {
62
+ tg.disableVerticalSwipes?.();
63
+ } catch {
64
+ /* not supported */
65
+ }
66
+
67
+ // Closing confirmation
68
+ try {
69
+ tg.enableClosingConfirmation?.();
70
+ } catch {
71
+ /* not supported */
72
+ }
73
+
74
+ // Apply colours
75
+ try {
76
+ tg.setHeaderColor?.("secondary_bg_color");
77
+ tg.setBackgroundColor?.("bg_color");
78
+ tg.setBottomBarColor?.("secondary_bg_color");
79
+ } catch {
80
+ /* not supported */
81
+ }
82
+
83
+ // Apply theme params to CSS custom properties
84
+ applyTgTheme();
85
+ }
86
+
87
+ /** Map Telegram themeParams to CSS custom properties on :root */
88
+ function applyTgTheme() {
89
+ const tg = getTg();
90
+ if (!tg?.themeParams) return;
91
+
92
+ const tp = tg.themeParams;
93
+ const root = document.documentElement;
94
+ root.setAttribute("data-tg-theme", "true");
95
+
96
+ if (tp.bg_color) root.style.setProperty("--bg-primary", tp.bg_color);
97
+ if (tp.secondary_bg_color) {
98
+ root.style.setProperty("--bg-secondary", tp.secondary_bg_color);
99
+ root.style.setProperty("--bg-card", tp.secondary_bg_color);
100
+ }
101
+ if (tp.text_color) root.style.setProperty("--text-primary", tp.text_color);
102
+ if (tp.hint_color) {
103
+ root.style.setProperty("--text-secondary", tp.hint_color);
104
+ root.style.setProperty("--text-hint", tp.hint_color);
105
+ }
106
+ if (tp.link_color) root.style.setProperty("--accent", tp.link_color);
107
+ if (tp.button_color) root.style.setProperty("--accent", tp.button_color);
108
+ if (tp.button_text_color)
109
+ root.style.setProperty("--accent-text", tp.button_text_color);
110
+ }
111
+
112
+ /* ─── Event Listeners ─── */
113
+
114
+ /**
115
+ * Subscribe to theme changes. Returns an unsubscribe function.
116
+ * @param {() => void} callback
117
+ * @returns {() => void}
118
+ */
119
+ export function onThemeChange(callback) {
120
+ const tg = getTg();
121
+ if (!tg) return () => {};
122
+ const handler = () => {
123
+ applyTgTheme();
124
+ callback();
125
+ };
126
+ tg.onEvent("themeChanged", handler);
127
+ return () => tg.offEvent("themeChanged", handler);
128
+ }
129
+
130
+ /**
131
+ * Subscribe to viewport changes. Returns an unsubscribe function.
132
+ * @param {(event: {isStateStable: boolean}) => void} callback
133
+ * @returns {() => void}
134
+ */
135
+ export function onViewportChange(callback) {
136
+ const tg = getTg();
137
+ if (!tg) return () => {};
138
+ tg.onEvent("viewportChanged", callback);
139
+ return () => tg.offEvent("viewportChanged", callback);
140
+ }
141
+
142
+ /* ─── MainButton Helpers ─── */
143
+
144
+ /**
145
+ * Show the Telegram MainButton with given text and handler.
146
+ * @param {string} text
147
+ * @param {() => void} onClick
148
+ * @param {{color?: string, textColor?: string, progress?: boolean}} options
149
+ */
150
+ export function showMainButton(text, onClick, options = {}) {
151
+ const tg = getTg();
152
+ if (!tg?.MainButton) return;
153
+ tg.MainButton.setText(text);
154
+ if (options.color) tg.MainButton.color = options.color;
155
+ if (options.textColor) tg.MainButton.textColor = options.textColor;
156
+ tg.MainButton.onClick(onClick);
157
+ tg.MainButton.show();
158
+ if (options.progress) tg.MainButton.showProgress();
159
+ }
160
+
161
+ /** Hide the Telegram MainButton and clear its handler. */
162
+ export function hideMainButton() {
163
+ const tg = getTg();
164
+ if (!tg?.MainButton) return;
165
+ tg.MainButton.hide();
166
+ tg.MainButton.hideProgress();
167
+ try {
168
+ tg.MainButton.offClick(tg.MainButton._callback);
169
+ } catch {
170
+ /* noop */
171
+ }
172
+ }
173
+
174
+ /* ─── BackButton Helpers ─── */
175
+
176
+ /**
177
+ * Show the Telegram BackButton with the given handler.
178
+ * @param {() => void} onClick
179
+ */
180
+ export function showBackButton(onClick) {
181
+ const tg = getTg();
182
+ if (!tg?.BackButton) return;
183
+ tg.BackButton.onClick(onClick);
184
+ tg.BackButton.show();
185
+ }
186
+
187
+ /** Hide the Telegram BackButton and clear its handler. */
188
+ export function hideBackButton() {
189
+ const tg = getTg();
190
+ if (!tg?.BackButton) return;
191
+ tg.BackButton.hide();
192
+ try {
193
+ tg.BackButton.offClick(tg.BackButton._callback);
194
+ } catch {
195
+ /* noop */
196
+ }
197
+ }
198
+
199
+ /* ─── SettingsButton ─── */
200
+
201
+ /**
202
+ * Show the Telegram SettingsButton (header gear icon).
203
+ * @param {() => void} onClick
204
+ */
205
+ export function showSettingsButton(onClick) {
206
+ const tg = getTg();
207
+ if (!tg?.SettingsButton) return;
208
+ tg.SettingsButton.onClick(onClick);
209
+ tg.SettingsButton.show();
210
+ }
211
+
212
+ /* ─── Cloud Storage ─── */
213
+
214
+ /**
215
+ * Read a value from Telegram Cloud Storage.
216
+ * @param {string} key
217
+ * @returns {Promise<string|null>}
218
+ */
219
+ export async function cloudStorageGet(key) {
220
+ const tg = getTg();
221
+ if (!tg?.CloudStorage) return null;
222
+ return new Promise((resolve) => {
223
+ tg.CloudStorage.getItem(key, (err, val) => {
224
+ if (err) {
225
+ resolve(null);
226
+ return;
227
+ }
228
+ resolve(val ?? null);
229
+ });
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Write a value to Telegram Cloud Storage.
235
+ * @param {string} key
236
+ * @param {string} value
237
+ * @returns {Promise<boolean>}
238
+ */
239
+ export async function cloudStorageSet(key, value) {
240
+ const tg = getTg();
241
+ if (!tg?.CloudStorage) return false;
242
+ return new Promise((resolve) => {
243
+ tg.CloudStorage.setItem(key, value, (err) => {
244
+ resolve(!err);
245
+ });
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Remove a key from Telegram Cloud Storage.
251
+ * @param {string} key
252
+ * @returns {Promise<boolean>}
253
+ */
254
+ export async function cloudStorageRemove(key) {
255
+ const tg = getTg();
256
+ if (!tg?.CloudStorage) return false;
257
+ return new Promise((resolve) => {
258
+ tg.CloudStorage.removeItem(key, (err) => {
259
+ resolve(!err);
260
+ });
261
+ });
262
+ }
263
+
264
+ /* ─── Auth / User ─── */
265
+
266
+ /** Get the raw initData string for server-side validation. */
267
+ export function getInitData() {
268
+ return getTg()?.initData || "";
269
+ }
270
+
271
+ /** Get the current Telegram user object, or null. */
272
+ export function getTelegramUser() {
273
+ return getTg()?.initDataUnsafe?.user || null;
274
+ }
275
+
276
+ /* ─── Native Dialogs ─── */
277
+
278
+ /**
279
+ * Show a native Telegram confirm dialog (falls back to window.confirm).
280
+ * @param {string} message
281
+ * @returns {Promise<boolean>}
282
+ */
283
+ export function showConfirm(message) {
284
+ return new Promise((resolve) => {
285
+ const tg = getTg();
286
+ if (!tg?.showConfirm) {
287
+ resolve(window.confirm(message));
288
+ return;
289
+ }
290
+ tg.showConfirm(message, resolve);
291
+ });
292
+ }
293
+
294
+ /**
295
+ * Show a native Telegram alert dialog (falls back to window.alert).
296
+ * @param {string} message
297
+ * @returns {Promise<void>}
298
+ */
299
+ export function showAlert(message) {
300
+ return new Promise((resolve) => {
301
+ const tg = getTg();
302
+ if (!tg?.showAlert) {
303
+ window.alert(message);
304
+ resolve();
305
+ return;
306
+ }
307
+ tg.showAlert(message, resolve);
308
+ });
309
+ }
310
+
311
+ /* ─── External Links ─── */
312
+
313
+ /**
314
+ * Open a URL in the external browser via Telegram, or fallback.
315
+ * @param {string} url
316
+ */
317
+ export function openLink(url) {
318
+ const tg = getTg();
319
+ if (tg?.openLink) {
320
+ tg.openLink(url);
321
+ return;
322
+ }
323
+ window.open(url, "_blank");
324
+ }
325
+
326
+ /* ─── Platform ─── */
327
+
328
+ /** Return the current Telegram platform string (e.g. 'android', 'ios', 'tdesktop'). */
329
+ export function getPlatform() {
330
+ return getTg()?.platform || "unknown";
331
+ }
@@ -0,0 +1,270 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * VirtEngine Control Center – Utility Helpers
3
+ * Pure functions – no framework imports needed
4
+ * ────────────────────────────────────────────────────────────── */
5
+
6
+ /**
7
+ * Format a Date (or ISO string) to a locale-aware string.
8
+ * @param {Date|string|number} d
9
+ * @returns {string}
10
+ */
11
+ export function formatDate(d) {
12
+ if (!d) return "—";
13
+ try {
14
+ const date = d instanceof Date ? d : new Date(d);
15
+ if (isNaN(date.getTime())) return String(d);
16
+ return date.toLocaleString(undefined, {
17
+ year: "numeric",
18
+ month: "short",
19
+ day: "numeric",
20
+ hour: "2-digit",
21
+ minute: "2-digit",
22
+ });
23
+ } catch {
24
+ return String(d);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Format a Date to a relative "Xm ago"/"Xh ago"/"Xd ago" string.
30
+ * @param {Date|string|number} d
31
+ * @returns {string}
32
+ */
33
+ export function formatRelative(d) {
34
+ if (d === null || d === undefined || d === "") return "—";
35
+ try {
36
+ let date;
37
+ if (d instanceof Date) {
38
+ date = d;
39
+ } else if (typeof d === "number") {
40
+ if (!Number.isFinite(d)) return "—";
41
+ const normalized = Math.abs(d) < 1e12 ? d * 1000 : d;
42
+ date = new Date(normalized);
43
+ } else {
44
+ date = new Date(d);
45
+ }
46
+ const timestamp = date.getTime();
47
+ if (!Number.isFinite(timestamp)) return "—";
48
+ const diffMs = Date.now() - timestamp;
49
+ if (!Number.isFinite(diffMs)) return "—";
50
+ if (diffMs < 0) return "just now";
51
+ const seconds = Math.floor(diffMs / 1000);
52
+ if (!Number.isFinite(seconds)) return "—";
53
+ if (seconds < 60) return `${seconds}s ago`;
54
+ const minutes = Math.floor(seconds / 60);
55
+ if (!Number.isFinite(minutes)) return "—";
56
+ if (minutes < 60) return `${minutes}m ago`;
57
+ const hours = Math.floor(minutes / 60);
58
+ if (!Number.isFinite(hours)) return "—";
59
+ if (hours < 24) return `${hours}h ago`;
60
+ const days = Math.floor(hours / 24);
61
+ if (!Number.isFinite(days)) return "—";
62
+ if (days < 30) return `${days}d ago`;
63
+ const months = Math.floor(days / 30);
64
+ if (!Number.isFinite(months)) return "—";
65
+ if (months < 12) return `${months}mo ago`;
66
+ const years = Math.floor(months / 12);
67
+ if (!Number.isFinite(years)) return "—";
68
+ return `${years}y ago`;
69
+ } catch {
70
+ return "—";
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Format milliseconds to a human-readable duration: "Xm Ys" or "Xh Ym".
76
+ * @param {number} ms
77
+ * @returns {string}
78
+ */
79
+ export function formatDuration(ms) {
80
+ if (ms == null || isNaN(ms)) return "—";
81
+ if (ms < 1000) return `${Math.round(ms)}ms`;
82
+ const totalSec = Math.floor(ms / 1000);
83
+ if (totalSec < 60) return `${totalSec}s`;
84
+ const minutes = Math.floor(totalSec / 60);
85
+ const seconds = totalSec % 60;
86
+ if (minutes < 60) return `${minutes}m ${seconds}s`;
87
+ const hours = Math.floor(minutes / 60);
88
+ const remainMin = minutes % 60;
89
+ return `${hours}h ${remainMin}m`;
90
+ }
91
+
92
+ /**
93
+ * Truncate a string, appending "…" if it exceeds maxLen.
94
+ * @param {string} str
95
+ * @param {number} maxLen
96
+ * @returns {string}
97
+ */
98
+ export function truncate(str, maxLen = 60) {
99
+ if (!str) return "";
100
+ if (str.length <= maxLen) return str;
101
+ return str.slice(0, maxLen - 1) + "…";
102
+ }
103
+
104
+ /**
105
+ * Debounce a function.
106
+ * @param {Function} fn
107
+ * @param {number} ms
108
+ * @returns {Function}
109
+ */
110
+ export function debounce(fn, ms = 300) {
111
+ let timer = null;
112
+ const debounced = (...args) => {
113
+ if (timer) clearTimeout(timer);
114
+ timer = setTimeout(() => {
115
+ timer = null;
116
+ fn(...args);
117
+ }, ms);
118
+ };
119
+ debounced.cancel = () => {
120
+ if (timer) {
121
+ clearTimeout(timer);
122
+ timer = null;
123
+ }
124
+ };
125
+ return debounced;
126
+ }
127
+
128
+ /**
129
+ * Deep clone a value via JSON round-trip. Returns null on failure.
130
+ * Prefers structuredClone when available.
131
+ * @param {*} v
132
+ * @returns {*}
133
+ */
134
+ export function cloneValue(v) {
135
+ if (v === null || v === undefined) return v;
136
+ try {
137
+ if (typeof structuredClone === "function") return structuredClone(v);
138
+ return JSON.parse(JSON.stringify(v));
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Format a byte count to a human-readable string.
146
+ * @param {number} bytes
147
+ * @returns {string}
148
+ */
149
+ export function formatBytes(bytes) {
150
+ if (bytes == null || isNaN(bytes)) return "—";
151
+ if (bytes === 0) return "0 B";
152
+ const units = ["B", "KB", "MB", "GB", "TB"];
153
+ const k = 1024;
154
+ const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k));
155
+ const idx = Math.min(i, units.length - 1);
156
+ const value = bytes / Math.pow(k, idx);
157
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[idx]}`;
158
+ }
159
+
160
+ /**
161
+ * Pluralize a word based on count.
162
+ * @param {number} count
163
+ * @param {string} singular
164
+ * @param {string} [plural]
165
+ * @returns {string}
166
+ */
167
+ export function pluralize(count, singular, plural) {
168
+ const p = plural || `${singular}s`;
169
+ return `${count} ${count === 1 ? singular : p}`;
170
+ }
171
+
172
+ /**
173
+ * Generate a simple unique ID (not crypto-grade).
174
+ * @returns {string}
175
+ */
176
+ export function generateId() {
177
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
178
+ }
179
+
180
+ /**
181
+ * Promisified setTimeout.
182
+ * @param {number} ms
183
+ * @returns {Promise<void>}
184
+ */
185
+ export function sleep(ms) {
186
+ return new Promise((resolve) => setTimeout(resolve, ms));
187
+ }
188
+
189
+ /**
190
+ * Conditional class name builder (similar to clsx / classnames).
191
+ * Accepts strings, objects { className: boolean }, and arrays.
192
+ * @param {...(string|object|Array|null|undefined|false)} args
193
+ * @returns {string}
194
+ */
195
+ export function classNames(...args) {
196
+ const classes = [];
197
+ for (const arg of args) {
198
+ if (!arg) continue;
199
+ if (typeof arg === "string") {
200
+ classes.push(arg);
201
+ } else if (Array.isArray(arg)) {
202
+ const inner = classNames(...arg);
203
+ if (inner) classes.push(inner);
204
+ } else if (typeof arg === "object") {
205
+ for (const [key, val] of Object.entries(arg)) {
206
+ if (val) classes.push(key);
207
+ }
208
+ }
209
+ }
210
+ return classes.join(" ");
211
+ }
212
+
213
+ /* ─── Data Export Utilities ─── */
214
+
215
+ /**
216
+ * Trigger a file download from in-memory content.
217
+ * @param {string} content
218
+ * @param {string} filename
219
+ * @param {string} [mimeType='text/plain']
220
+ */
221
+ export function downloadFile(content, filename, mimeType = "text/plain") {
222
+ const blob = new Blob([content], { type: mimeType });
223
+ const url = URL.createObjectURL(blob);
224
+ const a = document.createElement("a");
225
+ a.href = url;
226
+ a.download = filename;
227
+ document.body.appendChild(a);
228
+ a.click();
229
+ document.body.removeChild(a);
230
+ URL.revokeObjectURL(url);
231
+ }
232
+
233
+ /**
234
+ * Escape a value for CSV (RFC 4180).
235
+ * @param {*} val
236
+ * @returns {string}
237
+ */
238
+ function csvEscape(val) {
239
+ const str = val == null ? "" : String(val);
240
+ if (/[",\n\r]/.test(str)) {
241
+ return '"' + str.replace(/"/g, '""') + '"';
242
+ }
243
+ return str;
244
+ }
245
+
246
+ /**
247
+ * Export tabular data as a CSV file download.
248
+ * Adds a UTF-8 BOM for Excel compatibility.
249
+ * @param {string[]} headers
250
+ * @param {Array<Array<*>>} rows
251
+ * @param {string} filename
252
+ */
253
+ export function exportAsCSV(headers, rows, filename) {
254
+ const lines = [headers.map(csvEscape).join(",")];
255
+ for (const row of rows) {
256
+ lines.push(row.map(csvEscape).join(","));
257
+ }
258
+ const csv = "\uFEFF" + lines.join("\r\n");
259
+ downloadFile(csv, filename, "text/csv");
260
+ }
261
+
262
+ /**
263
+ * Export data as a pretty-printed JSON file download.
264
+ * @param {*} data
265
+ * @param {string} filename
266
+ */
267
+ export function exportAsJSON(data, filename) {
268
+ const json = JSON.stringify(data, null, 2);
269
+ downloadFile(json, filename, "application/json");
270
+ }