@devosurf/vynt 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1075 @@
1
+ (() => {
2
+ if (window.__vyntToolbarMounted) return;
3
+ window.__vyntToolbarMounted = true;
4
+
5
+ const currentScript = document.currentScript;
6
+ const scriptSrc = currentScript && currentScript.src ? currentScript.src : "";
7
+ const scriptUrl = scriptSrc ? new URL(scriptSrc) : null;
8
+ const configuredBase =
9
+ (currentScript && currentScript.getAttribute("data-vynt-bridge")) ||
10
+ (scriptUrl ? scriptUrl.origin : "http://127.0.0.1:4173");
11
+ const baseUrl = configuredBase.replace(/\/+$/, "");
12
+
13
+ const STORAGE_KEY = "vynt-toolbar-position-v2";
14
+ const DRAG_THRESHOLD = 3;
15
+ const VIEWPORT_PADDING = 24;
16
+ const OBJECTIVE_OVERLAY_PADDING = 6;
17
+
18
+ const state = {
19
+ busy: false,
20
+ collapsed: false,
21
+ initialized: false,
22
+ options: [],
23
+ optionsByObjective: {},
24
+ selectedKeyByObjective: {},
25
+ selectedKey: null,
26
+ hoveredObjectiveId: null,
27
+ hoveredObjectiveElement: null,
28
+ statusText: "Connecting...",
29
+ position: null,
30
+ };
31
+
32
+ let eventSource = null;
33
+ let dragStart = null;
34
+ let isDragging = false;
35
+
36
+ const icons = {
37
+ grip: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.5 5V6.5H4V5H5.5ZM5.5 7.5V9H4V7.5H5.5ZM5.5 10V11.5H4V10H5.5ZM12 5V6.5H10.5V5H12ZM12 7.5V9H10.5V7.5H12ZM12 10V11.5H10.5V10H12Z" fill="currentColor"/></svg>`,
38
+ prev: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>`,
39
+ next: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
40
+ apply: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
41
+ collapse: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
42
+ vynt: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>`
43
+ };
44
+
45
+ const css = `
46
+ [data-vynt-toolbar] {
47
+ box-sizing: border-box;
48
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
49
+ --vynt-bg: rgba(0, 0, 0, 0.65);
50
+ --vynt-border: rgba(255, 255, 255, 0.12);
51
+ --vynt-text: #ededed;
52
+ --vynt-text-dim: #a1a1a1;
53
+ --vynt-hover: rgba(255, 255, 255, 0.1);
54
+ --vynt-active: rgba(255, 255, 255, 0.15);
55
+ --vynt-radius: 12px;
56
+ --vynt-shadow: 0 0 0 1px var(--vynt-border), 0 8px 30px rgba(0, 0, 0, 0.24);
57
+ --vynt-height: 48px;
58
+ }
59
+
60
+ @media (prefers-color-scheme: light) {
61
+ [data-vynt-toolbar] {
62
+ --vynt-bg: rgba(255, 255, 255, 0.85);
63
+ --vynt-border: rgba(0, 0, 0, 0.1);
64
+ --vynt-text: #171717;
65
+ --vynt-text-dim: #666666;
66
+ --vynt-hover: rgba(0, 0, 0, 0.05);
67
+ --vynt-active: rgba(0, 0, 0, 0.08);
68
+ --vynt-shadow: 0 0 0 1px var(--vynt-border), 0 8px 30px rgba(0, 0, 0, 0.12);
69
+ }
70
+ }
71
+
72
+ #vynt-toolbar-root {
73
+ position: fixed;
74
+ z-index: 2147483647;
75
+ right: 24px;
76
+ bottom: 24px;
77
+ display: flex;
78
+ flex-direction: column;
79
+ align-items: center;
80
+ gap: 8px;
81
+ touch-action: none;
82
+ }
83
+
84
+ .vynt-shell {
85
+ height: var(--vynt-height);
86
+ border-radius: var(--vynt-radius);
87
+ background: var(--vynt-bg);
88
+ backdrop-filter: blur(12px);
89
+ -webkit-backdrop-filter: blur(12px);
90
+ box-shadow: var(--vynt-shadow);
91
+ display: flex;
92
+ align-items: center;
93
+ padding: 0 6px;
94
+ pointer-events: auto;
95
+ user-select: none;
96
+ transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), width 0.3s ease;
97
+ overflow: hidden;
98
+ will-change: transform;
99
+ }
100
+
101
+ .vynt-shell.vynt-dragging {
102
+ transform: scale(1.02);
103
+ transition: transform 0.1s ease;
104
+ cursor: grabbing !important;
105
+ }
106
+
107
+ .vynt-shell.vynt-collapsed {
108
+ width: var(--vynt-height);
109
+ padding: 0;
110
+ justify-content: center;
111
+ }
112
+
113
+ .vynt-shell.vynt-empty {
114
+ width: var(--vynt-height);
115
+ padding: 0;
116
+ justify-content: center;
117
+ }
118
+
119
+ .vynt-drag-handle {
120
+ display: flex;
121
+ align-items: center;
122
+ justify-content: center;
123
+ width: 32px;
124
+ height: 32px;
125
+ border-radius: 6px;
126
+ color: var(--vynt-text-dim);
127
+ cursor: grab;
128
+ flex-shrink: 0;
129
+ transition: background-color 0.15s ease, color 0.15s ease;
130
+ }
131
+
132
+ .vynt-drag-handle:hover {
133
+ background: var(--vynt-hover);
134
+ color: var(--vynt-text);
135
+ }
136
+
137
+ .vynt-drag-handle:active {
138
+ cursor: grabbing;
139
+ }
140
+
141
+ .vynt-divider {
142
+ width: 1px;
143
+ height: 24px;
144
+ background: var(--vynt-border);
145
+ margin: 0 4px;
146
+ }
147
+
148
+ .vynt-controls {
149
+ display: flex;
150
+ align-items: center;
151
+ gap: 4px;
152
+ opacity: 1;
153
+ transition: opacity 0.2s ease;
154
+ white-space: nowrap;
155
+ }
156
+
157
+ .vynt-shell.vynt-collapsed .vynt-controls,
158
+ .vynt-shell.vynt-collapsed .vynt-drag-handle,
159
+ .vynt-shell.vynt-collapsed .vynt-divider {
160
+ display: none;
161
+ }
162
+
163
+ .vynt-shell.vynt-empty .vynt-controls,
164
+ .vynt-shell.vynt-empty .vynt-drag-handle,
165
+ .vynt-shell.vynt-empty .vynt-divider {
166
+ display: none;
167
+ }
168
+
169
+ .vynt-btn {
170
+ display: flex;
171
+ align-items: center;
172
+ justify-content: center;
173
+ width: 32px;
174
+ height: 32px;
175
+ border-radius: 6px;
176
+ border: none;
177
+ background: transparent;
178
+ color: var(--vynt-text);
179
+ cursor: pointer;
180
+ transition: all 0.15s ease;
181
+ flex-shrink: 0;
182
+ padding: 0;
183
+ }
184
+
185
+ .vynt-btn:hover:not(:disabled) {
186
+ background: var(--vynt-hover);
187
+ }
188
+
189
+ .vynt-btn:active:not(:disabled) {
190
+ background: var(--vynt-active);
191
+ transform: scale(0.96);
192
+ }
193
+
194
+ .vynt-btn:disabled, .vynt-select-wrapper.vynt-disabled {
195
+ opacity: 0.4;
196
+ cursor: not-allowed;
197
+ pointer-events: none;
198
+ }
199
+
200
+ .vynt-select-wrapper {
201
+ position: relative;
202
+ display: flex;
203
+ align-items: center;
204
+ height: 32px;
205
+ border-radius: 6px;
206
+ background: transparent;
207
+ transition: background 0.15s ease;
208
+ padding: 0 4px;
209
+ }
210
+
211
+ .vynt-select-wrapper:hover:not(.vynt-disabled) {
212
+ background: var(--vynt-hover);
213
+ }
214
+
215
+ .vynt-select {
216
+ appearance: none;
217
+ -webkit-appearance: none;
218
+ background: transparent;
219
+ border: none;
220
+ color: var(--vynt-text);
221
+ font-family: inherit;
222
+ font-size: 13px;
223
+ font-weight: 500;
224
+ padding: 0 24px 0 8px;
225
+ height: 100%;
226
+ width: 100%;
227
+ cursor: pointer;
228
+ outline: none;
229
+ text-overflow: ellipsis;
230
+ max-width: 180px;
231
+ }
232
+
233
+ .vynt-select option {
234
+ color: #171717;
235
+ background: #fff;
236
+ }
237
+
238
+ .vynt-select-icon {
239
+ position: absolute;
240
+ right: 8px;
241
+ pointer-events: none;
242
+ color: var(--vynt-text-dim);
243
+ display: flex;
244
+ }
245
+
246
+ .vynt-status {
247
+ font-size: 11px;
248
+ font-weight: 500;
249
+ color: var(--vynt-text-dim);
250
+ letter-spacing: 0.2px;
251
+ text-transform: uppercase;
252
+ background: var(--vynt-bg);
253
+ padding: 4px 10px;
254
+ border-radius: 999px;
255
+ box-shadow: var(--vynt-shadow);
256
+ backdrop-filter: blur(12px);
257
+ -webkit-backdrop-filter: blur(12px);
258
+ opacity: 0;
259
+ transform: translateY(4px);
260
+ transition: opacity 0.3s ease, transform 0.3s ease;
261
+ pointer-events: none;
262
+ }
263
+
264
+ .vynt-status.vynt-show {
265
+ opacity: 1;
266
+ transform: translateY(0);
267
+ }
268
+
269
+ .vynt-logo-btn {
270
+ display: none;
271
+ align-items: center;
272
+ justify-content: center;
273
+ width: 100%;
274
+ height: 100%;
275
+ border: none;
276
+ background: transparent;
277
+ color: var(--vynt-text);
278
+ cursor: pointer;
279
+ border-radius: var(--vynt-radius);
280
+ padding: 0;
281
+ }
282
+
283
+ .vynt-shell.vynt-collapsed .vynt-logo-btn {
284
+ display: flex;
285
+ }
286
+
287
+ .vynt-shell.vynt-empty .vynt-logo-btn {
288
+ display: flex;
289
+ }
290
+
291
+ #vynt-objective-overlay {
292
+ position: fixed;
293
+ left: 0;
294
+ top: 0;
295
+ width: 0;
296
+ height: 0;
297
+ z-index: 2147483646;
298
+ pointer-events: none;
299
+ display: none;
300
+ }
301
+
302
+ .vynt-objective-frame {
303
+ position: absolute;
304
+ inset: 0;
305
+ border-radius: 12px;
306
+ border: 2px solid rgba(34, 211, 238, 0.85);
307
+ box-shadow: 0 0 0 1px rgba(34, 211, 238, 0.3), 0 10px 30px rgba(8, 145, 178, 0.2);
308
+ background: rgba(34, 211, 238, 0.08);
309
+ }
310
+
311
+ .vynt-objective-panel {
312
+ position: absolute;
313
+ left: 50%;
314
+ bottom: -50px;
315
+ transform: translateX(-50%);
316
+ height: 40px;
317
+ border-radius: 999px;
318
+ background: var(--vynt-bg);
319
+ box-shadow: var(--vynt-shadow);
320
+ backdrop-filter: blur(12px);
321
+ -webkit-backdrop-filter: blur(12px);
322
+ display: flex;
323
+ align-items: center;
324
+ gap: 4px;
325
+ padding: 4px;
326
+ pointer-events: auto;
327
+ }
328
+
329
+ .vynt-objective-panel .vynt-btn {
330
+ width: 30px;
331
+ height: 30px;
332
+ }
333
+
334
+ .vynt-objective-select-wrapper {
335
+ position: relative;
336
+ display: flex;
337
+ align-items: center;
338
+ height: 30px;
339
+ border-radius: 999px;
340
+ padding: 0 4px;
341
+ }
342
+
343
+ .vynt-objective-select {
344
+ appearance: none;
345
+ -webkit-appearance: none;
346
+ background: transparent;
347
+ border: none;
348
+ color: var(--vynt-text);
349
+ font-family: inherit;
350
+ font-size: 12px;
351
+ font-weight: 600;
352
+ padding: 0 20px 0 8px;
353
+ height: 100%;
354
+ max-width: 220px;
355
+ cursor: pointer;
356
+ outline: none;
357
+ }
358
+
359
+ .vynt-objective-label {
360
+ position: absolute;
361
+ top: -20px;
362
+ left: 8px;
363
+ font-size: 11px;
364
+ font-weight: 700;
365
+ letter-spacing: 0.3px;
366
+ color: rgba(255, 255, 255, 0.9);
367
+ text-transform: uppercase;
368
+ }
369
+ `;
370
+
371
+ const style = document.createElement("style");
372
+ style.setAttribute("data-vynt-toolbar", "style");
373
+ style.textContent = css;
374
+ document.head.appendChild(style);
375
+
376
+ const root = document.createElement("div");
377
+ root.id = "vynt-toolbar-root";
378
+ root.setAttribute("data-vynt-toolbar", "root");
379
+
380
+ const shell = document.createElement("div");
381
+ shell.className = "vynt-shell";
382
+
383
+ const dragHandle = document.createElement("div");
384
+ dragHandle.className = "vynt-drag-handle";
385
+ dragHandle.innerHTML = icons.grip;
386
+
387
+ const divider1 = document.createElement("div");
388
+ divider1.className = "vynt-divider";
389
+
390
+ const controls = document.createElement("div");
391
+ controls.className = "vynt-controls";
392
+
393
+ const prevBtn = document.createElement("button");
394
+ prevBtn.className = "vynt-btn";
395
+ prevBtn.innerHTML = icons.prev;
396
+ prevBtn.title = "Previous variant";
397
+
398
+ const selectWrapper = document.createElement("div");
399
+ selectWrapper.className = "vynt-select-wrapper";
400
+
401
+ const variantSelect = document.createElement("select");
402
+ variantSelect.className = "vynt-select";
403
+ variantSelect.setAttribute("data-vynt-toolbar", "variant");
404
+
405
+ const selectIcon = document.createElement("div");
406
+ selectIcon.className = "vynt-select-icon";
407
+ selectIcon.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`;
408
+
409
+ selectWrapper.appendChild(variantSelect);
410
+ selectWrapper.appendChild(selectIcon);
411
+
412
+ const nextBtn = document.createElement("button");
413
+ nextBtn.className = "vynt-btn";
414
+ nextBtn.innerHTML = icons.next;
415
+ nextBtn.title = "Next variant";
416
+
417
+ const applyBtn = document.createElement("button");
418
+ applyBtn.className = "vynt-btn";
419
+ applyBtn.innerHTML = icons.apply;
420
+ applyBtn.title = "Finalize selected variant";
421
+
422
+ const divider2 = document.createElement("div");
423
+ divider2.className = "vynt-divider";
424
+
425
+ const collapseBtn = document.createElement("button");
426
+ collapseBtn.className = "vynt-btn";
427
+ collapseBtn.innerHTML = icons.collapse;
428
+ collapseBtn.title = "Hide toolbar";
429
+
430
+ const logoBtn = document.createElement("button");
431
+ logoBtn.className = "vynt-logo-btn";
432
+ logoBtn.innerHTML = icons.vynt;
433
+ logoBtn.title = "Show toolbar";
434
+
435
+ const statusNode = document.createElement("div");
436
+ statusNode.className = "vynt-status";
437
+ statusNode.textContent = state.statusText;
438
+
439
+ controls.appendChild(prevBtn);
440
+ controls.appendChild(selectWrapper);
441
+ controls.appendChild(nextBtn);
442
+ controls.appendChild(applyBtn);
443
+ controls.appendChild(divider2);
444
+ controls.appendChild(collapseBtn);
445
+
446
+ shell.appendChild(dragHandle);
447
+ shell.appendChild(divider1);
448
+ shell.appendChild(controls);
449
+ shell.appendChild(logoBtn);
450
+
451
+ root.appendChild(shell);
452
+ root.appendChild(statusNode);
453
+ document.body.appendChild(root);
454
+
455
+ const objectiveOverlayRoot = document.createElement("div");
456
+ objectiveOverlayRoot.id = "vynt-objective-overlay";
457
+ objectiveOverlayRoot.setAttribute("data-vynt-toolbar-objective", "root");
458
+
459
+ const objectiveFrame = document.createElement("div");
460
+ objectiveFrame.className = "vynt-objective-frame";
461
+
462
+ const objectivePanel = document.createElement("div");
463
+ objectivePanel.className = "vynt-objective-panel";
464
+
465
+ const objectiveLabel = document.createElement("div");
466
+ objectiveLabel.className = "vynt-objective-label";
467
+
468
+ const objectivePrevBtn = document.createElement("button");
469
+ objectivePrevBtn.className = "vynt-btn";
470
+ objectivePrevBtn.innerHTML = icons.prev;
471
+ objectivePrevBtn.title = "Previous objective variant";
472
+
473
+ const objectiveSelectWrapper = document.createElement("div");
474
+ objectiveSelectWrapper.className = "vynt-objective-select-wrapper";
475
+
476
+ const objectiveSelect = document.createElement("select");
477
+ objectiveSelect.className = "vynt-objective-select";
478
+
479
+ const objectiveNextBtn = document.createElement("button");
480
+ objectiveNextBtn.className = "vynt-btn";
481
+ objectiveNextBtn.innerHTML = icons.next;
482
+ objectiveNextBtn.title = "Next objective variant";
483
+
484
+ objectiveSelectWrapper.appendChild(objectiveSelect);
485
+ objectivePanel.appendChild(objectivePrevBtn);
486
+ objectivePanel.appendChild(objectiveSelectWrapper);
487
+ objectivePanel.appendChild(objectiveNextBtn);
488
+ objectiveOverlayRoot.appendChild(objectiveFrame);
489
+ objectiveOverlayRoot.appendChild(objectivePanel);
490
+ objectiveOverlayRoot.appendChild(objectiveLabel);
491
+ document.body.appendChild(objectiveOverlayRoot);
492
+
493
+ let statusTimeout = null;
494
+ function setStatus(text, duration = 3000) {
495
+ state.statusText = text;
496
+ statusNode.textContent = text;
497
+
498
+ if (text) {
499
+ statusNode.classList.add("vynt-show");
500
+ clearTimeout(statusTimeout);
501
+ if (duration > 0) {
502
+ statusTimeout = setTimeout(() => {
503
+ statusNode.classList.remove("vynt-show");
504
+ }, duration);
505
+ }
506
+ } else {
507
+ statusNode.classList.remove("vynt-show");
508
+ }
509
+ }
510
+
511
+ function setBusy(nextBusy) {
512
+ state.busy = nextBusy;
513
+ render();
514
+ }
515
+
516
+ function getSelectedIndex() {
517
+ return state.options.findIndex((item) => item.key === state.selectedKey);
518
+ }
519
+
520
+ function parseSelectionKey(key) {
521
+ if (!key) return null;
522
+ const [objectiveId, variantId] = String(key).split("::");
523
+ if (!objectiveId || !variantId) return null;
524
+ return { objectiveId, variantId };
525
+ }
526
+
527
+ function getObjectiveOptions(objectiveId) {
528
+ return state.optionsByObjective[objectiveId] || [];
529
+ }
530
+
531
+ function getObjectiveSelectedKey(objectiveId) {
532
+ const options = getObjectiveOptions(objectiveId);
533
+ if (options.length === 0) return null;
534
+ const selected = state.selectedKeyByObjective[objectiveId];
535
+ if (selected && options.some((option) => option.key === selected)) {
536
+ return selected;
537
+ }
538
+ return options[0].key;
539
+ }
540
+
541
+ function getObjectiveSelectedIndex(objectiveId) {
542
+ const selectedKey = getObjectiveSelectedKey(objectiveId);
543
+ const options = getObjectiveOptions(objectiveId);
544
+ if (!selectedKey) return -1;
545
+ return options.findIndex((item) => item.key === selectedKey);
546
+ }
547
+
548
+ function hideObjectiveOverlay() {
549
+ state.hoveredObjectiveId = null;
550
+ state.hoveredObjectiveElement = null;
551
+ objectiveOverlayRoot.style.display = "none";
552
+ }
553
+
554
+ function refreshObjectiveOverlayPosition() {
555
+ const objectiveId = state.hoveredObjectiveId;
556
+ const objectiveElement = state.hoveredObjectiveElement;
557
+ if (!objectiveId || !objectiveElement || !objectiveElement.isConnected) {
558
+ hideObjectiveOverlay();
559
+ return;
560
+ }
561
+
562
+ const rect = objectiveElement.getBoundingClientRect();
563
+ if (rect.width < 2 || rect.height < 2) {
564
+ hideObjectiveOverlay();
565
+ return;
566
+ }
567
+
568
+ const x = Math.max(0, rect.left - OBJECTIVE_OVERLAY_PADDING);
569
+ const y = Math.max(0, rect.top - OBJECTIVE_OVERLAY_PADDING);
570
+ const width = Math.min(window.innerWidth - x, rect.width + OBJECTIVE_OVERLAY_PADDING * 2);
571
+ const height = Math.min(window.innerHeight - y, rect.height + OBJECTIVE_OVERLAY_PADDING * 2);
572
+
573
+ objectiveOverlayRoot.style.display = "block";
574
+ objectiveOverlayRoot.style.left = `${x}px`;
575
+ objectiveOverlayRoot.style.top = `${y}px`;
576
+ objectiveOverlayRoot.style.width = `${Math.max(0, width)}px`;
577
+ objectiveOverlayRoot.style.height = `${Math.max(0, height)}px`;
578
+
579
+ const panelBottom = -50;
580
+ if (y + height + panelBottom + 48 > window.innerHeight) {
581
+ objectivePanel.style.bottom = "auto";
582
+ objectivePanel.style.top = "-50px";
583
+ } else {
584
+ objectivePanel.style.top = "auto";
585
+ objectivePanel.style.bottom = "-50px";
586
+ }
587
+ }
588
+
589
+ function renderObjectiveOverlay() {
590
+ const objectiveId = state.hoveredObjectiveId;
591
+ if (!objectiveId) {
592
+ objectiveOverlayRoot.style.display = "none";
593
+ return;
594
+ }
595
+
596
+ const options = getObjectiveOptions(objectiveId);
597
+ if (options.length === 0 || !state.initialized) {
598
+ objectiveOverlayRoot.style.display = "none";
599
+ return;
600
+ }
601
+
602
+ const selectedKey = getObjectiveSelectedKey(objectiveId);
603
+ state.selectedKeyByObjective[objectiveId] = selectedKey;
604
+
605
+ objectiveLabel.textContent = objectiveId;
606
+ objectiveSelect.innerHTML = "";
607
+ for (const optionData of options) {
608
+ const option = document.createElement("option");
609
+ option.value = optionData.key;
610
+ option.textContent = optionData.label;
611
+ objectiveSelect.appendChild(option);
612
+ }
613
+ objectiveSelect.value = selectedKey;
614
+
615
+ const selectedIndex = getObjectiveSelectedIndex(objectiveId);
616
+ const disabled = state.busy || !state.initialized || options.length === 0;
617
+ objectivePrevBtn.disabled = disabled || selectedIndex <= 0;
618
+ objectiveNextBtn.disabled = disabled || selectedIndex >= options.length - 1;
619
+ objectiveSelect.disabled = disabled;
620
+
621
+ refreshObjectiveOverlayPosition();
622
+ }
623
+
624
+ function handleDocumentHover(event) {
625
+ if (dragStart || isDragging) return;
626
+ const target = event.target;
627
+ if (!(target instanceof Element)) return;
628
+ if (target.closest("#vynt-toolbar-root")) return;
629
+ if (target.closest("#vynt-objective-overlay")) return;
630
+
631
+ const candidate = target.closest("[data-vynt-objective]");
632
+ if (!candidate) {
633
+ hideObjectiveOverlay();
634
+ return;
635
+ }
636
+
637
+ const objectiveIdRaw = candidate.getAttribute("data-vynt-objective");
638
+ const objectiveId = objectiveIdRaw ? objectiveIdRaw.trim() : "";
639
+ if (!objectiveId) {
640
+ hideObjectiveOverlay();
641
+ return;
642
+ }
643
+
644
+ state.hoveredObjectiveId = objectiveId;
645
+ state.hoveredObjectiveElement = candidate;
646
+ renderObjectiveOverlay();
647
+ }
648
+
649
+ function constrainPosition(position) {
650
+ const rect = shell.getBoundingClientRect();
651
+ const width = rect.width || 300;
652
+ const height = rect.height || 48;
653
+ const maxX = window.innerWidth - width - VIEWPORT_PADDING;
654
+ const maxY = window.innerHeight - height - VIEWPORT_PADDING;
655
+ return {
656
+ x: Math.max(VIEWPORT_PADDING, Math.min(maxX, position.x)),
657
+ y: Math.max(VIEWPORT_PADDING, Math.min(maxY, position.y)),
658
+ };
659
+ }
660
+
661
+ function updateRootPosition() {
662
+ if (!state.position) {
663
+ root.style.removeProperty("left");
664
+ root.style.removeProperty("top");
665
+ root.style.right = "24px";
666
+ root.style.bottom = "24px";
667
+ return;
668
+ }
669
+ const next = constrainPosition(state.position);
670
+ state.position = next;
671
+ root.style.left = `${next.x}px`;
672
+ root.style.top = `${next.y}px`;
673
+ root.style.right = "auto";
674
+ root.style.bottom = "auto";
675
+ }
676
+
677
+ function persistPosition() {
678
+ if (!state.position) return;
679
+ try {
680
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state.position));
681
+ } catch {}
682
+ }
683
+
684
+ function loadPosition() {
685
+ try {
686
+ const raw = localStorage.getItem(STORAGE_KEY);
687
+ if (!raw) return;
688
+ const parsed = JSON.parse(raw);
689
+ if (typeof parsed?.x === "number" && typeof parsed?.y === "number") {
690
+ state.position = parsed;
691
+ }
692
+ } catch {}
693
+ }
694
+
695
+ function syncSelectionFromState(payload) {
696
+ const objectives = (payload && payload.state && payload.state.objectives) || [];
697
+ const options = [];
698
+ const optionsByObjective = {};
699
+ const selectedByObjective = {};
700
+ let activeKey = null;
701
+
702
+ for (const objective of objectives) {
703
+ const objectiveOptions = [];
704
+ const variants = Array.isArray(objective.variants) ? objective.variants : [];
705
+ for (const variant of variants) {
706
+ const key = `${objective.id}::${variant.id}`;
707
+ const option = {
708
+ key,
709
+ label: variant.name,
710
+ objectiveId: objective.id,
711
+ variantId: variant.id,
712
+ };
713
+ options.push({ ...option, label: `${objective.id} / ${variant.name}` });
714
+ objectiveOptions.push(option);
715
+ if (objective.activeVariantId && objective.activeVariantId === variant.id) {
716
+ activeKey = key;
717
+ selectedByObjective[objective.id] = key;
718
+ }
719
+ }
720
+ optionsByObjective[objective.id] = objectiveOptions;
721
+ if (!selectedByObjective[objective.id] && objectiveOptions.length > 0) {
722
+ selectedByObjective[objective.id] = objectiveOptions[0].key;
723
+ }
724
+ }
725
+
726
+ state.options = options;
727
+ state.optionsByObjective = optionsByObjective;
728
+ state.selectedKeyByObjective = selectedByObjective;
729
+ const hasCurrent = options.some((option) => option.key === state.selectedKey);
730
+ if (!hasCurrent) {
731
+ state.selectedKey = activeKey || (options[0] && options[0].key) || null;
732
+ }
733
+ }
734
+
735
+ function render() {
736
+ updateRootPosition();
737
+ const hasOptions = state.options.length > 0;
738
+
739
+ shell.classList.toggle("vynt-collapsed", state.collapsed);
740
+ shell.classList.toggle("vynt-empty", !hasOptions);
741
+ shell.classList.toggle("vynt-dragging", isDragging);
742
+
743
+ variantSelect.innerHTML = "";
744
+ if (state.options.length === 0) {
745
+ const option = document.createElement("option");
746
+ option.value = "";
747
+ option.textContent = "No variants";
748
+ variantSelect.appendChild(option);
749
+ } else {
750
+ for (const optionData of state.options) {
751
+ const option = document.createElement("option");
752
+ option.value = optionData.key;
753
+ option.textContent = optionData.label;
754
+ variantSelect.appendChild(option);
755
+ }
756
+ }
757
+
758
+ if (state.selectedKey) {
759
+ variantSelect.value = state.selectedKey;
760
+ }
761
+
762
+ const index = getSelectedIndex();
763
+ const disabled = state.busy || !state.initialized || !hasOptions;
764
+
765
+ prevBtn.disabled = disabled || index <= 0;
766
+ nextBtn.disabled = disabled || index >= state.options.length - 1;
767
+ applyBtn.disabled = disabled;
768
+ variantSelect.disabled = disabled;
769
+
770
+ if (disabled) {
771
+ selectWrapper.classList.add("vynt-disabled");
772
+ } else {
773
+ selectWrapper.classList.remove("vynt-disabled");
774
+ }
775
+
776
+ renderObjectiveOverlay();
777
+ }
778
+
779
+ async function api(path, init) {
780
+ const response = await fetch(`${baseUrl}${path}`, {
781
+ headers: {
782
+ "Content-Type": "application/json",
783
+ },
784
+ ...init,
785
+ });
786
+
787
+ const payload = await response.json().catch(() => ({}));
788
+ if (!response.ok) {
789
+ throw new Error(payload.error || `HTTP ${response.status}`);
790
+ }
791
+ return payload;
792
+ }
793
+
794
+ async function refreshStatus(label, duration) {
795
+ if (label) setStatus(label, duration);
796
+ try {
797
+ const payload = await api("/status", { method: "GET" });
798
+ state.initialized = !!payload.initialized;
799
+
800
+ if (!state.initialized) {
801
+ state.options = [];
802
+ state.selectedKey = null;
803
+ setStatus("Run vynt init first", 0);
804
+ render();
805
+ return;
806
+ }
807
+
808
+ syncSelectionFromState(payload);
809
+ if (state.options.length === 0) {
810
+ setStatus("No variants", 0);
811
+ } else if (label === "Loading...") {
812
+ setStatus("Ready", 2000);
813
+ }
814
+ render();
815
+ } catch (e) {
816
+ setStatus("Connection error", 0);
817
+ state.initialized = false;
818
+ render();
819
+ }
820
+ }
821
+
822
+ async function applySelectionKey(selectionKey) {
823
+ const selected = parseSelectionKey(selectionKey);
824
+ if (!selected) return;
825
+
826
+ setBusy(true);
827
+ setStatus(`Applying ${selected.variantId}...`, 0);
828
+
829
+ try {
830
+ await api("/apply", {
831
+ method: "POST",
832
+ body: JSON.stringify({
833
+ objectiveId: selected.objectiveId,
834
+ variantId: selected.variantId,
835
+ }),
836
+ });
837
+ state.selectedKeyByObjective[selected.objectiveId] = selectionKey;
838
+ await refreshStatus("Applied", 2000);
839
+ } catch (error) {
840
+ setStatus(error instanceof Error ? error.message : String(error), 5000);
841
+ } finally {
842
+ setBusy(false);
843
+ }
844
+ }
845
+
846
+ async function finalizeSelectionKey(selectionKey) {
847
+ const selected = parseSelectionKey(selectionKey);
848
+ if (!selected) return;
849
+
850
+ setBusy(true);
851
+ setStatus(`Finalizing ${selected.variantId}...`, 0);
852
+
853
+ try {
854
+ await api("/finalize", {
855
+ method: "POST",
856
+ body: JSON.stringify({
857
+ objectiveId: selected.objectiveId,
858
+ variantId: selected.variantId,
859
+ }),
860
+ });
861
+ state.selectedKeyByObjective[selected.objectiveId] = selectionKey;
862
+ await refreshStatus("Finalized winner", 2500);
863
+ } catch (error) {
864
+ setStatus(error instanceof Error ? error.message : String(error), 5000);
865
+ } finally {
866
+ setBusy(false);
867
+ }
868
+ }
869
+
870
+ async function finalizeSelected() {
871
+ await finalizeSelectionKey(state.selectedKey);
872
+ }
873
+
874
+ async function moveBy(delta) {
875
+ const total = state.options.length;
876
+ if (total === 0) return;
877
+ const current = getSelectedIndex();
878
+ const next = Math.max(0, Math.min(total - 1, current + delta));
879
+ if (next === current) return;
880
+ state.selectedKey = state.options[next].key;
881
+ render();
882
+ await applySelectionKey(state.selectedKey);
883
+ }
884
+
885
+ async function moveObjectiveBy(delta) {
886
+ const objectiveId = state.hoveredObjectiveId;
887
+ if (!objectiveId) return;
888
+ const options = getObjectiveOptions(objectiveId);
889
+ if (options.length === 0) return;
890
+ const current = getObjectiveSelectedIndex(objectiveId);
891
+ const next = Math.max(0, Math.min(options.length - 1, current + delta));
892
+ if (next === current) return;
893
+ const nextKey = options[next].key;
894
+ state.selectedKeyByObjective[objectiveId] = nextKey;
895
+ state.selectedKey = nextKey;
896
+ render();
897
+ await applySelectionKey(nextKey);
898
+ }
899
+
900
+ function connectEvents() {
901
+ if (eventSource) eventSource.close();
902
+
903
+ eventSource = new EventSource(`${baseUrl}/events`);
904
+ eventSource.addEventListener("state.changed", () => {
905
+ void refreshStatus("Synced", 2000);
906
+ });
907
+ eventSource.addEventListener("apply.failed", (event) => {
908
+ try {
909
+ const payload = JSON.parse(event.data);
910
+ setStatus(payload.payload && payload.payload.error ? String(payload.payload.error) : "Apply failed", 5000);
911
+ } catch {
912
+ setStatus("Apply failed", 5000);
913
+ }
914
+ });
915
+ eventSource.onerror = () => {
916
+ setStatus("Reconnecting...", 0);
917
+ };
918
+ }
919
+
920
+ function handlePointerDown(event) {
921
+ if (state.busy) return;
922
+
923
+ const isDragHandle = event.target.closest('.vynt-drag-handle');
924
+ const isLogoBtn = event.target.closest('.vynt-logo-btn');
925
+
926
+ if (!isDragHandle && (!isLogoBtn || (!state.collapsed && state.options.length > 0))) {
927
+ return;
928
+ }
929
+
930
+ event.preventDefault();
931
+ try {
932
+ (isDragHandle || isLogoBtn).setPointerCapture(event.pointerId);
933
+ } catch(e) {}
934
+
935
+ const rect = root.getBoundingClientRect();
936
+ const current = state.position || { x: rect.left, y: rect.top };
937
+
938
+ dragStart = {
939
+ id: event.pointerId,
940
+ startX: event.clientX,
941
+ startY: event.clientY,
942
+ initialX: current.x,
943
+ initialY: current.y,
944
+ };
945
+ isDragging = false;
946
+ }
947
+
948
+ function handlePointerMove(event) {
949
+ if (!dragStart || dragStart.id !== event.pointerId) return;
950
+
951
+ const deltaX = event.clientX - dragStart.startX;
952
+ const deltaY = event.clientY - dragStart.startY;
953
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
954
+
955
+ if (!isDragging && distance > DRAG_THRESHOLD) {
956
+ isDragging = true;
957
+ render();
958
+ }
959
+
960
+ if (!isDragging) return;
961
+
962
+ state.position = {
963
+ x: dragStart.initialX + deltaX,
964
+ y: dragStart.initialY + deltaY,
965
+ };
966
+
967
+ updateRootPosition();
968
+ }
969
+
970
+ function handlePointerUp(event) {
971
+ if (!dragStart || dragStart.id !== event.pointerId) return;
972
+
973
+ const isDragHandle = event.target.closest('.vynt-drag-handle');
974
+ const isLogoBtn = event.target.closest('.vynt-logo-btn');
975
+ const targetElement = isDragHandle || isLogoBtn;
976
+
977
+ try {
978
+ if (targetElement) {
979
+ targetElement.releasePointerCapture(event.pointerId);
980
+ }
981
+ } catch(e) {}
982
+
983
+ if (isDragging) {
984
+ persistPosition();
985
+ setTimeout(() => {
986
+ if(state.position) {
987
+ state.position = constrainPosition(state.position);
988
+ updateRootPosition();
989
+ persistPosition();
990
+ }
991
+ }, 300);
992
+ } else {
993
+ if (state.collapsed && isLogoBtn) {
994
+ state.collapsed = false;
995
+ render();
996
+ }
997
+ }
998
+
999
+ dragStart = null;
1000
+ isDragging = false;
1001
+ render();
1002
+ }
1003
+
1004
+ variantSelect.addEventListener("change", () => {
1005
+ state.selectedKey = variantSelect.value || null;
1006
+ render();
1007
+ const selected = parseSelectionKey(state.selectedKey);
1008
+ if (selected) {
1009
+ setStatus(`Selected ${selected.variantId}. Click apply.`, 2500);
1010
+ }
1011
+ });
1012
+
1013
+ applyBtn.addEventListener("click", (event) => {
1014
+ event.stopPropagation();
1015
+ void finalizeSelected();
1016
+ });
1017
+
1018
+ prevBtn.addEventListener("click", (event) => {
1019
+ event.stopPropagation();
1020
+ void moveBy(-1);
1021
+ });
1022
+
1023
+ nextBtn.addEventListener("click", (event) => {
1024
+ event.stopPropagation();
1025
+ void moveBy(1);
1026
+ });
1027
+
1028
+ collapseBtn.addEventListener("click", (event) => {
1029
+ event.stopPropagation();
1030
+ state.collapsed = true;
1031
+ render();
1032
+ });
1033
+
1034
+ objectiveSelect.addEventListener("change", () => {
1035
+ const objectiveId = state.hoveredObjectiveId;
1036
+ if (!objectiveId) return;
1037
+ const nextKey = objectiveSelect.value || null;
1038
+ if (!nextKey) return;
1039
+ state.selectedKeyByObjective[objectiveId] = nextKey;
1040
+ state.selectedKey = nextKey;
1041
+ render();
1042
+ });
1043
+
1044
+ objectivePrevBtn.addEventListener("click", (event) => {
1045
+ event.stopPropagation();
1046
+ void moveObjectiveBy(-1);
1047
+ });
1048
+
1049
+ objectiveNextBtn.addEventListener("click", (event) => {
1050
+ event.stopPropagation();
1051
+ void moveObjectiveBy(1);
1052
+ });
1053
+
1054
+ logoBtn.addEventListener("pointerdown", handlePointerDown);
1055
+ dragHandle.addEventListener("pointerdown", handlePointerDown);
1056
+
1057
+ document.addEventListener("pointermove", handlePointerMove);
1058
+ document.addEventListener("pointerup", handlePointerUp);
1059
+ document.addEventListener("pointercancel", handlePointerUp);
1060
+ document.addEventListener("mousemove", handleDocumentHover, true);
1061
+ window.addEventListener("scroll", refreshObjectiveOverlayPosition, true);
1062
+
1063
+ window.addEventListener("resize", () => {
1064
+ if (state.position) {
1065
+ state.position = constrainPosition(state.position);
1066
+ }
1067
+ refreshObjectiveOverlayPosition();
1068
+ render();
1069
+ });
1070
+
1071
+ loadPosition();
1072
+ connectEvents();
1073
+ render();
1074
+ void refreshStatus("Loading...", 0);
1075
+ })();