docugate 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1042 @@
1
+ // @docugate/pill v0.1.0 — auto-generated, do not edit
2
+ (function(){
3
+ 'use strict';
4
+ // @docugate/pill — in-app tour pill
5
+ // No framework, no dependencies. Shadow DOM isolates styles.
6
+ // ---------------------------------------------------------------------------
7
+ // Route matching (mirrors matchRoute in tour.ts)
8
+ // ---------------------------------------------------------------------------
9
+ function matchRoute(route, pathname) {
10
+ const routeParts = route.split('/').filter(Boolean);
11
+ const pathParts = pathname.split('/').filter(Boolean);
12
+ if (routeParts.length !== pathParts.length)
13
+ return false;
14
+ for (let i = 0; i < routeParts.length; i++) {
15
+ if (routeParts[i].startsWith(':'))
16
+ continue;
17
+ if (routeParts[i] !== pathParts[i])
18
+ return false;
19
+ }
20
+ return true;
21
+ }
22
+ // ---------------------------------------------------------------------------
23
+ // Selector suggestion
24
+ // ---------------------------------------------------------------------------
25
+ /**
26
+ * Suggest a [data-tour="..."] selector for an element.
27
+ * Priority: existing data-tour → id → role+text → tag+position.
28
+ */
29
+ function suggestSelector(el) {
30
+ const existing = el.getAttribute('data-tour');
31
+ if (existing) {
32
+ return { selector: `[data-tour="${existing}"]`, needsAttribute: false };
33
+ }
34
+ // Derive a slug from id, aria-label, or text content
35
+ const id = el.id;
36
+ if (id) {
37
+ const slug = id.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
38
+ return { selector: `[data-tour="${slug}"]`, needsAttribute: true };
39
+ }
40
+ const label = el.getAttribute('aria-label') || el.getAttribute('title');
41
+ if (label) {
42
+ const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
43
+ return { selector: `[data-tour="${slug}"]`, needsAttribute: true };
44
+ }
45
+ const text = (el.textContent ?? '').trim().slice(0, 30);
46
+ if (text) {
47
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
48
+ return { selector: `[data-tour="${slug}"]`, needsAttribute: true };
49
+ }
50
+ const tag = el.tagName.toLowerCase();
51
+ return { selector: `[data-tour="${tag}"]`, needsAttribute: true };
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // Server URL: read from this script's own src attribute
55
+ // ---------------------------------------------------------------------------
56
+ function getServerBase() {
57
+ const scripts = document.querySelectorAll('script[src]');
58
+ for (const s of Array.from(scripts)) {
59
+ const src = s.src;
60
+ if (src && src.endsWith('/pill.js')) {
61
+ const url = new URL(src);
62
+ return `${url.protocol}//${url.host}`;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // Fetch current tour from server or window.DOCUGATE_TOUR
69
+ // ---------------------------------------------------------------------------
70
+ /**
71
+ * The current page's tour, and whether the tour server answered at all. The
72
+ * two are different: a page with no tour yet still gets the pill, so its first
73
+ * stop can be added from the inspector. Only a silent server hides it.
74
+ */
75
+ async function fetchTour(base) {
76
+ const pathname = location.pathname;
77
+ if (window.DOCUGATE_TOUR) {
78
+ const match = window.DOCUGATE_TOUR.find((t) => matchRoute(t.route, pathname));
79
+ return { alive: true, tour: match ?? null };
80
+ }
81
+ try {
82
+ const res = await fetch(`${base}/tour?path=${encodeURIComponent(pathname)}`, { signal: AbortSignal.timeout(3000) });
83
+ if (res.status === 404)
84
+ return { alive: true, tour: null };
85
+ if (!res.ok)
86
+ return { alive: false, tour: null };
87
+ return { alive: true, tour: (await res.json()) };
88
+ }
89
+ catch {
90
+ return { alive: false, tour: null };
91
+ }
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // CSS (injected into shadow DOM)
95
+ // ---------------------------------------------------------------------------
96
+ const STYLES = `
97
+ :host { all: initial; }
98
+ *, *::before, *::after { box-sizing: border-box; }
99
+
100
+ /* ── pill button ── */
101
+ .pill-btn {
102
+ position: fixed;
103
+ bottom: 20px;
104
+ right: 20px;
105
+ z-index: 2147483647;
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 6px;
109
+ padding: 6px 12px;
110
+ background: #1a1a1a;
111
+ color: #e8e8e8;
112
+ border: 1px solid #333;
113
+ border-radius: 999px;
114
+ font: 12px/1 -apple-system, "Segoe UI", system-ui, sans-serif;
115
+ cursor: pointer;
116
+ user-select: none;
117
+ letter-spacing: 0.01em;
118
+ transition: background 0.15s;
119
+ }
120
+ .pill-btn:hover { background: #2a2a2a; }
121
+ .pill-btn:focus-visible { outline: 2px solid #4a90d9; outline-offset: 2px; }
122
+ .pill-dot {
123
+ width: 6px;
124
+ height: 6px;
125
+ background: #4a90d9;
126
+ border-radius: 50%;
127
+ flex-shrink: 0;
128
+ }
129
+
130
+ /* ── menu ── */
131
+ .menu {
132
+ position: fixed;
133
+ bottom: 54px;
134
+ right: 20px;
135
+ z-index: 2147483647;
136
+ background: #1a1a1a;
137
+ border: 1px solid #333;
138
+ border-radius: 8px;
139
+ overflow: hidden;
140
+ font: 13px/1 -apple-system, "Segoe UI", system-ui, sans-serif;
141
+ min-width: 170px;
142
+ }
143
+ .menu-item {
144
+ display: block;
145
+ width: 100%;
146
+ padding: 10px 14px;
147
+ background: none;
148
+ border: none;
149
+ color: #e0e0e0;
150
+ text-align: left;
151
+ cursor: pointer;
152
+ font: inherit;
153
+ transition: background 0.1s;
154
+ }
155
+ .menu-item:hover { background: #2a2a2a; }
156
+ .menu-item:focus-visible { outline: 2px solid #4a90d9; outline-offset: -2px; }
157
+ .menu-item + .menu-item { border-top: 1px solid #2a2a2a; }
158
+ .menu-item.active { color: #4a90d9; }
159
+ .menu-item:disabled { color: #777; cursor: default; background: none; }
160
+
161
+ /* ── overlay / backdrop ── */
162
+ .overlay {
163
+ position: fixed;
164
+ inset: 0;
165
+ z-index: 2147483640;
166
+ pointer-events: none;
167
+ }
168
+
169
+ /* ── walkthrough card ── */
170
+ .wt-card {
171
+ position: fixed;
172
+ z-index: 2147483645;
173
+ background: #1a1a1a;
174
+ border: 1px solid #333;
175
+ border-radius: 10px;
176
+ padding: 18px 20px 14px;
177
+ width: 320px;
178
+ max-width: calc(100vw - 32px);
179
+ font: 13px/1.55 -apple-system, "Segoe UI", system-ui, sans-serif;
180
+ color: #ddd;
181
+ }
182
+ .wt-card-heading {
183
+ font-size: 14px;
184
+ font-weight: 600;
185
+ color: #fff;
186
+ margin: 0 0 12px;
187
+ }
188
+ .wt-field {
189
+ display: flex;
190
+ flex-direction: column;
191
+ gap: 2px;
192
+ margin-bottom: 8px;
193
+ }
194
+ .wt-field-label {
195
+ font-size: 10px;
196
+ text-transform: uppercase;
197
+ letter-spacing: 0.06em;
198
+ color: #666;
199
+ }
200
+ .wt-field-value {
201
+ font-size: 12px;
202
+ color: #b0b0b0;
203
+ word-break: break-all;
204
+ }
205
+ .wt-field-value a {
206
+ color: #4a90d9;
207
+ text-decoration: none;
208
+ }
209
+ .wt-field-value a:hover { text-decoration: underline; }
210
+ .wt-prose {
211
+ font-size: 13px;
212
+ color: #bbb;
213
+ margin: 10px 0 14px;
214
+ line-height: 1.55;
215
+ }
216
+ .wt-nav {
217
+ display: flex;
218
+ justify-content: flex-end;
219
+ gap: 8px;
220
+ }
221
+ .wt-btn {
222
+ padding: 5px 14px;
223
+ border-radius: 6px;
224
+ border: 1px solid #444;
225
+ background: #252525;
226
+ color: #ddd;
227
+ font: 12px -apple-system, "Segoe UI", system-ui, sans-serif;
228
+ cursor: pointer;
229
+ transition: background 0.1s;
230
+ }
231
+ .wt-btn:hover { background: #333; }
232
+ .wt-btn:focus-visible { outline: 2px solid #4a90d9; outline-offset: 2px; }
233
+ .wt-btn.primary {
234
+ background: #1e4a8a;
235
+ border-color: #2a5fa0;
236
+ color: #fff;
237
+ }
238
+ .wt-btn.primary:hover { background: #25559a; }
239
+ .wt-counter {
240
+ font-size: 11px;
241
+ color: #555;
242
+ margin-right: auto;
243
+ align-self: center;
244
+ }
245
+
246
+ /* ── inspector popup ── */
247
+ .insp-popup {
248
+ position: fixed;
249
+ z-index: 2147483645;
250
+ background: #1a1a1a;
251
+ border: 1px solid #333;
252
+ border-radius: 8px;
253
+ padding: 12px 14px 10px;
254
+ width: 280px;
255
+ max-width: calc(100vw - 24px);
256
+ font: 12px/1.5 -apple-system, "Segoe UI", system-ui, sans-serif;
257
+ color: #ccc;
258
+ pointer-events: auto;
259
+ }
260
+ .insp-popup-heading {
261
+ font-size: 13px;
262
+ font-weight: 600;
263
+ color: #fff;
264
+ margin: 0 0 8px;
265
+ }
266
+ .insp-popup-row {
267
+ display: flex;
268
+ flex-direction: column;
269
+ gap: 2px;
270
+ margin-bottom: 7px;
271
+ }
272
+ .insp-popup-label {
273
+ font-size: 10px;
274
+ text-transform: uppercase;
275
+ letter-spacing: 0.06em;
276
+ color: #555;
277
+ }
278
+ .insp-popup-val {
279
+ font-size: 12px;
280
+ color: #aaa;
281
+ word-break: break-all;
282
+ }
283
+ .insp-popup-val a { color: #4a90d9; text-decoration: none; }
284
+ .insp-popup-val a:hover { text-decoration: underline; }
285
+ .insp-popup-prose { font-size: 12px; color: #bbb; margin: 6px 0 10px; }
286
+ .insp-popup-actions {
287
+ display: flex;
288
+ gap: 6px;
289
+ justify-content: flex-end;
290
+ }
291
+
292
+ /* ── add form ── */
293
+ .add-form {
294
+ position: fixed;
295
+ z-index: 2147483646;
296
+ background: #1a1a1a;
297
+ border: 1px solid #333;
298
+ border-radius: 10px;
299
+ padding: 18px 20px 14px;
300
+ width: 340px;
301
+ max-width: calc(100vw - 32px);
302
+ font: 13px/1.4 -apple-system, "Segoe UI", system-ui, sans-serif;
303
+ color: #ddd;
304
+ top: 50%;
305
+ left: 50%;
306
+ transform: translate(-50%, -50%);
307
+ }
308
+ .add-form-title { font-size: 14px; font-weight: 600; color: #fff; margin: 0 0 14px; }
309
+ .add-form label { display: block; font-size: 11px; color: #666; margin-bottom: 4px; margin-top: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
310
+ .add-form label:first-of-type { margin-top: 0; }
311
+ .add-form input, .add-form textarea {
312
+ width: 100%;
313
+ background: #111;
314
+ border: 1px solid #333;
315
+ border-radius: 5px;
316
+ color: #ddd;
317
+ font: 12px/1.4 -apple-system, "Segoe UI", system-ui, sans-serif;
318
+ padding: 6px 8px;
319
+ resize: vertical;
320
+ }
321
+ .add-form input:focus, .add-form textarea:focus { outline: 2px solid #4a90d9; border-color: transparent; }
322
+ .add-form textarea { min-height: 60px; }
323
+ .add-form-warn {
324
+ margin-top: 10px;
325
+ font-size: 11px;
326
+ color: #c09a40;
327
+ background: #1e1a0a;
328
+ border: 1px solid #3a2e0a;
329
+ border-radius: 5px;
330
+ padding: 7px 9px;
331
+ line-height: 1.5;
332
+ }
333
+ .add-form-warn code { font-family: monospace; color: #e0b840; }
334
+ .add-form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
335
+
336
+ /* ── reduced motion ── */
337
+ @media (prefers-reduced-motion: reduce) {
338
+ *, *::before, *::after { transition: none !important; }
339
+ }
340
+ `;
341
+ // ---------------------------------------------------------------------------
342
+ // Highlight ring drawn on a canvas over the page
343
+ // ---------------------------------------------------------------------------
344
+ function createRingCanvas() {
345
+ const c = document.createElement('canvas');
346
+ c.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483641;';
347
+ c.width = window.innerWidth;
348
+ c.height = window.innerHeight;
349
+ return c;
350
+ }
351
+ function drawRing(canvas, rect) {
352
+ const ctx = canvas.getContext('2d');
353
+ const pad = 6;
354
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
355
+ // dim the rest of the page
356
+ ctx.fillStyle = 'rgba(0,0,0,0.55)';
357
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
358
+ // cut-out for target
359
+ const x = rect.left - pad;
360
+ const y = rect.top - pad;
361
+ const w = rect.width + pad * 2;
362
+ const h = rect.height + pad * 2;
363
+ const r = 6;
364
+ ctx.save();
365
+ ctx.globalCompositeOperation = 'destination-out';
366
+ ctx.beginPath();
367
+ ctx.moveTo(x + r, y);
368
+ ctx.lineTo(x + w - r, y);
369
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
370
+ ctx.lineTo(x + w, y + h - r);
371
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
372
+ ctx.lineTo(x + r, y + h);
373
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
374
+ ctx.lineTo(x, y + r);
375
+ ctx.quadraticCurveTo(x, y, x + r, y);
376
+ ctx.closePath();
377
+ ctx.fill();
378
+ ctx.restore();
379
+ // ring
380
+ ctx.strokeStyle = '#4a90d9';
381
+ ctx.lineWidth = 2;
382
+ ctx.beginPath();
383
+ ctx.moveTo(x + r, y);
384
+ ctx.lineTo(x + w - r, y);
385
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
386
+ ctx.lineTo(x + w, y + h - r);
387
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
388
+ ctx.lineTo(x + r, y + h);
389
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
390
+ ctx.lineTo(x, y + r);
391
+ ctx.quadraticCurveTo(x, y, x + r, y);
392
+ ctx.closePath();
393
+ ctx.stroke();
394
+ }
395
+ function clearCanvas(canvas) {
396
+ const ctx = canvas.getContext('2d');
397
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
398
+ }
399
+ // ---------------------------------------------------------------------------
400
+ // Card positioning: place card near target without clipping viewport
401
+ // ---------------------------------------------------------------------------
402
+ function positionCard(card, rect) {
403
+ const cw = 320;
404
+ const ch = card.offsetHeight || 220;
405
+ const vw = window.innerWidth;
406
+ const vh = window.innerHeight;
407
+ const pad = 12;
408
+ let top = rect.bottom + 16;
409
+ let left = rect.left;
410
+ if (top + ch > vh - pad)
411
+ top = rect.top - ch - 16;
412
+ if (top < pad)
413
+ top = pad;
414
+ if (left + cw > vw - pad)
415
+ left = vw - cw - pad;
416
+ if (left < pad)
417
+ left = pad;
418
+ card.style.top = `${top}px`;
419
+ card.style.left = `${left}px`;
420
+ }
421
+ // ---------------------------------------------------------------------------
422
+ // Inspector: highlight element on hover
423
+ // ---------------------------------------------------------------------------
424
+ let inspectorHighlight = null;
425
+ function createHighlightEl() {
426
+ const el = document.createElement('div');
427
+ el.style.cssText = [
428
+ 'position:fixed',
429
+ 'pointer-events:none',
430
+ 'z-index:2147483642',
431
+ 'border:2px solid #4a90d9',
432
+ 'border-radius:4px',
433
+ 'background:rgba(74,144,217,0.08)',
434
+ 'transition:top 0.06s,left 0.06s,width 0.06s,height 0.06s',
435
+ 'display:none',
436
+ ].join(';');
437
+ return el;
438
+ }
439
+ function moveHighlight(el, rect) {
440
+ el.style.display = 'block';
441
+ el.style.top = `${rect.top - 2}px`;
442
+ el.style.left = `${rect.left - 2}px`;
443
+ el.style.width = `${rect.width + 4}px`;
444
+ el.style.height = `${rect.height + 4}px`;
445
+ }
446
+ // ---------------------------------------------------------------------------
447
+ // Main Pill class
448
+ // ---------------------------------------------------------------------------
449
+ class DocugatePill {
450
+ constructor(base) {
451
+ this.tour = null;
452
+ this.alive = false;
453
+ this.menuOpen = false;
454
+ this.walkthroughActive = false;
455
+ this.inspectorActive = false;
456
+ this.wtIndex = 0;
457
+ this.wtStops = [];
458
+ this.canvas = null;
459
+ this.pillBtn = null;
460
+ this.menuEl = null;
461
+ this.wtCard = null;
462
+ this.inspPopup = null;
463
+ this.addForm = null;
464
+ this.highlightEl = null;
465
+ this.evtSource = null;
466
+ this.isStaticMode = false;
467
+ this.base = base;
468
+ this.isStaticMode = !!window.DOCUGATE_TOUR;
469
+ this.host = document.createElement('div');
470
+ this.host.setAttribute('data-docugate-pill', '');
471
+ this.shadow = this.host.attachShadow({ mode: 'open' });
472
+ const style = document.createElement('style');
473
+ style.textContent = STYLES;
474
+ this.shadow.appendChild(style);
475
+ this.boundHandleKey = this.handleKey.bind(this);
476
+ this.boundHandleMouseMove = this.handleInspectorMouseMove.bind(this);
477
+ this.boundHandleClick = this.handleInspectorClick.bind(this);
478
+ document.body.appendChild(this.host);
479
+ this.init();
480
+ this.patchHistory();
481
+ if (!this.isStaticMode) {
482
+ this.listenEvents();
483
+ }
484
+ }
485
+ async init() {
486
+ await this.load();
487
+ // Server didn't respond: draw nothing.
488
+ if (!this.alive)
489
+ return;
490
+ this.render();
491
+ }
492
+ async load() {
493
+ const { alive, tour } = await fetchTour(this.base);
494
+ this.alive = alive;
495
+ this.tour = tour;
496
+ }
497
+ render() {
498
+ this.removePill();
499
+ if (!this.alive)
500
+ return;
501
+ const btn = document.createElement('button');
502
+ btn.className = 'pill-btn';
503
+ btn.setAttribute('aria-label', 'Docugate tour');
504
+ btn.innerHTML = `<span class="pill-dot"></span>Tour`;
505
+ btn.addEventListener('click', (e) => {
506
+ e.stopPropagation();
507
+ this.toggleMenu();
508
+ });
509
+ this.shadow.appendChild(btn);
510
+ this.pillBtn = btn;
511
+ }
512
+ removePill() {
513
+ this.pillBtn?.remove();
514
+ this.pillBtn = null;
515
+ this.closeMenu();
516
+ }
517
+ // ── Menu ──────────────────────────────────────────────────────────────────
518
+ toggleMenu() {
519
+ if (this.menuOpen) {
520
+ this.closeMenu();
521
+ }
522
+ else {
523
+ this.openMenu();
524
+ }
525
+ }
526
+ openMenu() {
527
+ this.closeMenu();
528
+ const menu = document.createElement('div');
529
+ menu.className = 'menu';
530
+ menu.setAttribute('role', 'menu');
531
+ const walkBtn = document.createElement('button');
532
+ walkBtn.className = 'menu-item';
533
+ const stops = this.tour?.stops.length ?? 0;
534
+ walkBtn.textContent = stops ? 'Walkthrough' : 'No tour on this page yet';
535
+ walkBtn.disabled = !stops;
536
+ walkBtn.setAttribute('role', 'menuitem');
537
+ walkBtn.addEventListener('click', () => { this.closeMenu(); this.startWalkthrough(); });
538
+ const inspBtn = document.createElement('button');
539
+ inspBtn.className = `menu-item${this.inspectorActive ? ' active' : ''}`;
540
+ inspBtn.textContent = this.inspectorActive ? 'Inspector: on' : 'Inspector';
541
+ inspBtn.setAttribute('role', 'menuitem');
542
+ inspBtn.addEventListener('click', () => { this.closeMenu(); this.toggleInspector(); });
543
+ menu.appendChild(walkBtn);
544
+ menu.appendChild(inspBtn);
545
+ this.shadow.appendChild(menu);
546
+ this.menuEl = menu;
547
+ this.menuOpen = true;
548
+ const close = (e) => {
549
+ if (!menu.contains(e.target) && e.target !== this.pillBtn) {
550
+ this.closeMenu();
551
+ document.removeEventListener('click', close, true);
552
+ }
553
+ };
554
+ document.addEventListener('click', close, true);
555
+ }
556
+ closeMenu() {
557
+ this.menuEl?.remove();
558
+ this.menuEl = null;
559
+ this.menuOpen = false;
560
+ }
561
+ // ── Walkthrough ───────────────────────────────────────────────────────────
562
+ startWalkthrough() {
563
+ if (!this.tour)
564
+ return;
565
+ // Filter to stops whose target exists in the DOM
566
+ this.wtStops = this.tour.stops.filter((s) => document.querySelector(s.target) !== null);
567
+ if (!this.wtStops.length)
568
+ return;
569
+ this.walkthroughActive = true;
570
+ this.wtIndex = 0;
571
+ this.canvas = createRingCanvas();
572
+ document.body.appendChild(this.canvas);
573
+ document.addEventListener('keydown', this.boundHandleKey);
574
+ window.addEventListener('resize', () => this.refreshWtCard());
575
+ this.showWtStep();
576
+ }
577
+ showWtStep() {
578
+ const stop = this.wtStops[this.wtIndex];
579
+ const target = document.querySelector(stop.target);
580
+ if (!target) {
581
+ this.nextWtStep();
582
+ return;
583
+ }
584
+ const rect = target.getBoundingClientRect();
585
+ if (this.canvas) {
586
+ this.canvas.width = window.innerWidth;
587
+ this.canvas.height = window.innerHeight;
588
+ drawRing(this.canvas, rect);
589
+ }
590
+ target.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
591
+ this.wtCard?.remove();
592
+ const card = document.createElement('div');
593
+ card.className = 'wt-card';
594
+ card.setAttribute('role', 'dialog');
595
+ card.setAttribute('aria-modal', 'true');
596
+ card.setAttribute('aria-label', stop.heading);
597
+ const heading = document.createElement('h2');
598
+ heading.className = 'wt-card-heading';
599
+ heading.textContent = stop.heading;
600
+ card.appendChild(heading);
601
+ const fields = [];
602
+ if (stop.data)
603
+ fields.push({ label: 'Data', value: stop.data });
604
+ fields.push({ label: 'Code', value: stop.code });
605
+ if (stop.source)
606
+ fields.push({ label: 'Source', value: stop.source });
607
+ if (stop.docs)
608
+ fields.push({ label: 'Docs', value: stop.docs, isLink: true });
609
+ for (const f of fields) {
610
+ const row = document.createElement('div');
611
+ row.className = 'wt-field';
612
+ const lbl = document.createElement('span');
613
+ lbl.className = 'wt-field-label';
614
+ lbl.textContent = f.label;
615
+ const val = document.createElement('span');
616
+ val.className = 'wt-field-value';
617
+ if (f.isLink && f.value.startsWith('http')) {
618
+ const a = document.createElement('a');
619
+ a.href = f.value;
620
+ a.target = '_blank';
621
+ a.rel = 'noopener noreferrer';
622
+ a.textContent = f.value;
623
+ val.appendChild(a);
624
+ }
625
+ else {
626
+ val.textContent = f.value;
627
+ }
628
+ row.appendChild(lbl);
629
+ row.appendChild(val);
630
+ card.appendChild(row);
631
+ }
632
+ if (stop.prose) {
633
+ const prose = document.createElement('p');
634
+ prose.className = 'wt-prose';
635
+ prose.textContent = stop.prose;
636
+ card.appendChild(prose);
637
+ }
638
+ const nav = document.createElement('div');
639
+ nav.className = 'wt-nav';
640
+ const counter = document.createElement('span');
641
+ counter.className = 'wt-counter';
642
+ counter.textContent = `${this.wtIndex + 1} / ${this.wtStops.length}`;
643
+ nav.appendChild(counter);
644
+ if (this.wtIndex > 0) {
645
+ const back = document.createElement('button');
646
+ back.className = 'wt-btn';
647
+ back.textContent = 'Back';
648
+ back.addEventListener('click', () => this.prevWtStep());
649
+ nav.appendChild(back);
650
+ }
651
+ const isLast = this.wtIndex === this.wtStops.length - 1;
652
+ const next = document.createElement('button');
653
+ next.className = 'wt-btn primary';
654
+ next.textContent = isLast ? 'Done' : 'Next';
655
+ next.addEventListener('click', () => isLast ? this.stopWalkthrough() : this.nextWtStep());
656
+ nav.appendChild(next);
657
+ card.appendChild(nav);
658
+ this.shadow.appendChild(card);
659
+ this.wtCard = card;
660
+ // Position after appending (needs offsetHeight)
661
+ requestAnimationFrame(() => positionCard(card, rect));
662
+ }
663
+ refreshWtCard() {
664
+ if (!this.walkthroughActive)
665
+ return;
666
+ const stop = this.wtStops[this.wtIndex];
667
+ const target = document.querySelector(stop.target);
668
+ if (!target)
669
+ return;
670
+ const rect = target.getBoundingClientRect();
671
+ if (this.canvas) {
672
+ this.canvas.width = window.innerWidth;
673
+ this.canvas.height = window.innerHeight;
674
+ drawRing(this.canvas, rect);
675
+ }
676
+ if (this.wtCard)
677
+ positionCard(this.wtCard, rect);
678
+ }
679
+ nextWtStep() {
680
+ this.wtIndex++;
681
+ if (this.wtIndex >= this.wtStops.length) {
682
+ this.stopWalkthrough();
683
+ return;
684
+ }
685
+ this.showWtStep();
686
+ }
687
+ prevWtStep() {
688
+ if (this.wtIndex > 0) {
689
+ this.wtIndex--;
690
+ this.showWtStep();
691
+ }
692
+ }
693
+ stopWalkthrough() {
694
+ this.walkthroughActive = false;
695
+ this.wtCard?.remove();
696
+ this.wtCard = null;
697
+ if (this.canvas) {
698
+ clearCanvas(this.canvas);
699
+ this.canvas.remove();
700
+ this.canvas = null;
701
+ }
702
+ document.removeEventListener('keydown', this.boundHandleKey);
703
+ }
704
+ // ── Inspector ─────────────────────────────────────────────────────────────
705
+ toggleInspector() {
706
+ if (this.inspectorActive) {
707
+ this.stopInspector();
708
+ }
709
+ else {
710
+ this.startInspector();
711
+ }
712
+ }
713
+ startInspector() {
714
+ this.inspectorActive = true;
715
+ this.highlightEl = createHighlightEl();
716
+ document.body.appendChild(this.highlightEl);
717
+ document.addEventListener('mousemove', this.boundHandleMouseMove, true);
718
+ document.addEventListener('click', this.boundHandleClick, true);
719
+ document.addEventListener('keydown', this.boundHandleKey);
720
+ }
721
+ stopInspector() {
722
+ this.inspectorActive = false;
723
+ document.removeEventListener('mousemove', this.boundHandleMouseMove, true);
724
+ document.removeEventListener('click', this.boundHandleClick, true);
725
+ document.removeEventListener('keydown', this.boundHandleKey);
726
+ if (this.highlightEl) {
727
+ this.highlightEl.remove();
728
+ this.highlightEl = null;
729
+ }
730
+ inspectorHighlight = null;
731
+ this.closeInspPopup();
732
+ this.closeAddForm();
733
+ }
734
+ handleInspectorMouseMove(e) {
735
+ if (!this.inspectorActive)
736
+ return;
737
+ const el = e.target;
738
+ if (el && !this.host.contains(el)) {
739
+ const rect = el.getBoundingClientRect();
740
+ if (this.highlightEl)
741
+ moveHighlight(this.highlightEl, rect);
742
+ inspectorHighlight = el;
743
+ }
744
+ }
745
+ handleInspectorClick(e) {
746
+ if (!this.inspectorActive)
747
+ return;
748
+ const el = e.target;
749
+ if (!el || this.host.contains(el))
750
+ return;
751
+ e.preventDefault();
752
+ e.stopPropagation();
753
+ const stop = this.findStopForElement(el);
754
+ if (stop) {
755
+ this.showInspPopup(el, stop);
756
+ }
757
+ else {
758
+ this.showAddForm(el);
759
+ }
760
+ }
761
+ findStopForElement(el) {
762
+ if (!this.tour)
763
+ return null;
764
+ for (const stop of this.tour.stops) {
765
+ try {
766
+ if (el.matches(stop.target))
767
+ return stop;
768
+ }
769
+ catch {
770
+ // invalid selector — skip
771
+ }
772
+ }
773
+ return null;
774
+ }
775
+ showInspPopup(el, stop) {
776
+ this.closeInspPopup();
777
+ const popup = document.createElement('div');
778
+ popup.className = 'insp-popup';
779
+ popup.setAttribute('role', 'dialog');
780
+ popup.setAttribute('aria-label', stop.heading);
781
+ const heading = document.createElement('h3');
782
+ heading.className = 'insp-popup-heading';
783
+ heading.textContent = stop.heading;
784
+ popup.appendChild(heading);
785
+ const rows = [];
786
+ if (stop.data)
787
+ rows.push({ label: 'Data', value: stop.data });
788
+ rows.push({ label: 'Code', value: stop.code });
789
+ if (stop.source)
790
+ rows.push({ label: 'Source', value: stop.source });
791
+ if (stop.docs)
792
+ rows.push({ label: 'Docs', value: stop.docs, isLink: true });
793
+ for (const r of rows) {
794
+ const row = document.createElement('div');
795
+ row.className = 'insp-popup-row';
796
+ const lbl = document.createElement('span');
797
+ lbl.className = 'insp-popup-label';
798
+ lbl.textContent = r.label;
799
+ const val = document.createElement('span');
800
+ val.className = 'insp-popup-val';
801
+ if (r.isLink && r.value.startsWith('http')) {
802
+ const a = document.createElement('a');
803
+ a.href = r.value;
804
+ a.target = '_blank';
805
+ a.rel = 'noopener noreferrer';
806
+ a.textContent = r.value;
807
+ val.appendChild(a);
808
+ }
809
+ else {
810
+ val.textContent = r.value;
811
+ }
812
+ row.appendChild(lbl);
813
+ row.appendChild(val);
814
+ popup.appendChild(row);
815
+ }
816
+ if (stop.prose) {
817
+ const prose = document.createElement('p');
818
+ prose.className = 'insp-popup-prose';
819
+ prose.textContent = stop.prose;
820
+ popup.appendChild(prose);
821
+ }
822
+ if (!this.isStaticMode) {
823
+ const actions = document.createElement('div');
824
+ actions.className = 'insp-popup-actions';
825
+ const editBtn = document.createElement('button');
826
+ editBtn.className = 'wt-btn';
827
+ editBtn.textContent = 'Edit';
828
+ editBtn.addEventListener('click', () => {
829
+ this.closeInspPopup();
830
+ this.showAddForm(el, stop);
831
+ });
832
+ actions.appendChild(editBtn);
833
+ const closeBtn = document.createElement('button');
834
+ closeBtn.className = 'wt-btn primary';
835
+ closeBtn.textContent = 'Close';
836
+ closeBtn.addEventListener('click', () => this.closeInspPopup());
837
+ actions.appendChild(closeBtn);
838
+ popup.appendChild(actions);
839
+ }
840
+ else {
841
+ const closeBtn = document.createElement('button');
842
+ closeBtn.className = 'wt-btn primary';
843
+ closeBtn.textContent = 'Close';
844
+ closeBtn.addEventListener('click', () => this.closeInspPopup());
845
+ const actions = document.createElement('div');
846
+ actions.className = 'insp-popup-actions';
847
+ actions.appendChild(closeBtn);
848
+ popup.appendChild(actions);
849
+ }
850
+ // Position near element
851
+ const rect = el.getBoundingClientRect();
852
+ popup.style.top = `${Math.min(rect.bottom + 10, window.innerHeight - 200)}px`;
853
+ popup.style.left = `${Math.min(rect.left, window.innerWidth - 296)}px`;
854
+ this.shadow.appendChild(popup);
855
+ this.inspPopup = popup;
856
+ }
857
+ closeInspPopup() {
858
+ this.inspPopup?.remove();
859
+ this.inspPopup = null;
860
+ }
861
+ // ── Add/Edit form ─────────────────────────────────────────────────────────
862
+ showAddForm(el, existing) {
863
+ this.closeAddForm();
864
+ const { selector, needsAttribute } = suggestSelector(el);
865
+ const form = document.createElement('div');
866
+ form.className = 'add-form';
867
+ form.setAttribute('role', 'dialog');
868
+ form.setAttribute('aria-modal', 'true');
869
+ form.setAttribute('aria-label', existing ? 'Edit stop' : 'Add stop');
870
+ const title = document.createElement('h3');
871
+ title.className = 'add-form-title';
872
+ title.textContent = existing ? 'Edit stop' : 'Add to tour';
873
+ form.appendChild(title);
874
+ const fields = [
875
+ { id: 'f-heading', label: 'Heading', value: existing?.heading ?? '' },
876
+ { id: 'f-target', label: 'Target (selector)', value: existing?.target ?? selector },
877
+ { id: 'f-data', label: 'Data (optional)', value: existing?.data ?? '' },
878
+ { id: 'f-code', label: 'Code file', value: existing?.code ?? '' },
879
+ { id: 'f-source', label: 'Source file (optional)', value: existing?.source ?? '' },
880
+ { id: 'f-docs', label: 'Docs URL (optional)', value: existing?.docs ?? '' },
881
+ { id: 'f-prose', label: 'Prose', value: existing?.prose ?? '', multi: true },
882
+ ];
883
+ const inputs = {};
884
+ for (const f of fields) {
885
+ const lbl = document.createElement('label');
886
+ lbl.setAttribute('for', f.id);
887
+ lbl.textContent = f.label;
888
+ form.appendChild(lbl);
889
+ const input = f.multi
890
+ ? document.createElement('textarea')
891
+ : document.createElement('input');
892
+ input.id = f.id;
893
+ input.value = f.value;
894
+ if (!f.multi)
895
+ input.type = 'text';
896
+ form.appendChild(input);
897
+ inputs[f.id] = input;
898
+ }
899
+ if (needsAttribute && !existing) {
900
+ const warn = document.createElement('div');
901
+ warn.className = 'add-form-warn';
902
+ warn.innerHTML = `This element has no <code>data-tour</code> attribute yet. Add this attribute to identify it:<br><code>data-tour="${selector.replace(/^\[data-tour="/, '').replace(/"\]$/, '')}"</code>`;
903
+ form.appendChild(warn);
904
+ }
905
+ const actions = document.createElement('div');
906
+ actions.className = 'add-form-actions';
907
+ const cancel = document.createElement('button');
908
+ cancel.className = 'wt-btn';
909
+ cancel.textContent = 'Cancel';
910
+ cancel.addEventListener('click', () => this.closeAddForm());
911
+ actions.appendChild(cancel);
912
+ const save = document.createElement('button');
913
+ save.className = 'wt-btn primary';
914
+ save.textContent = 'Save';
915
+ save.addEventListener('click', () => {
916
+ const stop = {
917
+ heading: inputs['f-heading'].value.trim(),
918
+ target: inputs['f-target'].value.trim(),
919
+ data: inputs['f-data'].value.trim() || undefined,
920
+ code: inputs['f-code'].value.trim(),
921
+ source: inputs['f-source'].value.trim() || undefined,
922
+ docs: inputs['f-docs'].value.trim() || undefined,
923
+ prose: inputs['f-prose'].value.trim(),
924
+ };
925
+ if (!stop.heading || !stop.target || !stop.code)
926
+ return;
927
+ this.saveStop(stop);
928
+ this.closeAddForm();
929
+ });
930
+ actions.appendChild(save);
931
+ form.appendChild(actions);
932
+ this.shadow.appendChild(form);
933
+ this.addForm = form;
934
+ inputs['f-heading'].focus();
935
+ }
936
+ closeAddForm() {
937
+ this.addForm?.remove();
938
+ this.addForm = null;
939
+ }
940
+ async saveStop(stop) {
941
+ // A page with no tour yet starts one at its own path.
942
+ const route = this.tour?.route ?? location.pathname;
943
+ try {
944
+ await fetch(`${this.base}/stop`, {
945
+ method: 'PUT',
946
+ headers: { 'Content-Type': 'application/json' },
947
+ body: JSON.stringify({ route, stop }),
948
+ });
949
+ }
950
+ catch {
951
+ // best-effort
952
+ }
953
+ }
954
+ // ── Keyboard ──────────────────────────────────────────────────────────────
955
+ handleKey(e) {
956
+ if (e.key === 'Escape') {
957
+ if (this.addForm) {
958
+ this.closeAddForm();
959
+ return;
960
+ }
961
+ if (this.inspPopup) {
962
+ this.closeInspPopup();
963
+ return;
964
+ }
965
+ if (this.walkthroughActive) {
966
+ this.stopWalkthrough();
967
+ return;
968
+ }
969
+ if (this.inspectorActive) {
970
+ this.stopInspector();
971
+ return;
972
+ }
973
+ }
974
+ if (this.walkthroughActive) {
975
+ if (e.key === 'ArrowRight')
976
+ this.nextWtStep();
977
+ if (e.key === 'ArrowLeft')
978
+ this.prevWtStep();
979
+ }
980
+ }
981
+ // ── SSE events ────────────────────────────────────────────────────────────
982
+ listenEvents() {
983
+ try {
984
+ const es = new EventSource(`${this.base}/events`);
985
+ es.addEventListener('message', (e) => {
986
+ if (e.data === 'change')
987
+ this.reload();
988
+ });
989
+ es.addEventListener('error', () => {
990
+ es.close();
991
+ });
992
+ this.evtSource = es;
993
+ }
994
+ catch {
995
+ // SSE not available
996
+ }
997
+ }
998
+ async reload() {
999
+ await this.load();
1000
+ if (this.walkthroughActive)
1001
+ this.stopWalkthrough();
1002
+ if (this.inspectorActive)
1003
+ this.stopInspector();
1004
+ this.render();
1005
+ }
1006
+ // ── URL change detection ──────────────────────────────────────────────────
1007
+ patchHistory() {
1008
+ const onNavigate = () => { setTimeout(() => this.reload(), 50); };
1009
+ const origPush = history.pushState.bind(history);
1010
+ history.pushState = (...args) => {
1011
+ origPush(...args);
1012
+ onNavigate();
1013
+ };
1014
+ const origReplace = history.replaceState.bind(history);
1015
+ history.replaceState = (...args) => {
1016
+ origReplace(...args);
1017
+ onNavigate();
1018
+ };
1019
+ window.addEventListener('popstate', onNavigate);
1020
+ }
1021
+ }
1022
+ // ---------------------------------------------------------------------------
1023
+ // Bootstrap
1024
+ // ---------------------------------------------------------------------------
1025
+ function boot() {
1026
+ if (document.querySelector('[data-docugate-pill]'))
1027
+ return; // already mounted
1028
+ const base = window.DOCUGATE_TOUR ? '' : (getServerBase() ?? '');
1029
+ if (!window.DOCUGATE_TOUR && !base)
1030
+ return; // no server src and no static tour
1031
+ new DocugatePill(base);
1032
+ }
1033
+ if (typeof document !== 'undefined') {
1034
+ if (document.readyState === 'loading') {
1035
+ document.addEventListener('DOMContentLoaded', boot);
1036
+ }
1037
+ else {
1038
+ boot();
1039
+ }
1040
+ }
1041
+
1042
+ })();