@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,9 @@
1
+ import type { JSX } from "react";
2
+
3
+ export interface VyntToolbarProviderProps {
4
+ bridgeUrl?: string;
5
+ enabled?: boolean;
6
+ removeOnUnmount?: boolean;
7
+ }
8
+
9
+ export declare function VyntToolbarProvider(props?: VyntToolbarProviderProps): JSX.Element | null;
@@ -0,0 +1,615 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+
5
+ const DEFAULT_BRIDGE_URL = "/__vynt";
6
+ const DEFAULT_ENABLED =
7
+ typeof process === "undefined" || !process.env
8
+ ? true
9
+ : process.env.NODE_ENV !== "production";
10
+
11
+ const ROOT_ID = "vynt-inline-toolbar-root";
12
+ const STYLE_ID = "vynt-inline-toolbar-style";
13
+ const STORAGE_KEY = "vynt-toolbar-position";
14
+ const DRAG_THRESHOLD = 6;
15
+ const VIEWPORT_PADDING = 12;
16
+ const EXPANDED_WIDTH = 320;
17
+ const COLLAPSED_SIZE = 44;
18
+ const TOOLBAR_HEIGHT = 44;
19
+ const VYNT_ICON = `<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>`;
20
+ const APPLY_ICON = `<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>`;
21
+
22
+ function mountInlineToolbar({ bridgeUrl, keepMountedOnUnmount }) {
23
+ if (typeof document === "undefined") {
24
+ return () => {};
25
+ }
26
+
27
+ const existingRoot = document.getElementById(ROOT_ID);
28
+ if (existingRoot) {
29
+ return () => {};
30
+ }
31
+
32
+ if (!document.getElementById(STYLE_ID)) {
33
+ const style = document.createElement("style");
34
+ style.id = STYLE_ID;
35
+ style.textContent = `
36
+ [data-vynt-toolbar-inline] {
37
+ box-sizing: border-box;
38
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
39
+ }
40
+
41
+ #${ROOT_ID} {
42
+ position: fixed;
43
+ right: 18px;
44
+ bottom: 18px;
45
+ z-index: 2147483647;
46
+ }
47
+
48
+ .vynt-shell {
49
+ width: 320px;
50
+ height: 44px;
51
+ border-radius: 999px;
52
+ border: 1px solid rgba(255, 255, 255, 0.16);
53
+ background: rgba(20, 21, 25, 0.94);
54
+ color: rgba(255, 255, 255, 0.94);
55
+ box-shadow: 0 16px 36px rgba(0, 0, 0, 0.34);
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 6px;
59
+ padding: 5px;
60
+ pointer-events: auto;
61
+ cursor: grab;
62
+ user-select: none;
63
+ transition: transform 140ms ease, opacity 140ms ease, width 180ms ease;
64
+ }
65
+
66
+ .vynt-shell.vynt-dragging {
67
+ cursor: grabbing;
68
+ transform: scale(1.03);
69
+ }
70
+
71
+ .vynt-shell.vynt-collapsed {
72
+ width: 44px;
73
+ justify-content: center;
74
+ padding: 0;
75
+ }
76
+
77
+ .vynt-shell.vynt-empty {
78
+ width: 44px;
79
+ justify-content: center;
80
+ padding: 0;
81
+ }
82
+
83
+ .vynt-toggle {
84
+ width: 34px;
85
+ height: 34px;
86
+ border-radius: 999px;
87
+ border: 0;
88
+ background: transparent;
89
+ color: rgba(255, 255, 255, 0.92);
90
+ display: inline-flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ font-size: 15px;
94
+ cursor: pointer;
95
+ }
96
+
97
+ .vynt-controls {
98
+ display: flex;
99
+ align-items: center;
100
+ gap: 6px;
101
+ width: 100%;
102
+ }
103
+
104
+ .vynt-shell.vynt-empty .vynt-controls {
105
+ display: none;
106
+ }
107
+
108
+ .vynt-btn {
109
+ width: 34px;
110
+ height: 34px;
111
+ border-radius: 999px;
112
+ border: 0;
113
+ background: transparent;
114
+ color: rgba(255, 255, 255, 0.9);
115
+ display: inline-flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ font-size: 16px;
119
+ line-height: 1;
120
+ cursor: pointer;
121
+ transition: background-color 120ms ease, color 120ms ease;
122
+ }
123
+
124
+ .vynt-btn:hover:not(:disabled),
125
+ .vynt-toggle:hover:not(:disabled) {
126
+ background: rgba(255, 255, 255, 0.12);
127
+ color: #fff;
128
+ }
129
+
130
+ .vynt-btn:disabled,
131
+ .vynt-select:disabled,
132
+ .vynt-toggle:disabled {
133
+ opacity: 0.45;
134
+ cursor: default;
135
+ }
136
+
137
+ .vynt-select {
138
+ flex: 1;
139
+ min-width: 0;
140
+ height: 34px;
141
+ border-radius: 999px;
142
+ border: 1px solid rgba(255, 255, 255, 0.14);
143
+ background: rgba(255, 255, 255, 0.08);
144
+ color: rgba(255, 255, 255, 0.95);
145
+ padding: 0 10px;
146
+ font-size: 12px;
147
+ outline: none;
148
+ }
149
+
150
+ .vynt-select option {
151
+ color: #111;
152
+ }
153
+
154
+ .vynt-status {
155
+ position: absolute;
156
+ left: 50%;
157
+ bottom: -18px;
158
+ transform: translateX(-50%);
159
+ font-size: 11px;
160
+ color: rgba(255, 255, 255, 0.75);
161
+ white-space: nowrap;
162
+ pointer-events: none;
163
+ }
164
+ `;
165
+ document.head.appendChild(style);
166
+ }
167
+
168
+ const normalizedBridgeUrl = bridgeUrl.replace(/\/+$/, "");
169
+
170
+ const localState = {
171
+ busy: false,
172
+ collapsed: false,
173
+ initialized: false,
174
+ options: [],
175
+ selectedKey: null,
176
+ statusText: "Connecting...",
177
+ position: null,
178
+ };
179
+
180
+ let eventSource;
181
+ let dragStart = null;
182
+ let isDragging = false;
183
+ let preventExpandClick = false;
184
+
185
+ const root = document.createElement("div");
186
+ root.id = ROOT_ID;
187
+ root.setAttribute("data-vynt-toolbar-inline", "root");
188
+
189
+ const shell = document.createElement("div");
190
+ shell.className = "vynt-shell";
191
+
192
+ const controls = document.createElement("div");
193
+ controls.className = "vynt-controls";
194
+
195
+ const prevBtn = document.createElement("button");
196
+ prevBtn.className = "vynt-btn";
197
+ prevBtn.textContent = "<";
198
+
199
+ const variantSelect = document.createElement("select");
200
+ variantSelect.className = "vynt-select";
201
+
202
+ const nextBtn = document.createElement("button");
203
+ nextBtn.className = "vynt-btn";
204
+ nextBtn.textContent = ">";
205
+
206
+ const applyBtn = document.createElement("button");
207
+ applyBtn.className = "vynt-btn";
208
+ applyBtn.innerHTML = APPLY_ICON;
209
+ applyBtn.title = "Finalize selected variant";
210
+
211
+ const collapseBtn = document.createElement("button");
212
+ collapseBtn.className = "vynt-toggle";
213
+ collapseBtn.textContent = "-";
214
+ collapseBtn.title = "Collapse";
215
+
216
+ const collapsedBtn = document.createElement("button");
217
+ collapsedBtn.className = "vynt-toggle";
218
+ collapsedBtn.innerHTML = VYNT_ICON;
219
+ collapsedBtn.title = "Vynt";
220
+ collapsedBtn.style.display = "none";
221
+
222
+ const statusNode = document.createElement("div");
223
+ statusNode.className = "vynt-status";
224
+ statusNode.textContent = localState.statusText;
225
+
226
+ controls.appendChild(prevBtn);
227
+ controls.appendChild(variantSelect);
228
+ controls.appendChild(nextBtn);
229
+ controls.appendChild(applyBtn);
230
+ controls.appendChild(collapseBtn);
231
+ shell.appendChild(controls);
232
+ shell.appendChild(collapsedBtn);
233
+ root.appendChild(shell);
234
+ root.appendChild(statusNode);
235
+ document.body.appendChild(root);
236
+
237
+ function setStatus(text) {
238
+ localState.statusText = text;
239
+ statusNode.textContent = text;
240
+ }
241
+
242
+ function setBusy(nextBusy) {
243
+ localState.busy = nextBusy;
244
+ render();
245
+ }
246
+
247
+ function parseSelectionKey(key) {
248
+ const [objectiveId, variantId] = String(key).split("::");
249
+ if (!objectiveId || !variantId) return null;
250
+ return { objectiveId, variantId };
251
+ }
252
+
253
+ function getSelectedIndex() {
254
+ return localState.options.findIndex((item) => item.key === localState.selectedKey);
255
+ }
256
+
257
+ function constrainPosition(position, collapsed) {
258
+ const width = collapsed ? COLLAPSED_SIZE : EXPANDED_WIDTH;
259
+ const maxX = window.innerWidth - width - VIEWPORT_PADDING;
260
+ const maxY = window.innerHeight - TOOLBAR_HEIGHT - VIEWPORT_PADDING;
261
+ return {
262
+ x: Math.max(VIEWPORT_PADDING, Math.min(maxX, position.x)),
263
+ y: Math.max(VIEWPORT_PADDING, Math.min(maxY, position.y)),
264
+ };
265
+ }
266
+
267
+ function updateRootPosition() {
268
+ if (!localState.position) {
269
+ root.style.removeProperty("left");
270
+ root.style.removeProperty("top");
271
+ root.style.right = "18px";
272
+ root.style.bottom = "18px";
273
+ return;
274
+ }
275
+ const next = constrainPosition(localState.position, localState.collapsed);
276
+ localState.position = next;
277
+ root.style.left = `${next.x}px`;
278
+ root.style.top = `${next.y}px`;
279
+ root.style.right = "auto";
280
+ root.style.bottom = "auto";
281
+ }
282
+
283
+ function persistPosition() {
284
+ if (!localState.position) return;
285
+ try {
286
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(localState.position));
287
+ } catch {}
288
+ }
289
+
290
+ function loadPosition() {
291
+ try {
292
+ const raw = localStorage.getItem(STORAGE_KEY);
293
+ if (!raw) return;
294
+ const parsed = JSON.parse(raw);
295
+ if (typeof parsed?.x === "number" && typeof parsed?.y === "number") {
296
+ localState.position = parsed;
297
+ }
298
+ } catch {}
299
+ }
300
+
301
+ function syncSelectionFromStatus(payload) {
302
+ const objectives = payload?.state?.objectives || [];
303
+ const options = [];
304
+ let activeKey = null;
305
+
306
+ for (const objective of objectives) {
307
+ const variants = Array.isArray(objective.variants) ? objective.variants : [];
308
+ for (const variant of variants) {
309
+ const key = `${objective.id}::${variant.id}`;
310
+ options.push({
311
+ key,
312
+ label: `${objective.id} / ${variant.name}`,
313
+ objectiveId: objective.id,
314
+ variantId: variant.id,
315
+ });
316
+ if (objective.activeVariantId && objective.activeVariantId === variant.id) {
317
+ activeKey = key;
318
+ }
319
+ }
320
+ }
321
+
322
+ localState.options = options;
323
+ const hasCurrent = options.some((option) => option.key === localState.selectedKey);
324
+ if (!hasCurrent) {
325
+ localState.selectedKey = activeKey || (options[0] && options[0].key) || null;
326
+ }
327
+ }
328
+
329
+ function render() {
330
+ updateRootPosition();
331
+ const hasOptions = localState.options.length > 0;
332
+ shell.classList.toggle("vynt-collapsed", localState.collapsed);
333
+ shell.classList.toggle("vynt-empty", !hasOptions);
334
+ shell.classList.toggle("vynt-dragging", isDragging);
335
+
336
+ controls.style.display = localState.collapsed || !hasOptions ? "none" : "flex";
337
+ collapsedBtn.style.display = localState.collapsed || !hasOptions ? "inline-flex" : "none";
338
+
339
+ variantSelect.innerHTML = "";
340
+ for (const optionData of localState.options) {
341
+ const option = document.createElement("option");
342
+ option.value = optionData.key;
343
+ option.textContent = optionData.label;
344
+ variantSelect.appendChild(option);
345
+ }
346
+ if (localState.selectedKey) {
347
+ variantSelect.value = localState.selectedKey;
348
+ }
349
+
350
+ const index = getSelectedIndex();
351
+ const disabled = localState.busy || !localState.initialized;
352
+ prevBtn.disabled = disabled || !hasOptions || index <= 0;
353
+ nextBtn.disabled = disabled || !hasOptions || index >= localState.options.length - 1;
354
+ applyBtn.disabled = disabled || !hasOptions;
355
+ variantSelect.disabled = disabled || !hasOptions;
356
+ collapseBtn.disabled = disabled;
357
+ collapsedBtn.disabled = disabled;
358
+ }
359
+
360
+ async function api(path, init) {
361
+ const response = await fetch(`${normalizedBridgeUrl}${path}`, {
362
+ headers: {
363
+ "Content-Type": "application/json",
364
+ },
365
+ ...init,
366
+ });
367
+
368
+ const payload = await response.json().catch(() => ({}));
369
+ if (!response.ok) {
370
+ throw new Error(payload.error || `HTTP ${response.status}`);
371
+ }
372
+ return payload;
373
+ }
374
+
375
+ async function refreshStatus(label) {
376
+ if (label) setStatus(label);
377
+
378
+ const payload = await api("/status", { method: "GET" });
379
+ localState.initialized = !!payload.initialized;
380
+
381
+ if (!payload.initialized) {
382
+ localState.options = [];
383
+ localState.selectedKey = null;
384
+ setStatus("Run vynt init first");
385
+ render();
386
+ return;
387
+ }
388
+
389
+ syncSelectionFromStatus(payload);
390
+ setStatus(localState.options.length === 0 ? "No variants" : "Ready");
391
+ render();
392
+ }
393
+
394
+ async function applySelected() {
395
+ const selected = parseSelectionKey(localState.selectedKey);
396
+ if (!selected) return;
397
+
398
+ setBusy(true);
399
+ setStatus(`Applying ${selected.variantId}...`);
400
+
401
+ try {
402
+ await api("/apply", {
403
+ method: "POST",
404
+ body: JSON.stringify({
405
+ objectiveId: selected.objectiveId,
406
+ variantId: selected.variantId,
407
+ }),
408
+ });
409
+ await refreshStatus("Applied");
410
+ } catch (error) {
411
+ const message = error instanceof Error ? error.message : String(error);
412
+ setStatus(message);
413
+ } finally {
414
+ setBusy(false);
415
+ }
416
+ }
417
+
418
+ async function finalizeSelected() {
419
+ const selected = parseSelectionKey(localState.selectedKey);
420
+ if (!selected) return;
421
+
422
+ setBusy(true);
423
+ setStatus(`Finalizing ${selected.variantId}...`);
424
+
425
+ try {
426
+ await api("/finalize", {
427
+ method: "POST",
428
+ body: JSON.stringify({
429
+ objectiveId: selected.objectiveId,
430
+ variantId: selected.variantId,
431
+ }),
432
+ });
433
+ await refreshStatus("Finalized winner");
434
+ } catch (error) {
435
+ const message = error instanceof Error ? error.message : String(error);
436
+ setStatus(message);
437
+ } finally {
438
+ setBusy(false);
439
+ }
440
+ }
441
+
442
+ async function moveBy(delta) {
443
+ const total = localState.options.length;
444
+ if (total === 0) return;
445
+
446
+ const current = getSelectedIndex();
447
+ const next = Math.max(0, Math.min(total - 1, current + delta));
448
+ if (next === current) return;
449
+
450
+ localState.selectedKey = localState.options[next].key;
451
+ render();
452
+ await applySelected();
453
+ }
454
+
455
+ function handleDragStart(event) {
456
+ if (localState.busy) return;
457
+ const target = event.target;
458
+ if (target.closest("button") || target.closest("select")) {
459
+ return;
460
+ }
461
+
462
+ const rect = root.getBoundingClientRect();
463
+ const current = localState.position || { x: rect.left, y: rect.top };
464
+
465
+ dragStart = {
466
+ pointerX: event.clientX,
467
+ pointerY: event.clientY,
468
+ x: current.x,
469
+ y: current.y,
470
+ };
471
+ isDragging = false;
472
+ }
473
+
474
+ function handleDragMove(event) {
475
+ if (!dragStart) return;
476
+
477
+ const deltaX = event.clientX - dragStart.pointerX;
478
+ const deltaY = event.clientY - dragStart.pointerY;
479
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
480
+
481
+ if (!isDragging && distance > DRAG_THRESHOLD) {
482
+ isDragging = true;
483
+ preventExpandClick = true;
484
+ }
485
+
486
+ if (!isDragging) return;
487
+
488
+ localState.position = {
489
+ x: dragStart.x + deltaX,
490
+ y: dragStart.y + deltaY,
491
+ };
492
+
493
+ updateRootPosition();
494
+ render();
495
+ }
496
+
497
+ function handleDragEnd() {
498
+ if (!dragStart) return;
499
+ if (isDragging) persistPosition();
500
+ dragStart = null;
501
+ isDragging = false;
502
+ render();
503
+ window.setTimeout(() => {
504
+ preventExpandClick = false;
505
+ }, 120);
506
+ }
507
+
508
+ function connectEvents() {
509
+ eventSource = new EventSource(`${normalizedBridgeUrl}/events`);
510
+ eventSource.addEventListener("state.changed", () => {
511
+ void refreshStatus("Synced");
512
+ });
513
+ eventSource.addEventListener("apply.failed", (event) => {
514
+ try {
515
+ const payload = JSON.parse(event.data);
516
+ setStatus(payload.payload?.error ? String(payload.payload.error) : "Apply failed");
517
+ } catch {
518
+ setStatus("Apply failed");
519
+ }
520
+ });
521
+ eventSource.addEventListener("rollback.failed", (event) => {
522
+ try {
523
+ const payload = JSON.parse(event.data);
524
+ setStatus(payload.payload?.error ? String(payload.payload.error) : "Rollback failed");
525
+ } catch {
526
+ setStatus("Rollback failed");
527
+ }
528
+ });
529
+ eventSource.onerror = () => {
530
+ setStatus("Bridge unavailable");
531
+ };
532
+ }
533
+
534
+ variantSelect.addEventListener("change", () => {
535
+ localState.selectedKey = variantSelect.value || null;
536
+ render();
537
+ const selected = parseSelectionKey(localState.selectedKey);
538
+ if (selected) {
539
+ setStatus(`Selected ${selected.variantId}. Click apply.`);
540
+ }
541
+ });
542
+
543
+ applyBtn.addEventListener("click", (event) => {
544
+ event.stopPropagation();
545
+ void finalizeSelected();
546
+ });
547
+
548
+ prevBtn.addEventListener("click", (event) => {
549
+ event.stopPropagation();
550
+ void moveBy(-1);
551
+ });
552
+
553
+ nextBtn.addEventListener("click", (event) => {
554
+ event.stopPropagation();
555
+ void moveBy(1);
556
+ });
557
+
558
+ collapseBtn.addEventListener("click", (event) => {
559
+ event.stopPropagation();
560
+ localState.collapsed = true;
561
+ render();
562
+ });
563
+
564
+ collapsedBtn.addEventListener("click", (event) => {
565
+ event.stopPropagation();
566
+ if (preventExpandClick) return;
567
+ localState.collapsed = false;
568
+ render();
569
+ });
570
+
571
+ shell.addEventListener("mousedown", handleDragStart);
572
+ document.addEventListener("mousemove", handleDragMove);
573
+ document.addEventListener("mouseup", handleDragEnd);
574
+ window.addEventListener("resize", () => {
575
+ if (localState.position) {
576
+ localState.position = constrainPosition(localState.position, localState.collapsed);
577
+ }
578
+ render();
579
+ });
580
+
581
+ loadPosition();
582
+ connectEvents();
583
+ render();
584
+ void refreshStatus("Loading");
585
+
586
+ return () => {
587
+ if (eventSource) {
588
+ eventSource.close();
589
+ }
590
+ document.removeEventListener("mousemove", handleDragMove);
591
+ document.removeEventListener("mouseup", handleDragEnd);
592
+ if (!keepMountedOnUnmount) {
593
+ root.remove();
594
+ }
595
+ };
596
+ }
597
+
598
+ export function VyntToolbarProvider({
599
+ bridgeUrl = DEFAULT_BRIDGE_URL,
600
+ enabled = DEFAULT_ENABLED,
601
+ removeOnUnmount = true,
602
+ } = {}) {
603
+ useEffect(() => {
604
+ if (!enabled || typeof window === "undefined") {
605
+ return;
606
+ }
607
+
608
+ return mountInlineToolbar({
609
+ bridgeUrl,
610
+ keepMountedOnUnmount: !removeOnUnmount,
611
+ });
612
+ }, [bridgeUrl, enabled, removeOnUnmount]);
613
+
614
+ return null;
615
+ }
@@ -0,0 +1 @@
1
+ export { VyntToolbarProvider, type VyntToolbarProviderProps } from "./VyntToolbarProvider.js";
@@ -0,0 +1 @@
1
+ export { VyntToolbarProvider } from "./VyntToolbarProvider.js";