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,190 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * Component: Diff Viewer — VS Code-style diff display
3
+ * ────────────────────────────────────────────────────────────── */
4
+ import { h } from "preact";
5
+ import { useState, useEffect, useCallback } from "preact/hooks";
6
+ import htm from "htm";
7
+ import { apiFetch } from "../modules/api.js";
8
+
9
+ const html = htm.bind(h);
10
+
11
+ /* ─── File type icons ─── */
12
+ const EXT_ICONS = {
13
+ js: "📜", mjs: "📜", cjs: "📜",
14
+ ts: "🔷", tsx: "🔷",
15
+ json: "📋", yaml: "📋", yml: "📋", toml: "📋",
16
+ css: "🎨", scss: "🎨", less: "🎨",
17
+ html: "🌐", htm: "🌐",
18
+ md: "📝", txt: "📄",
19
+ py: "🐍", rb: "💎", go: "🔵", rs: "🦀",
20
+ sh: "🐚", bash: "🐚", ps1: "🐚",
21
+ sql: "🗃️", graphql: "🗃️",
22
+ };
23
+
24
+ function fileIcon(filename) {
25
+ const ext = (filename || "").split(".").pop().toLowerCase();
26
+ return EXT_ICONS[ext] || "📄";
27
+ }
28
+
29
+ /* ─── Parse unified diff into lines ─── */
30
+ function parseDiffLines(rawDiff) {
31
+ if (!rawDiff) return [];
32
+ return rawDiff.split("\n").map((line, i) => {
33
+ let type = "context";
34
+ if (line.startsWith("+") && !line.startsWith("+++")) type = "addition";
35
+ else if (line.startsWith("-") && !line.startsWith("---")) type = "deletion";
36
+ else if (line.startsWith("@@")) type = "hunk-header";
37
+ return { text: line, type, index: i };
38
+ });
39
+ }
40
+
41
+ /* ─── DiffFile component ─── */
42
+ function DiffFile({ file }) {
43
+ const [expanded, setExpanded] = useState(false);
44
+ const lines = parseDiffLines(file.patch || file.diff);
45
+ const additions = file.additions ?? lines.filter((l) => l.type === "addition").length;
46
+ const deletions = file.deletions ?? lines.filter((l) => l.type === "deletion").length;
47
+
48
+ const statusClass = file.status === "added"
49
+ ? "added"
50
+ : file.status === "removed" || file.status === "deleted"
51
+ ? "deleted"
52
+ : "modified";
53
+
54
+ return html`
55
+ <div class="diff-file-item">
56
+ <div
57
+ class="diff-file-header ${statusClass}"
58
+ onClick=${() => setExpanded(!expanded)}
59
+ >
60
+ <span class="diff-file-icon">${fileIcon(file.filename)}</span>
61
+ <span class="diff-file-name">${file.filename}</span>
62
+ <span class="diff-file-stats">
63
+ ${additions > 0 && html`<span class="diff-stat-add">+${additions}</span>`}
64
+ ${deletions > 0 && html`<span class="diff-stat-del">-${deletions}</span>`}
65
+ </span>
66
+ <span class="diff-file-toggle">${expanded ? "▾" : "▸"}</span>
67
+ </div>
68
+ ${expanded && lines.length > 0 && html`
69
+ <div class="diff-hunk">
70
+ ${lines.map(
71
+ (line) => html`
72
+ <div key=${line.index} class="diff-line ${line.type}">
73
+ <span class="diff-line-text">${line.text}</span>
74
+ </div>
75
+ `,
76
+ )}
77
+ </div>
78
+ `}
79
+ ${expanded && lines.length === 0 && html`
80
+ <div class="diff-hunk">
81
+ <div class="diff-line context">
82
+ <span class="diff-line-text">(no diff available)</span>
83
+ </div>
84
+ </div>
85
+ `}
86
+ </div>
87
+ `;
88
+ }
89
+
90
+ /* ─── DiffViewer component ─── */
91
+ export function DiffViewer({ sessionId }) {
92
+ const [diffData, setDiffData] = useState(null);
93
+ const [loading, setLoading] = useState(false);
94
+ const [error, setError] = useState(null);
95
+
96
+ useEffect(() => {
97
+ if (!sessionId) return;
98
+ let active = true;
99
+ setLoading(true);
100
+ setError(null);
101
+
102
+ apiFetch(`/api/sessions/${sessionId}/diff`, { _silent: true })
103
+ .then((res) => {
104
+ if (!active) return;
105
+ setDiffData(res?.diff || null);
106
+ })
107
+ .catch(() => {
108
+ if (active) setError("unavailable");
109
+ })
110
+ .finally(() => {
111
+ if (active) setLoading(false);
112
+ });
113
+
114
+ return () => { active = false; };
115
+ }, [sessionId]);
116
+
117
+ const handleRetry = useCallback(() => {
118
+ setError(null);
119
+ setLoading(true);
120
+ apiFetch(`/api/sessions/${sessionId}/diff`, { _silent: true })
121
+ .then((res) => setDiffData(res?.diff || null))
122
+ .catch(() => setError("unavailable"))
123
+ .finally(() => setLoading(false));
124
+ }, [sessionId]);
125
+
126
+ if (!sessionId) {
127
+ return html`
128
+ <div class="diff-viewer diff-empty">
129
+ <div class="session-empty-icon">📝</div>
130
+ <div class="session-empty-text">Select a session to view diffs</div>
131
+ </div>
132
+ `;
133
+ }
134
+
135
+ if (loading) {
136
+ return html`
137
+ <div class="diff-viewer">
138
+ <div class="diff-loading">Loading diff…</div>
139
+ </div>
140
+ `;
141
+ }
142
+
143
+ if (error) {
144
+ return html`
145
+ <div class="diff-viewer">
146
+ <div class="session-empty">
147
+ <div class="session-empty-icon">📝</div>
148
+ <div class="session-empty-text">Diff not available</div>
149
+ <button class="btn btn-primary btn-sm" onClick=${handleRetry}>
150
+ Retry
151
+ </button>
152
+ </div>
153
+ </div>
154
+ `;
155
+ }
156
+
157
+ const files = diffData?.files || [];
158
+ const totalAdditions = files.reduce(
159
+ (n, f) => n + (f.additions ?? 0),
160
+ 0,
161
+ );
162
+ const totalDeletions = files.reduce(
163
+ (n, f) => n + (f.deletions ?? 0),
164
+ 0,
165
+ );
166
+
167
+ return html`
168
+ <div class="diff-viewer">
169
+ ${files.length > 0 && html`
170
+ <div class="diff-summary">
171
+ <span>${files.length} file${files.length !== 1 ? "s" : ""} changed</span>
172
+ ${totalAdditions > 0 && html`<span class="diff-stat-add">+${totalAdditions}</span>`}
173
+ ${totalDeletions > 0 && html`<span class="diff-stat-del">-${totalDeletions}</span>`}
174
+ </div>
175
+ `}
176
+ <div class="diff-file-list">
177
+ ${files.length > 0
178
+ ? files.map(
179
+ (f) => html`<${DiffFile} key=${f.filename} file=${f} />`,
180
+ )
181
+ : html`
182
+ <div class="session-empty">
183
+ <div class="session-empty-icon">✨</div>
184
+ <div class="session-empty-text">No changes yet</div>
185
+ </div>
186
+ `}
187
+ </div>
188
+ </div>
189
+ `;
190
+ }
@@ -0,0 +1,357 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * VirtEngine Control Center – Form / Control Components
3
+ * SegmentedControl, Collapsible, PullToRefresh, SearchInput,
4
+ * Toggle, Stepper, SliderControl
5
+ * ────────────────────────────────────────────────────────────── */
6
+
7
+ import { h } from "preact";
8
+ import {
9
+ useState,
10
+ useRef,
11
+ useCallback,
12
+ useEffect,
13
+ } from "preact/hooks";
14
+ import htm from "htm";
15
+
16
+ const html = htm.bind(h);
17
+
18
+ import { ICONS } from "../modules/icons.js";
19
+ import { haptic } from "../modules/telegram.js";
20
+
21
+ /* ═══════════════════════════════════════════════
22
+ * SegmentedControl
23
+ * ═══════════════════════════════════════════════ */
24
+
25
+ /**
26
+ * Pill-shaped segmented control.
27
+ * @param {{options: Array<{value: string, label: string}>, value: string, onChange: (v: string) => void}} props
28
+ */
29
+ export function SegmentedControl({ options = [], value, onChange, disabled = false }) {
30
+ return html`
31
+ <div class="segmented-control ${disabled ? "disabled" : ""}">
32
+ ${options.map(
33
+ (opt) => html`
34
+ <button
35
+ key=${opt.value}
36
+ class="segmented-btn ${value === opt.value ? "active" : ""}"
37
+ disabled=${disabled}
38
+ onClick=${() => {
39
+ if (disabled) return;
40
+ haptic("light");
41
+ onChange(opt.value);
42
+ }}
43
+ >
44
+ ${opt.label}
45
+ </button>
46
+ `,
47
+ )}
48
+ </div>
49
+ `;
50
+ }
51
+
52
+ /* ═══════════════════════════════════════════════
53
+ * Collapsible
54
+ * ═══════════════════════════════════════════════ */
55
+
56
+ /**
57
+ * Expandable section with chevron rotation animation.
58
+ * @param {{title: string, defaultOpen?: boolean, children?: any}} props
59
+ */
60
+ export function Collapsible({ title, defaultOpen = true, children }) {
61
+ const [open, setOpen] = useState(defaultOpen);
62
+
63
+ return html`
64
+ <div class="collapsible">
65
+ <button
66
+ class="collapsible-header ${open ? "open" : ""}"
67
+ onClick=${() => {
68
+ haptic("light");
69
+ setOpen(!open);
70
+ }}
71
+ >
72
+ <span class="collapsible-title">${title}</span>
73
+ <span class="collapsible-chevron ${open ? "open" : ""}">
74
+ ${ICONS.chevronDown}
75
+ </span>
76
+ </button>
77
+ <div class="collapsible-body ${open ? "open" : ""}">${children}</div>
78
+ </div>
79
+ `;
80
+ }
81
+
82
+ /* ═══════════════════════════════════════════════
83
+ * PullToRefresh
84
+ * ═══════════════════════════════════════════════ */
85
+
86
+ /**
87
+ * Wraps content with pull-to-refresh gesture detection.
88
+ * Shows a spinner while refreshing.
89
+ * @param {{onRefresh: () => Promise<void>, children?: any}} props
90
+ */
91
+ export function PullToRefresh({ onRefresh, children }) {
92
+ const [refreshing, setRefreshing] = useState(false);
93
+ const [pullDistance, setPullDistance] = useState(0);
94
+ const containerRef = useRef(null);
95
+ const startYRef = useRef(0);
96
+ const pullingRef = useRef(false);
97
+
98
+ // Detect non-touch (desktop) device
99
+ const [hasTouch, setHasTouch] = useState(false);
100
+ useEffect(() => {
101
+ setHasTouch('ontouchstart' in window || navigator.maxTouchPoints > 0);
102
+ }, []);
103
+
104
+ const THRESHOLD = 64;
105
+
106
+ // Desktop refresh handler
107
+ const handleDesktopRefresh = useCallback(async () => {
108
+ if (refreshing) return;
109
+ setRefreshing(true);
110
+ haptic("medium");
111
+ try {
112
+ await onRefresh();
113
+ } finally {
114
+ setRefreshing(false);
115
+ }
116
+ }, [onRefresh, refreshing]);
117
+
118
+ const handleTouchStart = useCallback((e) => {
119
+ if (containerRef.current && containerRef.current.scrollTop <= 0) {
120
+ startYRef.current = e.touches[0].clientY;
121
+ pullingRef.current = true;
122
+ }
123
+ }, []);
124
+
125
+ const handleTouchMove = useCallback((e) => {
126
+ if (!pullingRef.current) return;
127
+ const diff = e.touches[0].clientY - startYRef.current;
128
+ if (diff > 0) {
129
+ // Apply diminishing returns to pull distance
130
+ setPullDistance(Math.min(diff * 0.4, THRESHOLD * 1.5));
131
+ }
132
+ }, []);
133
+
134
+ const handleTouchEnd = useCallback(async () => {
135
+ if (!pullingRef.current) return;
136
+ pullingRef.current = false;
137
+
138
+ if (pullDistance >= THRESHOLD) {
139
+ setRefreshing(true);
140
+ haptic("medium");
141
+ try {
142
+ await onRefresh();
143
+ } finally {
144
+ setRefreshing(false);
145
+ }
146
+ }
147
+ setPullDistance(0);
148
+ }, [onRefresh, pullDistance]);
149
+
150
+ return html`
151
+ <div
152
+ ref=${containerRef}
153
+ class="pull-to-refresh-container"
154
+ onTouchStart=${handleTouchStart}
155
+ onTouchMove=${handleTouchMove}
156
+ onTouchEnd=${handleTouchEnd}
157
+ >
158
+ ${!hasTouch && html`
159
+ <button
160
+ class="ptr-desktop-refresh ${refreshing ? 'spinning' : ''}"
161
+ onClick=${handleDesktopRefresh}
162
+ disabled=${refreshing}
163
+ title="Refresh"
164
+ aria-label="Refresh"
165
+ >
166
+ ${ICONS.refresh}
167
+ </button>
168
+ `}
169
+ ${(refreshing || pullDistance > 0) &&
170
+ html`
171
+ <div
172
+ class="ptr-indicator"
173
+ style="height: ${refreshing ? THRESHOLD : pullDistance}px;
174
+ display:flex;align-items:center;justify-content:center;
175
+ transition: ${pullingRef.current ? "none" : "height 0.2s ease"}"
176
+ >
177
+ <div
178
+ class="ptr-spinner-icon ${refreshing ? "spinning" : ""}"
179
+ style="transform: rotate(${pullDistance * 4}deg);
180
+ opacity: ${Math.min(1, pullDistance / THRESHOLD)}"
181
+ >
182
+ ${ICONS.refresh}
183
+ </div>
184
+ </div>
185
+ `}
186
+ ${children}
187
+ </div>
188
+ `;
189
+ }
190
+
191
+ /* ═══════════════════════════════════════════════
192
+ * SearchInput
193
+ * ═══════════════════════════════════════════════ */
194
+
195
+ /**
196
+ * Search input with magnifying glass icon and clear button.
197
+ * @param {{value: string, onInput: (e: Event) => void, placeholder?: string, onClear?: () => void}} props
198
+ */
199
+ export function SearchInput({
200
+ value = "",
201
+ onInput,
202
+ placeholder = "Search…",
203
+ onClear,
204
+ disabled = false,
205
+ inputRef,
206
+ }) {
207
+ return html`
208
+ <div class="search-input-wrap ${disabled ? "disabled" : ""}">
209
+ <span class="search-input-icon">${ICONS.search}</span>
210
+ <input
211
+ ref=${inputRef}
212
+ class="input search-input"
213
+ type="text"
214
+ placeholder=${placeholder}
215
+ value=${value}
216
+ onInput=${onInput}
217
+ disabled=${disabled}
218
+ />
219
+ ${value && !disabled
220
+ ? html`
221
+ <button
222
+ class="search-input-clear"
223
+ onClick=${() => {
224
+ if (onClear) onClear();
225
+ }}
226
+ >
227
+ ${ICONS.close}
228
+ </button>
229
+ `
230
+ : null}
231
+ </div>
232
+ `;
233
+ }
234
+
235
+ /* ═══════════════════════════════════════════════
236
+ * Toggle
237
+ * ═══════════════════════════════════════════════ */
238
+
239
+ /**
240
+ * iOS-style toggle switch.
241
+ * @param {{checked: boolean, onChange: (checked: boolean) => void, label?: string}} props
242
+ */
243
+ export function Toggle({ checked = false, onChange, label, disabled = false }) {
244
+ const handleClick = () => {
245
+ if (disabled) return;
246
+ haptic("light");
247
+ onChange(!checked);
248
+ };
249
+
250
+ return html`
251
+ <div class="toggle-wrap ${disabled ? "disabled" : ""}" onClick=${handleClick}>
252
+ ${label ? html`<span class="toggle-label">${label}</span>` : null}
253
+ <div class="toggle ${checked ? "toggle-on" : ""} ${disabled ? "disabled" : ""}">
254
+ <div class="toggle-thumb"></div>
255
+ </div>
256
+ </div>
257
+ `;
258
+ }
259
+
260
+ /* ═══════════════════════════════════════════════
261
+ * Stepper
262
+ * ═══════════════════════════════════════════════ */
263
+
264
+ /**
265
+ * Numeric stepper with − and + buttons.
266
+ * @param {{value: number, min?: number, max?: number, step?: number, onChange: (v: number) => void, label?: string}} props
267
+ */
268
+ export function Stepper({
269
+ value = 0,
270
+ min = 0,
271
+ max = 100,
272
+ step = 1,
273
+ onChange,
274
+ label,
275
+ disabled = false,
276
+ }) {
277
+ const decrement = () => {
278
+ if (disabled) return;
279
+ const next = Math.max(min, value - step);
280
+ if (next !== value) {
281
+ haptic("light");
282
+ onChange(next);
283
+ }
284
+ };
285
+ const increment = () => {
286
+ if (disabled) return;
287
+ const next = Math.min(max, value + step);
288
+ if (next !== value) {
289
+ haptic("light");
290
+ onChange(next);
291
+ }
292
+ };
293
+
294
+ return html`
295
+ <div class="stepper-wrap ${disabled ? "disabled" : ""}">
296
+ ${label ? html`<span class="stepper-label">${label}</span>` : null}
297
+ <div class="stepper ${disabled ? "disabled" : ""}">
298
+ <button
299
+ class="stepper-btn"
300
+ onClick=${decrement}
301
+ disabled=${disabled || value <= min}
302
+ >
303
+
304
+ </button>
305
+ <span class="stepper-value">${value}</span>
306
+ <button
307
+ class="stepper-btn"
308
+ onClick=${increment}
309
+ disabled=${disabled || value >= max}
310
+ >
311
+ +
312
+ </button>
313
+ </div>
314
+ </div>
315
+ `;
316
+ }
317
+
318
+ /* ═══════════════════════════════════════════════
319
+ * SliderControl
320
+ * ═══════════════════════════════════════════════ */
321
+
322
+ /**
323
+ * Range slider with value display pill.
324
+ * @param {{value: number, min?: number, max?: number, step?: number, onChange: (v: number) => void, label?: string, suffix?: string}} props
325
+ */
326
+ export function SliderControl({
327
+ value = 0,
328
+ min = 0,
329
+ max = 100,
330
+ step = 1,
331
+ onChange,
332
+ label,
333
+ suffix = "",
334
+ }) {
335
+ return html`
336
+ <div class="slider-control">
337
+ ${label
338
+ ? html`<div class="slider-control-header">
339
+ <span class="slider-control-label">${label}</span>
340
+ <span class="pill">${value}${suffix}</span>
341
+ </div>`
342
+ : null}
343
+ <div class="slider-control-row">
344
+ <input
345
+ type="range"
346
+ class="slider-input"
347
+ min=${min}
348
+ max=${max}
349
+ step=${step}
350
+ value=${value}
351
+ onInput=${(e) => onChange(Number(e.target.value))}
352
+ />
353
+ ${!label ? html`<span class="pill">${value}${suffix}</span>` : null}
354
+ </div>
355
+ </div>
356
+ `;
357
+ }