@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,473 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * VirtEngine Control Center – Shared UI Components
3
+ * Card, Badge, StatCard, Modal, Toast, EmptyState, etc.
4
+ * ────────────────────────────────────────────────────────────── */
5
+
6
+ import { h } from "preact";
7
+ import {
8
+ useState,
9
+ useEffect,
10
+ useRef,
11
+ useCallback,
12
+ } from "preact/hooks";
13
+ import htm from "htm";
14
+
15
+ const html = htm.bind(h);
16
+
17
+ import { ICONS } from "../modules/icons.js";
18
+ import { toasts, showToast, shouldShowToast } from "../modules/state.js";
19
+ import {
20
+ haptic,
21
+ showBackButton,
22
+ hideBackButton,
23
+ getTg,
24
+ } from "../modules/telegram.js";
25
+ import { classNames } from "../modules/utils.js";
26
+
27
+ /* ═══════════════════════════════════════════════
28
+ * Card
29
+ * ═══════════════════════════════════════════════ */
30
+
31
+ /**
32
+ * Card container with optional title / subtitle.
33
+ * @param {{title?: string, subtitle?: string, children?: any, className?: string, onClick?: () => void}} props
34
+ */
35
+ export function Card({ title, subtitle, children, className = "", onClick }) {
36
+ return html`
37
+ <div class="card ${className}" onClick=${onClick}>
38
+ ${title ? html`<div class="card-title">${title}</div>` : null}
39
+ ${subtitle ? html`<div class="card-subtitle">${subtitle}</div>` : null}
40
+ ${children}
41
+ </div>
42
+ `;
43
+ }
44
+
45
+ /* ═══════════════════════════════════════════════
46
+ * Badge
47
+ * ═══════════════════════════════════════════════ */
48
+
49
+ const BADGE_STATUS_MAP = new Set([
50
+ "draft",
51
+ "todo",
52
+ "inprogress",
53
+ "inreview",
54
+ "done",
55
+ "error",
56
+ "cancelled",
57
+ "critical",
58
+ "high",
59
+ "medium",
60
+ "low",
61
+ "log",
62
+ "info",
63
+ "warning",
64
+ ]);
65
+
66
+ /**
67
+ * Status badge pill.
68
+ * @param {{status?: string, text?: string, className?: string}} props
69
+ */
70
+ export function Badge({ status, text, className = "" }) {
71
+ const label = text || status || "";
72
+ const normalized = (status || "").toLowerCase().replace(/\s+/g, "");
73
+ const statusClass = BADGE_STATUS_MAP.has(normalized)
74
+ ? `badge-${normalized}`
75
+ : "";
76
+ return html`<span class="badge ${statusClass} ${className}">${label}</span>`;
77
+ }
78
+
79
+ /* ═══════════════════════════════════════════════
80
+ * StatCard
81
+ * ═══════════════════════════════════════════════ */
82
+
83
+ /**
84
+ * Stat display card with large value and small label.
85
+ * @param {{value: any, label: string, trend?: 'up'|'down', color?: string}} props
86
+ */
87
+ export function StatCard({ value, label, trend, color }) {
88
+ const valueStyle = color ? `color: ${color}` : "";
89
+ const trendIcon =
90
+ trend === "up"
91
+ ? html`<span class="stat-trend stat-trend-up">↑</span>`
92
+ : trend === "down"
93
+ ? html`<span class="stat-trend stat-trend-down">↓</span>`
94
+ : null;
95
+
96
+ return html`
97
+ <div class="stat-card">
98
+ <div class="stat-value" style=${valueStyle}>
99
+ ${value ?? "—"}${trendIcon}
100
+ </div>
101
+ <div class="stat-label">${label}</div>
102
+ </div>
103
+ `;
104
+ }
105
+
106
+ /* ═══════════════════════════════════════════════
107
+ * SkeletonCard
108
+ * ═══════════════════════════════════════════════ */
109
+
110
+ /**
111
+ * Animated loading placeholder.
112
+ * @param {{height?: string, className?: string}} props
113
+ */
114
+ export function SkeletonCard({ height = "80px", className = "" }) {
115
+ return html`
116
+ <div
117
+ class="skeleton skeleton-card ${className}"
118
+ style="height: ${height}"
119
+ ></div>
120
+ `;
121
+ }
122
+
123
+ /* ═══════════════════════════════════════════════
124
+ * Modal (Bottom Sheet)
125
+ * ═══════════════════════════════════════════════ */
126
+
127
+ /**
128
+ * Bottom-sheet modal with drag handle, title, swipe-to-dismiss, and TG BackButton integration.
129
+ * @param {{title?: string, open?: boolean, onClose: () => void, children?: any}} props
130
+ */
131
+ export function Modal({ title, open = true, onClose, children }) {
132
+ const [visible, setVisible] = useState(false);
133
+ const contentRef = useRef(null);
134
+ const dragState = useRef({ startY: 0, startRect: 0, dragging: false });
135
+ const [dragY, setDragY] = useState(0);
136
+
137
+ useEffect(() => {
138
+ requestAnimationFrame(() => setVisible(open));
139
+ }, [open]);
140
+
141
+ // BackButton integration
142
+ useEffect(() => {
143
+ const tg = getTg();
144
+ if (!tg?.BackButton) return;
145
+
146
+ const handler = () => {
147
+ onClose();
148
+ tg.BackButton.hide();
149
+ tg.BackButton.offClick(handler);
150
+ };
151
+ tg.BackButton.show();
152
+ tg.BackButton.onClick(handler);
153
+
154
+ return () => {
155
+ tg.BackButton.hide();
156
+ tg.BackButton.offClick(handler);
157
+ };
158
+ }, [onClose]);
159
+
160
+ // Prevent body scroll while dragging
161
+ useEffect(() => {
162
+ if (dragState.current.dragging) {
163
+ document.body.style.overflow = "hidden";
164
+ return () => { document.body.style.overflow = ""; };
165
+ }
166
+ });
167
+
168
+ const handleTouchStart = useCallback((e) => {
169
+ const el = contentRef.current;
170
+ if (!el) return;
171
+ const rect = el.getBoundingClientRect();
172
+ const touchY = e.touches[0].clientY;
173
+ // Only start drag if touch is within top 60px of the modal content
174
+ if (touchY - rect.top > 60) return;
175
+ dragState.current = { startY: touchY, startRect: rect.top, dragging: true };
176
+ // Disable transition during active drag
177
+ el.style.transition = "none";
178
+ }, []);
179
+
180
+ const handleTouchMove = useCallback((e) => {
181
+ if (!dragState.current.dragging) return;
182
+ const deltaY = e.touches[0].clientY - dragState.current.startY;
183
+ if (deltaY < 0) {
184
+ setDragY(0);
185
+ return;
186
+ }
187
+ // Diminishing returns past 100px
188
+ const translated = deltaY <= 100 ? deltaY : 100 + (deltaY - 100) * 0.3;
189
+ setDragY(translated);
190
+ e.preventDefault();
191
+ }, []);
192
+
193
+ const handleTouchEnd = useCallback(() => {
194
+ if (!dragState.current.dragging) return;
195
+ dragState.current.dragging = false;
196
+ const el = contentRef.current;
197
+ if (el) el.style.transition = "";
198
+ if (dragY > 150) {
199
+ getTg()?.HapticFeedback?.impactOccurred("light");
200
+ onClose();
201
+ }
202
+ setDragY(0);
203
+ }, [dragY, onClose]);
204
+
205
+ const handleTouchCancel = useCallback(() => {
206
+ if (!dragState.current.dragging) return;
207
+ dragState.current.dragging = false;
208
+ const el = contentRef.current;
209
+ if (el) el.style.transition = "";
210
+ setDragY(0);
211
+ }, []);
212
+
213
+ if (!open) return null;
214
+
215
+ const dragStyle = dragY > 0
216
+ ? `transform: translateY(${dragY}px); opacity: ${Math.max(0.2, 1 - dragY / 400)}`
217
+ : "";
218
+
219
+ return html`
220
+ <div
221
+ class="modal-overlay ${visible ? "modal-overlay-visible" : ""}"
222
+ onClick=${(e) => {
223
+ if (e.target === e.currentTarget) onClose();
224
+ }}
225
+ >
226
+ <div
227
+ ref=${contentRef}
228
+ class="modal-content ${visible ? "modal-content-visible" : ""} ${dragY > 0 ? "modal-dragging" : ""}"
229
+ style=${dragStyle}
230
+ onClick=${(e) => e.stopPropagation()}
231
+ onTouchStart=${handleTouchStart}
232
+ onTouchMove=${handleTouchMove}
233
+ onTouchEnd=${handleTouchEnd}
234
+ onTouchCancel=${handleTouchCancel}
235
+ >
236
+ <div class="modal-handle"></div>
237
+ ${title ? html`<div class="modal-title">${title}</div>` : null}
238
+ ${children}
239
+ </div>
240
+ </div>
241
+ `;
242
+ }
243
+
244
+ /* ═══════════════════════════════════════════════
245
+ * ConfirmDialog
246
+ * ═══════════════════════════════════════════════ */
247
+
248
+ /**
249
+ * Confirmation dialog — tries Telegram native showConfirm first, falls back to styled modal.
250
+ * @param {{title?: string, message: string, confirmText?: string, cancelText?: string, onConfirm: () => void, onCancel: () => void, destructive?: boolean}} props
251
+ */
252
+ export function ConfirmDialog({
253
+ title = "Confirm",
254
+ message,
255
+ confirmText = "Confirm",
256
+ cancelText = "Cancel",
257
+ onConfirm,
258
+ onCancel,
259
+ destructive = false,
260
+ }) {
261
+ const [tried, setTried] = useState(false);
262
+
263
+ // Try Telegram native confirm first
264
+ useEffect(() => {
265
+ const tg = getTg();
266
+ if (tg?.showConfirm && !tried) {
267
+ setTried(true);
268
+ tg.showConfirm(message, (ok) => {
269
+ if (ok) onConfirm();
270
+ else onCancel();
271
+ });
272
+ }
273
+ }, [message, onConfirm, onCancel, tried]);
274
+
275
+ // If Telegram native is available, render nothing (native dialog handles it)
276
+ if (getTg()?.showConfirm) return null;
277
+
278
+ const confirmBtnStyle = destructive
279
+ ? "background: var(--destructive); color: #fff;"
280
+ : "";
281
+
282
+ return html`
283
+ <div class="modal-overlay modal-overlay-visible" onClick=${onCancel}>
284
+ <div
285
+ class="confirm-dialog"
286
+ onClick=${(e) => e.stopPropagation()}
287
+ >
288
+ <div class="confirm-dialog-title">${title}</div>
289
+ <div class="confirm-dialog-message">${message}</div>
290
+ <div class="confirm-dialog-actions">
291
+ <button class="btn btn-secondary" onClick=${onCancel}>
292
+ ${cancelText}
293
+ </button>
294
+ <button
295
+ class="btn btn-primary ${destructive ? "btn-destructive" : ""}"
296
+ style=${confirmBtnStyle}
297
+ onClick=${onConfirm}
298
+ >
299
+ ${confirmText}
300
+ </button>
301
+ </div>
302
+ </div>
303
+ </div>
304
+ `;
305
+ }
306
+
307
+ /* ═══════════════════════════════════════════════
308
+ * confirmAction (Promise-based utility)
309
+ * ═══════════════════════════════════════════════ */
310
+
311
+ /**
312
+ * Quick inline confirmation — returns a Promise<boolean>.
313
+ * Uses Telegram native confirm if available, otherwise browser confirm().
314
+ * @param {string} message
315
+ * @returns {Promise<boolean>}
316
+ */
317
+ export function confirmAction(message) {
318
+ const tg = getTg();
319
+ if (tg?.showConfirm) {
320
+ return new Promise((resolve) => tg.showConfirm(message, resolve));
321
+ }
322
+ return Promise.resolve(window.confirm(message));
323
+ }
324
+
325
+ /* ═══════════════════════════════════════════════
326
+ * Toast / ToastContainer
327
+ * ═══════════════════════════════════════════════ */
328
+
329
+ /**
330
+ * Renders all active toasts from the toasts signal.
331
+ * Each toast auto-dismisses (handled by showToast in state.js).
332
+ */
333
+ export function ToastContainer() {
334
+ const items = toasts.value;
335
+ if (!items.length) return null;
336
+
337
+ const visible = items.filter(shouldShowToast);
338
+ if (!visible.length) return null;
339
+
340
+ return html`
341
+ <div class="toast-container">
342
+ ${visible.map(
343
+ (t) => html`
344
+ <div key=${t.id} class="toast toast-${t.type}">
345
+ <span class="toast-message">${t.message}</span>
346
+ <button
347
+ class="toast-close"
348
+ onClick=${() => {
349
+ toasts.value = toasts.value.filter((x) => x.id !== t.id);
350
+ }}
351
+ >
352
+ ×
353
+ </button>
354
+ </div>
355
+ `,
356
+ )}
357
+ </div>
358
+ `;
359
+ }
360
+
361
+ /* ═══════════════════════════════════════════════
362
+ * EmptyState
363
+ * ═══════════════════════════════════════════════ */
364
+
365
+ /**
366
+ * Empty state display.
367
+ * @param {{icon?: string, title?: string, message?: string, description?: string, action?: {label: string, onClick: () => void}}} props
368
+ */
369
+ export function EmptyState({ icon, title, message, description, action }) {
370
+ const iconSvg = icon && ICONS[icon] ? ICONS[icon] : null;
371
+ const displayIcon = iconSvg ? html`<div class="empty-state-icon">${iconSvg}</div>`
372
+ : icon ? html`<div class="empty-state-icon">${icon}</div>`
373
+ : null;
374
+ const displayTitle = title || message || null;
375
+ return html`
376
+ <div class="empty-state">
377
+ ${displayIcon}
378
+ ${displayTitle ? html`<div class="empty-state-title">${displayTitle}</div>` : null}
379
+ ${description
380
+ ? html`<div class="empty-state-description">${description}</div>`
381
+ : null}
382
+ ${action
383
+ ? html`<button class="btn btn-primary btn-sm" onClick=${action.onClick}>
384
+ ${action.label}
385
+ </button>`
386
+ : null}
387
+ </div>
388
+ `;
389
+ }
390
+
391
+ /* ═══════════════════════════════════════════════
392
+ * Divider
393
+ * ═══════════════════════════════════════════════ */
394
+
395
+ /**
396
+ * Section divider with optional centered label.
397
+ * @param {{label?: string}} props
398
+ */
399
+ export function Divider({ label }) {
400
+ if (!label) return html`<div class="divider"></div>`;
401
+ return html`
402
+ <div class="divider divider-label">
403
+ <span>${label}</span>
404
+ </div>
405
+ `;
406
+ }
407
+
408
+ /* ═══════════════════════════════════════════════
409
+ * Avatar
410
+ * ═══════════════════════════════════════════════ */
411
+
412
+ /**
413
+ * Circle avatar with initials fallback.
414
+ * @param {{name?: string, size?: number, src?: string}} props
415
+ */
416
+ export function Avatar({ name = "", size = 36, src }) {
417
+ const initials = name
418
+ .split(/\s+/)
419
+ .slice(0, 2)
420
+ .map((w) => w.charAt(0).toUpperCase())
421
+ .join("");
422
+
423
+ const style = `width:${size}px;height:${size}px;border-radius:50%;overflow:hidden;
424
+ display:flex;align-items:center;justify-content:center;
425
+ background:var(--accent,#5b6eae);color:var(--accent-text,#fff);
426
+ font-size:${Math.round(size * 0.4)}px;font-weight:600;flex-shrink:0`;
427
+
428
+ if (src) {
429
+ return html`
430
+ <div style=${style}>
431
+ <img
432
+ src=${src}
433
+ alt=${name}
434
+ style="width:100%;height:100%;object-fit:cover"
435
+ onError=${(e) => {
436
+ e.target.style.display = "none";
437
+ }}
438
+ />
439
+ </div>
440
+ `;
441
+ }
442
+
443
+ return html`<div style=${style}>${initials || "?"}</div>`;
444
+ }
445
+
446
+ /* ═══════════════════════════════════════════════
447
+ * ListItem
448
+ * ═══════════════════════════════════════════════ */
449
+
450
+ /**
451
+ * Generic list item for settings-style lists.
452
+ * @param {{title: string, subtitle?: string, trailing?: any, onClick?: () => void, icon?: string}} props
453
+ */
454
+ export function ListItem({ title, subtitle, trailing, onClick, icon }) {
455
+ const iconSvg = icon && ICONS[icon] ? ICONS[icon] : null;
456
+ return html`
457
+ <div
458
+ class=${classNames("list-item", { "list-item-clickable": !!onClick })}
459
+ onClick=${onClick}
460
+ >
461
+ ${iconSvg ? html`<div class="list-item-icon">${iconSvg}</div>` : null}
462
+ <div class="list-item-body">
463
+ <div class="list-item-title">${title}</div>
464
+ ${subtitle
465
+ ? html`<div class="list-item-subtitle">${subtitle}</div>`
466
+ : null}
467
+ </div>
468
+ ${trailing != null
469
+ ? html`<div class="list-item-trailing">${trailing}</div>`
470
+ : null}
471
+ </div>
472
+ `;
473
+ }
package/ui/index.html ADDED
@@ -0,0 +1,70 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
6
+ <meta name="color-scheme" content="dark light" />
7
+ <title>VirtEngine Control Center</title>
8
+ <script src="https://telegram.org/js/telegram-web-app.js"></script>
9
+ <link rel="preconnect" href="https://esm.sh" crossorigin />
10
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Sora:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
13
+ <link rel="stylesheet" href="styles.css" />
14
+ <link rel="stylesheet" href="styles/kanban.css" />
15
+ <link rel="stylesheet" href="styles/sessions.css" />
16
+ <!-- Import map: ensures all modules share a single Preact instance
17
+ (required for @preact/signals auto-subscription to work) -->
18
+ <script type="importmap">
19
+ {
20
+ "imports": {
21
+ "preact": "https://esm.sh/preact@10.25.4",
22
+ "preact/hooks": "https://esm.sh/preact@10.25.4/hooks",
23
+ "htm": "https://esm.sh/htm@3.1.1",
24
+ "@preact/signals": "https://esm.sh/@preact/signals@1.3.1?deps=preact@10.25.4"
25
+ }
26
+ }
27
+ </script>
28
+ </head>
29
+ <body>
30
+ <div id="app">
31
+ <div id="boot-loader" style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:'Sora',sans-serif;color:#9aa3b2;background:#0b0f14;background-image:radial-gradient(circle at 20% 10%,rgba(76,201,240,.18),transparent 55%),radial-gradient(circle at 80% 0%,rgba(94,234,212,.12),transparent 45%),radial-gradient(circle at 50% 100%,rgba(59,130,246,.12),transparent 60%);overflow:hidden">
32
+ <style>
33
+ .boot-logo{position:relative;width:80px;height:80px;margin-bottom:24px}
34
+ .boot-ring{position:absolute;inset:0;border-radius:50%;border:2px solid rgba(99,102,241,.15);animation:boot-spin 3s linear infinite}
35
+ .boot-ring-inner{position:absolute;inset:8px;border-radius:50%;border:2px solid transparent;border-top-color:#6366f1;border-right-color:rgba(99,102,241,.4);animation:boot-spin 1.5s linear infinite reverse}
36
+ .boot-dot{position:absolute;top:50%;left:50%;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;background:linear-gradient(135deg,#6366f1,#a78bfa);box-shadow:0 0 20px rgba(99,102,241,.5),0 0 40px rgba(99,102,241,.2);animation:boot-pulse 2s ease-in-out infinite}
37
+ .boot-title{font-size:22px;font-weight:700;letter-spacing:-.02em;background:linear-gradient(135deg,#f1f5f9,#94a3b8);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-bottom:6px;animation:boot-fadeUp .6s ease .2s both}
38
+ .boot-subtitle{font-size:13px;color:#64748b;animation:boot-fadeUp .6s ease .4s both}
39
+ .boot-progress{width:120px;height:2px;background:rgba(255,255,255,.06);border-radius:2px;margin-top:20px;overflow:hidden;animation:boot-fadeUp .6s ease .6s both}
40
+ .boot-progress-bar{height:100%;width:0;background:linear-gradient(90deg,#6366f1,#a78bfa);border-radius:2px;animation:boot-load 2s ease-in-out .8s forwards}
41
+ @keyframes boot-spin{to{transform:rotate(360deg)}}
42
+ @keyframes boot-pulse{0%,100%{transform:scale(1);opacity:.8}50%{transform:scale(1.2);opacity:1}}
43
+ @keyframes boot-fadeUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
44
+ @keyframes boot-load{0%{width:0}60%{width:70%}100%{width:100%}}
45
+ </style>
46
+ <div class="boot-logo">
47
+ <div class="boot-ring"></div>
48
+ <div class="boot-ring-inner"></div>
49
+ <div class="boot-dot"></div>
50
+ </div>
51
+ <div class="boot-title">VirtEngine</div>
52
+ <div class="boot-subtitle" id="boot-status">Initializing Control Center…</div>
53
+ <div class="boot-progress"><div class="boot-progress-bar"></div></div>
54
+ </div>
55
+ </div>
56
+ <script type="module" src="app.js"></script>
57
+ <script>
58
+ // Signal Telegram that the page has loaded (before ES modules finish loading)
59
+ try { if (window.Telegram && Telegram.WebApp) Telegram.WebApp.ready(); } catch(e) {}
60
+ // Fallback: if the ES module fails to load within 12s, show an error
61
+ setTimeout(function() {
62
+ var el = document.getElementById('boot-loader');
63
+ if (el) {
64
+ var s = document.getElementById('boot-status');
65
+ if (s) s.innerHTML = '⚠️ Failed to load app modules.<br><small style="color:#888">Check your internet connection (CDN: esm.sh) or try refreshing.</small>';
66
+ }
67
+ }, 12000);
68
+ </script>
69
+ </body>
70
+ </html>