bosun 0.26.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.
Files changed (122) hide show
  1. package/.env.example +918 -0
  2. package/LICENSE +190 -0
  3. package/README.md +98 -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/bosun.config.example.json +115 -0
  13. package/bosun.schema.json +465 -0
  14. package/claude-shell.mjs +708 -0
  15. package/cli.mjs +1028 -0
  16. package/codex-config.mjs +1274 -0
  17. package/codex-model-profiles.mjs +135 -0
  18. package/codex-shell.mjs +762 -0
  19. package/compat.mjs +286 -0
  20. package/config-doctor.mjs +613 -0
  21. package/config.mjs +1724 -0
  22. package/conflict-resolver.mjs +248 -0
  23. package/container-runner.mjs +450 -0
  24. package/copilot-shell.mjs +827 -0
  25. package/daemon-restart-policy.mjs +56 -0
  26. package/diff-stats.mjs +282 -0
  27. package/error-detector.mjs +829 -0
  28. package/fetch-runtime.mjs +34 -0
  29. package/fleet-coordinator.mjs +838 -0
  30. package/get-telegram-chat-id.mjs +71 -0
  31. package/git-safety.mjs +170 -0
  32. package/github-reconciler.mjs +403 -0
  33. package/hook-profiles.mjs +651 -0
  34. package/kanban-adapter.mjs +4491 -0
  35. package/lib/logger.mjs +645 -0
  36. package/maintenance.mjs +828 -0
  37. package/merge-strategy.mjs +1171 -0
  38. package/monitor.mjs +12237 -0
  39. package/package.json +209 -0
  40. package/postinstall.mjs +187 -0
  41. package/pr-cleanup-daemon.mjs +978 -0
  42. package/preflight.mjs +408 -0
  43. package/prepublish-check.mjs +90 -0
  44. package/presence.mjs +328 -0
  45. package/primary-agent.mjs +290 -0
  46. package/publish.mjs +241 -0
  47. package/repo-root.mjs +29 -0
  48. package/restart-controller.mjs +100 -0
  49. package/review-agent.mjs +557 -0
  50. package/rotate-agent-logs.sh +133 -0
  51. package/sdk-conflict-resolver.mjs +973 -0
  52. package/session-tracker.mjs +880 -0
  53. package/setup.mjs +3946 -0
  54. package/shared-knowledge.mjs +410 -0
  55. package/shared-state-manager.mjs +841 -0
  56. package/shared-workspace-cli.mjs +199 -0
  57. package/shared-workspace-registry.mjs +537 -0
  58. package/shared-workspaces.json +18 -0
  59. package/startup-service.mjs +1070 -0
  60. package/sync-engine.mjs +1063 -0
  61. package/task-archiver.mjs +801 -0
  62. package/task-assessment.mjs +550 -0
  63. package/task-claims.mjs +924 -0
  64. package/task-complexity.mjs +581 -0
  65. package/task-executor.mjs +5111 -0
  66. package/task-store.mjs +753 -0
  67. package/telegram-bot.mjs +9683 -0
  68. package/telegram-sentinel.mjs +2010 -0
  69. package/ui/app.js +867 -0
  70. package/ui/app.legacy.js +1464 -0
  71. package/ui/app.monolith.js +2488 -0
  72. package/ui/components/charts.js +226 -0
  73. package/ui/components/chat-view.js +567 -0
  74. package/ui/components/command-palette.js +587 -0
  75. package/ui/components/diff-viewer.js +190 -0
  76. package/ui/components/forms.js +357 -0
  77. package/ui/components/kanban-board.js +451 -0
  78. package/ui/components/session-list.js +305 -0
  79. package/ui/components/shared.js +525 -0
  80. package/ui/demo.html +640 -0
  81. package/ui/index.html +70 -0
  82. package/ui/modules/api.js +297 -0
  83. package/ui/modules/icons.js +461 -0
  84. package/ui/modules/router.js +81 -0
  85. package/ui/modules/settings-schema.js +261 -0
  86. package/ui/modules/state.js +679 -0
  87. package/ui/modules/telegram.js +331 -0
  88. package/ui/modules/utils.js +270 -0
  89. package/ui/styles/animations.css +140 -0
  90. package/ui/styles/base.css +98 -0
  91. package/ui/styles/components.css +2032 -0
  92. package/ui/styles/kanban.css +286 -0
  93. package/ui/styles/layout.css +810 -0
  94. package/ui/styles/sessions.css +841 -0
  95. package/ui/styles/variables.css +188 -0
  96. package/ui/styles.css +141 -0
  97. package/ui/styles.monolith.css +1046 -0
  98. package/ui/tabs/agents.js +1417 -0
  99. package/ui/tabs/chat.js +75 -0
  100. package/ui/tabs/control.js +892 -0
  101. package/ui/tabs/dashboard.js +515 -0
  102. package/ui/tabs/infra.js +537 -0
  103. package/ui/tabs/logs.js +783 -0
  104. package/ui/tabs/settings.js +1509 -0
  105. package/ui/tabs/tasks.js +1385 -0
  106. package/ui-server.mjs +4084 -0
  107. package/update-check.mjs +471 -0
  108. package/utils.mjs +172 -0
  109. package/ve-kanban.mjs +654 -0
  110. package/ve-kanban.ps1 +1365 -0
  111. package/ve-kanban.sh +18 -0
  112. package/ve-orchestrator.mjs +340 -0
  113. package/ve-orchestrator.ps1 +6546 -0
  114. package/ve-orchestrator.sh +18 -0
  115. package/vibe-kanban-wrapper.mjs +41 -0
  116. package/vk-error-resolver.mjs +470 -0
  117. package/vk-log-stream.mjs +914 -0
  118. package/whatsapp-channel.mjs +520 -0
  119. package/workspace-monitor.mjs +581 -0
  120. package/workspace-reaper.mjs +405 -0
  121. package/workspace-registry.mjs +238 -0
  122. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,525 @@
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
+ // Escape key to close (desktop support)
142
+ useEffect(() => {
143
+ if (!open) return;
144
+ const handler = (e) => { if (e.key === 'Escape' && onClose) onClose(); };
145
+ document.addEventListener('keydown', handler);
146
+ return () => document.removeEventListener('keydown', handler);
147
+ }, [open, onClose]);
148
+
149
+ // BackButton integration
150
+ useEffect(() => {
151
+ const tg = getTg();
152
+ if (!tg?.BackButton) return;
153
+
154
+ const handler = () => {
155
+ onClose();
156
+ tg.BackButton.hide();
157
+ tg.BackButton.offClick(handler);
158
+ };
159
+ tg.BackButton.show();
160
+ tg.BackButton.onClick(handler);
161
+
162
+ return () => {
163
+ tg.BackButton.hide();
164
+ tg.BackButton.offClick(handler);
165
+ };
166
+ }, [onClose]);
167
+
168
+ // Prevent body scroll while dragging
169
+ useEffect(() => {
170
+ if (dragState.current.dragging) {
171
+ document.body.style.overflow = "hidden";
172
+ return () => { document.body.style.overflow = ""; };
173
+ }
174
+ });
175
+
176
+ const handleTouchStart = useCallback((e) => {
177
+ const el = contentRef.current;
178
+ if (!el) return;
179
+ const rect = el.getBoundingClientRect();
180
+ const touchY = e.touches[0].clientY;
181
+ // Only start drag if touch is within top 60px of the modal content
182
+ if (touchY - rect.top > 60) return;
183
+ dragState.current = { startY: touchY, startRect: rect.top, dragging: true };
184
+ // Disable transition during active drag
185
+ el.style.transition = "none";
186
+ }, []);
187
+
188
+ const handleTouchMove = useCallback((e) => {
189
+ if (!dragState.current.dragging) return;
190
+ const deltaY = e.touches[0].clientY - dragState.current.startY;
191
+ if (deltaY < 0) {
192
+ setDragY(0);
193
+ return;
194
+ }
195
+ // Diminishing returns past 100px
196
+ const translated = deltaY <= 100 ? deltaY : 100 + (deltaY - 100) * 0.3;
197
+ setDragY(translated);
198
+ e.preventDefault();
199
+ }, []);
200
+
201
+ const handleTouchEnd = useCallback(() => {
202
+ if (!dragState.current.dragging) return;
203
+ dragState.current.dragging = false;
204
+ const el = contentRef.current;
205
+ if (el) el.style.transition = "";
206
+ if (dragY > 150) {
207
+ getTg()?.HapticFeedback?.impactOccurred("light");
208
+ onClose();
209
+ }
210
+ setDragY(0);
211
+ }, [dragY, onClose]);
212
+
213
+ const handleTouchCancel = useCallback(() => {
214
+ if (!dragState.current.dragging) return;
215
+ dragState.current.dragging = false;
216
+ const el = contentRef.current;
217
+ if (el) el.style.transition = "";
218
+ setDragY(0);
219
+ }, []);
220
+
221
+ if (!open) return null;
222
+
223
+ const dragStyle = dragY > 0
224
+ ? `transform: translateY(${dragY}px); opacity: ${Math.max(0.2, 1 - dragY / 400)}`
225
+ : "";
226
+
227
+ return html`
228
+ <div
229
+ class="modal-overlay ${visible ? "modal-overlay-visible" : ""}"
230
+ onClick=${(e) => {
231
+ if (e.target === e.currentTarget) onClose();
232
+ }}
233
+ >
234
+ <div
235
+ ref=${contentRef}
236
+ class="modal-content ${visible ? "modal-content-visible" : ""} ${dragY > 0 ? "modal-dragging" : ""}"
237
+ style=${dragStyle}
238
+ onClick=${(e) => e.stopPropagation()}
239
+ onTouchStart=${handleTouchStart}
240
+ onTouchMove=${handleTouchMove}
241
+ onTouchEnd=${handleTouchEnd}
242
+ onTouchCancel=${handleTouchCancel}
243
+ >
244
+ <div class="modal-header">
245
+ <div class="modal-handle"></div>
246
+ ${title ? html`<div class="modal-title">${title}</div>` : null}
247
+ <button class="modal-close-btn" onClick=${onClose} aria-label="Close">
248
+ ${ICONS.close}
249
+ </button>
250
+ </div>
251
+ ${children}
252
+ </div>
253
+ </div>
254
+ `;
255
+ }
256
+
257
+ /* ═══════════════════════════════════════════════
258
+ * ConfirmDialog
259
+ * ═══════════════════════════════════════════════ */
260
+
261
+ /**
262
+ * Confirmation dialog — tries Telegram native showConfirm first, falls back to styled modal.
263
+ * @param {{title?: string, message: string, confirmText?: string, cancelText?: string, onConfirm: () => void, onCancel: () => void, destructive?: boolean}} props
264
+ */
265
+ export function ConfirmDialog({
266
+ title = "Confirm",
267
+ message,
268
+ confirmText = "Confirm",
269
+ cancelText = "Cancel",
270
+ onConfirm,
271
+ onCancel,
272
+ destructive = false,
273
+ }) {
274
+ const [tried, setTried] = useState(false);
275
+
276
+ // Escape key to cancel (desktop support)
277
+ useEffect(() => {
278
+ const handler = (e) => { if (e.key === 'Escape' && onCancel) onCancel(); };
279
+ document.addEventListener('keydown', handler);
280
+ return () => document.removeEventListener('keydown', handler);
281
+ }, [onCancel]);
282
+
283
+ // Try Telegram native confirm first
284
+ useEffect(() => {
285
+ const tg = getTg();
286
+ if (tg?.showConfirm && !tried) {
287
+ setTried(true);
288
+ tg.showConfirm(message, (ok) => {
289
+ if (ok) onConfirm();
290
+ else onCancel();
291
+ });
292
+ }
293
+ }, [message, onConfirm, onCancel, tried]);
294
+
295
+ // If Telegram native is available, render nothing (native dialog handles it)
296
+ if (getTg()?.showConfirm) return null;
297
+
298
+ const confirmBtnStyle = destructive
299
+ ? "background: var(--destructive); color: #fff;"
300
+ : "";
301
+
302
+ return html`
303
+ <div class="modal-overlay modal-overlay-visible" onClick=${onCancel}>
304
+ <div
305
+ class="confirm-dialog"
306
+ onClick=${(e) => e.stopPropagation()}
307
+ >
308
+ <div class="confirm-dialog-title">${title}</div>
309
+ <div class="confirm-dialog-message">${message}</div>
310
+ <div class="confirm-dialog-actions">
311
+ <button class="btn btn-secondary" onClick=${onCancel}>
312
+ ${cancelText}
313
+ </button>
314
+ <button
315
+ class="btn btn-primary ${destructive ? "btn-destructive" : ""}"
316
+ style=${confirmBtnStyle}
317
+ onClick=${onConfirm}
318
+ >
319
+ ${confirmText}
320
+ </button>
321
+ </div>
322
+ </div>
323
+ </div>
324
+ `;
325
+ }
326
+
327
+ /* ═══════════════════════════════════════════════
328
+ * confirmAction (Promise-based utility)
329
+ * ═══════════════════════════════════════════════ */
330
+
331
+ /**
332
+ * Quick inline confirmation — returns a Promise<boolean>.
333
+ * Uses Telegram native confirm if available, otherwise browser confirm().
334
+ * @param {string} message
335
+ * @returns {Promise<boolean>}
336
+ */
337
+ export function confirmAction(message) {
338
+ const tg = getTg();
339
+ if (tg?.showConfirm) {
340
+ return new Promise((resolve) => tg.showConfirm(message, resolve));
341
+ }
342
+ return Promise.resolve(window.confirm(message));
343
+ }
344
+
345
+ /* ═══════════════════════════════════════════════
346
+ * Spinner
347
+ * ═══════════════════════════════════════════════ */
348
+
349
+ /**
350
+ * Inline SVG spinner for loading indicators.
351
+ * @param {{size?: number, color?: string}} props
352
+ */
353
+ export function Spinner({ size = 16, color = "currentColor" }) {
354
+ return html`<svg class="spinner" width=${size} height=${size} viewBox="0 0 24 24" fill="none" stroke=${color} stroke-width="2.5" stroke-linecap="round">
355
+ <circle cx="12" cy="12" r="10" opacity="0.25" />
356
+ <path d="M12 2a10 10 0 0 1 10 10" />
357
+ </svg>`;
358
+ }
359
+
360
+ /* ═══════════════════════════════════════════════
361
+ * LoadingButton
362
+ * ═══════════════════════════════════════════════ */
363
+
364
+ /**
365
+ * Button that shows a spinner when loading.
366
+ * @param {{loading?: boolean, onClick?: () => void, children?: any, class?: string, disabled?: boolean}} props
367
+ */
368
+ export function LoadingButton({ loading = false, onClick, children, class: cls = "", disabled = false, ...rest }) {
369
+ return html`<button
370
+ class=${`btn ${cls} ${loading ? "btn-loading" : ""}`}
371
+ onClick=${!loading && !disabled ? onClick : undefined}
372
+ disabled=${loading || disabled}
373
+ ...${rest}
374
+ >${loading ? html`<${Spinner} size=${14} /> ` : ""}${children}</button>`;
375
+ }
376
+
377
+ /* ═══════════════════════════════════════════════
378
+ * Toast / ToastContainer
379
+ * ═══════════════════════════════════════════════ */
380
+
381
+ /**
382
+ * Renders all active toasts from the toasts signal.
383
+ * Each toast auto-dismisses (handled by showToast in state.js).
384
+ */
385
+ export function ToastContainer() {
386
+ const items = toasts.value;
387
+ if (!items.length) return null;
388
+
389
+ const visible = items.filter(shouldShowToast);
390
+ if (!visible.length) return null;
391
+
392
+ return html`
393
+ <div class="toast-container">
394
+ ${visible.map(
395
+ (t) => html`
396
+ <div key=${t.id} class="toast toast-${t.type}">
397
+ <span class="toast-message">${t.message}</span>
398
+ <button
399
+ class="toast-close"
400
+ onClick=${() => {
401
+ toasts.value = toasts.value.filter((x) => x.id !== t.id);
402
+ }}
403
+ >
404
+ ×
405
+ </button>
406
+ </div>
407
+ `,
408
+ )}
409
+ </div>
410
+ `;
411
+ }
412
+
413
+ /* ═══════════════════════════════════════════════
414
+ * EmptyState
415
+ * ═══════════════════════════════════════════════ */
416
+
417
+ /**
418
+ * Empty state display.
419
+ * @param {{icon?: string, title?: string, message?: string, description?: string, action?: {label: string, onClick: () => void}}} props
420
+ */
421
+ export function EmptyState({ icon, title, message, description, action }) {
422
+ const iconSvg = icon && ICONS[icon] ? ICONS[icon] : null;
423
+ const displayIcon = iconSvg ? html`<div class="empty-state-icon">${iconSvg}</div>`
424
+ : icon ? html`<div class="empty-state-icon">${icon}</div>`
425
+ : null;
426
+ const displayTitle = title || message || null;
427
+ return html`
428
+ <div class="empty-state">
429
+ ${displayIcon}
430
+ ${displayTitle ? html`<div class="empty-state-title">${displayTitle}</div>` : null}
431
+ ${description
432
+ ? html`<div class="empty-state-description">${description}</div>`
433
+ : null}
434
+ ${action
435
+ ? html`<button class="btn btn-primary btn-sm" onClick=${action.onClick}>
436
+ ${action.label}
437
+ </button>`
438
+ : null}
439
+ </div>
440
+ `;
441
+ }
442
+
443
+ /* ═══════════════════════════════════════════════
444
+ * Divider
445
+ * ═══════════════════════════════════════════════ */
446
+
447
+ /**
448
+ * Section divider with optional centered label.
449
+ * @param {{label?: string}} props
450
+ */
451
+ export function Divider({ label }) {
452
+ if (!label) return html`<div class="divider"></div>`;
453
+ return html`
454
+ <div class="divider divider-label">
455
+ <span>${label}</span>
456
+ </div>
457
+ `;
458
+ }
459
+
460
+ /* ═══════════════════════════════════════════════
461
+ * Avatar
462
+ * ═══════════════════════════════════════════════ */
463
+
464
+ /**
465
+ * Circle avatar with initials fallback.
466
+ * @param {{name?: string, size?: number, src?: string}} props
467
+ */
468
+ export function Avatar({ name = "", size = 36, src }) {
469
+ const initials = name
470
+ .split(/\s+/)
471
+ .slice(0, 2)
472
+ .map((w) => w.charAt(0).toUpperCase())
473
+ .join("");
474
+
475
+ const style = `width:${size}px;height:${size}px;border-radius:50%;overflow:hidden;
476
+ display:flex;align-items:center;justify-content:center;
477
+ background:var(--accent,#5b6eae);color:var(--accent-text,#fff);
478
+ font-size:${Math.round(size * 0.4)}px;font-weight:600;flex-shrink:0`;
479
+
480
+ if (src) {
481
+ return html`
482
+ <div style=${style}>
483
+ <img
484
+ src=${src}
485
+ alt=${name}
486
+ style="width:100%;height:100%;object-fit:cover"
487
+ onError=${(e) => {
488
+ e.target.style.display = "none";
489
+ }}
490
+ />
491
+ </div>
492
+ `;
493
+ }
494
+
495
+ return html`<div style=${style}>${initials || "?"}</div>`;
496
+ }
497
+
498
+ /* ═══════════════════════════════════════════════
499
+ * ListItem
500
+ * ═══════════════════════════════════════════════ */
501
+
502
+ /**
503
+ * Generic list item for settings-style lists.
504
+ * @param {{title: string, subtitle?: string, trailing?: any, onClick?: () => void, icon?: string}} props
505
+ */
506
+ export function ListItem({ title, subtitle, trailing, onClick, icon }) {
507
+ const iconSvg = icon && ICONS[icon] ? ICONS[icon] : null;
508
+ return html`
509
+ <div
510
+ class=${classNames("list-item", { "list-item-clickable": !!onClick })}
511
+ onClick=${onClick}
512
+ >
513
+ ${iconSvg ? html`<div class="list-item-icon">${iconSvg}</div>` : null}
514
+ <div class="list-item-body">
515
+ <div class="list-item-title">${title}</div>
516
+ ${subtitle
517
+ ? html`<div class="list-item-subtitle">${subtitle}</div>`
518
+ : null}
519
+ </div>
520
+ ${trailing != null
521
+ ? html`<div class="list-item-trailing">${trailing}</div>`
522
+ : null}
523
+ </div>
524
+ `;
525
+ }