eddie-jekyll 0.2.0 → 0.2.4

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,1043 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+
3
+ // Eddie search widget
4
+ //
5
+ // Self-contained vanilla JS widget using Shadow DOM for style isolation.
6
+ // Embeds a search modal that communicates with a Web Worker running
7
+ // WASM-based semantic + keyword search.
8
+
9
+ "use strict";
10
+
11
+ (function () {
12
+ const scriptEl = document.currentScript;
13
+ if (!scriptEl) return;
14
+
15
+ function parseOffsetPx(attrName) {
16
+ const raw = scriptEl.getAttribute(attrName);
17
+ if (raw == null || raw === "") return 0;
18
+ const value = Number(raw);
19
+ return Number.isFinite(value) ? Math.trunc(value) : 0;
20
+ }
21
+
22
+ function parseIntAttr(attrName, fallback) {
23
+ const raw = scriptEl.getAttribute(attrName);
24
+ if (raw == null || raw === "") return fallback;
25
+ const value = Number.parseInt(raw, 10);
26
+ return Number.isFinite(value) && value > 0 ? value : fallback;
27
+ }
28
+
29
+ function normalizeQaMode(raw) {
30
+ const value = (raw || "").toLowerCase();
31
+ if (value === "off" || value === "always" || value === "auto") {
32
+ return value;
33
+ }
34
+ return "auto";
35
+ }
36
+
37
+ function normalizePosition(raw) {
38
+ const value = (raw || "").toLowerCase();
39
+ const allowed = new Set([
40
+ "top-left",
41
+ "top-right",
42
+ "bottom-left",
43
+ "bottom-right",
44
+ ]);
45
+ return allowed.has(value) ? value : "bottom-right";
46
+ }
47
+
48
+ const config = {
49
+ indexUrl: scriptEl.getAttribute("data-index-url") || "/eddie/index.ed",
50
+ position: normalizePosition(scriptEl.getAttribute("data-position")),
51
+ theme: scriptEl.getAttribute("data-theme") || "auto",
52
+ offsetY: parseOffsetPx("data-offset-y"),
53
+ offsetX: parseOffsetPx("data-offset-x"),
54
+ qaMode: normalizeQaMode(scriptEl.getAttribute("data-qa-mode")),
55
+ qaSubject: (scriptEl.getAttribute("data-qa-subject") || "").toLowerCase(),
56
+ resultTopK: parseIntAttr("data-top-k", 8),
57
+ answerTopK: parseIntAttr("data-answer-top-k", 5),
58
+ };
59
+
60
+ const HEART_SPRITES = [
61
+ [".11111.", "1222221", "1222221", ".12221."], // solid
62
+ [".11111.", "12.2.21", "1222221", ".12.21."], // circuit
63
+ [".13331.", "1344431", "1244421", ".12221."], // beveled
64
+ [".13131.", "1344431", "12.4.21", ".12221."], // gear-ish
65
+ ];
66
+
67
+ const HEART_PALETTE = {
68
+ "1": "#f2c94c",
69
+ "2": "#e0b63f",
70
+ "3": "#f8dda1",
71
+ "4": "#b78e28",
72
+ };
73
+
74
+ // Resolve asset URLs relative to this script's location
75
+ const scriptSrc = new URL(scriptEl.src, location.href);
76
+ const baseUrl = scriptSrc.href.substring(0, scriptSrc.href.lastIndexOf("/") + 1);
77
+
78
+ function resolveAsset(name) {
79
+ return baseUrl + name;
80
+ }
81
+
82
+ // -- State --
83
+ let worker = null;
84
+ let searchRequestId = 0;
85
+ let isOpen = false;
86
+ let selectedIndex = -1;
87
+ let currentResults = [];
88
+ let currentAnswer = null;
89
+ let engineState = "idle"; // idle | loading | ready | error
90
+ let lastHeartIndex = -1;
91
+ let activeRequestId = 0;
92
+ let searchPending = false;
93
+
94
+ // -- DOM setup --
95
+ const host = document.createElement("div");
96
+ host.id = "eddie-host";
97
+ const shadow = host.attachShadow({ mode: "closed" });
98
+
99
+ // -- Styles --
100
+ const style = document.createElement("style");
101
+ style.textContent = `
102
+ :host {
103
+ --sa-font: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif;
104
+ --sa-font-mono: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
105
+ --sa-bg: #ffffff;
106
+ --sa-bg-elevated: #f6f6f6;
107
+ --sa-text: #1a1a1a;
108
+ --sa-text-muted: #6b6b6b;
109
+ --sa-border: #e0e0e0;
110
+ --sa-accent: #2563eb;
111
+ --sa-accent-soft: rgba(37, 99, 235, 0.08);
112
+ --sa-backdrop: rgba(0, 0, 0, 0.4);
113
+ --sa-shadow: 0 16px 48px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08);
114
+ --sa-radius: 12px;
115
+ --sa-radius-sm: 6px;
116
+ --sa-trigger-size: 48px;
117
+
118
+ all: initial;
119
+ font-family: var(--sa-font);
120
+ position: fixed;
121
+ z-index: 999999;
122
+ }
123
+
124
+ @media (prefers-color-scheme: dark) {
125
+ :host {
126
+ --sa-bg: #1a1a1a;
127
+ --sa-bg-elevated: #252525;
128
+ --sa-text: #e8e8e8;
129
+ --sa-text-muted: #999999;
130
+ --sa-border: #333333;
131
+ --sa-accent: #60a5fa;
132
+ --sa-accent-soft: rgba(96, 165, 250, 0.1);
133
+ --sa-backdrop: rgba(0, 0, 0, 0.6);
134
+ --sa-shadow: 0 16px 48px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
135
+ }
136
+ }
137
+
138
+ *, *::before, *::after {
139
+ box-sizing: border-box;
140
+ margin: 0;
141
+ padding: 0;
142
+ }
143
+
144
+ .sa-trigger {
145
+ position: fixed;
146
+ width: var(--sa-trigger-size);
147
+ height: var(--sa-trigger-size);
148
+ border-radius: 50%;
149
+ border: 1px solid var(--sa-border);
150
+ background: var(--sa-bg);
151
+ color: var(--sa-text);
152
+ cursor: pointer;
153
+ display: flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ box-shadow: 0 2px 12px rgba(0,0,0,0.1);
157
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
158
+ }
159
+ .sa-trigger:hover {
160
+ transform: scale(1.06);
161
+ box-shadow: 0 4px 16px rgba(0,0,0,0.15);
162
+ }
163
+ .sa-trigger:active {
164
+ transform: scale(0.96);
165
+ }
166
+ .sa-trigger svg {
167
+ width: 20px;
168
+ height: 20px;
169
+ stroke: currentColor;
170
+ fill: none;
171
+ stroke-width: 2;
172
+ stroke-linecap: round;
173
+ stroke-linejoin: round;
174
+ }
175
+
176
+ .sa-pos-bottom-right { right: 24px; bottom: 24px; }
177
+ .sa-pos-bottom-left { left: 24px; bottom: 24px; }
178
+ .sa-pos-top-right { right: 24px; top: 24px; }
179
+ .sa-pos-top-left { left: 24px; top: 24px; }
180
+
181
+ .sa-backdrop {
182
+ position: fixed;
183
+ inset: 0;
184
+ background: var(--sa-backdrop);
185
+ display: none;
186
+ align-items: flex-start;
187
+ justify-content: center;
188
+ padding-top: 12vh;
189
+ }
190
+ .sa-backdrop.sa-open {
191
+ display: flex;
192
+ }
193
+
194
+ .sa-modal {
195
+ background: var(--sa-bg);
196
+ border: 1px solid var(--sa-border);
197
+ border-radius: var(--sa-radius);
198
+ box-shadow: var(--sa-shadow);
199
+ width: 100%;
200
+ max-width: 600px;
201
+ max-height: 72vh;
202
+ display: flex;
203
+ flex-direction: column;
204
+ overflow: hidden;
205
+ animation: sa-slide-in 0.18s ease-out;
206
+ }
207
+ @keyframes sa-slide-in {
208
+ from { opacity: 0; transform: translateY(-12px) scale(0.98); }
209
+ to { opacity: 1; transform: translateY(0) scale(1); }
210
+ }
211
+
212
+ .sa-header {
213
+ display: flex;
214
+ align-items: center;
215
+ padding: 16px;
216
+ gap: 12px;
217
+ border-bottom: 1px solid var(--sa-border);
218
+ }
219
+
220
+ .sa-search-icon {
221
+ flex-shrink: 0;
222
+ width: 18px;
223
+ height: 18px;
224
+ stroke: var(--sa-text-muted);
225
+ fill: none;
226
+ stroke-width: 2;
227
+ stroke-linecap: round;
228
+ stroke-linejoin: round;
229
+ }
230
+
231
+ .sa-input {
232
+ flex: 1;
233
+ border: none;
234
+ background: none;
235
+ font-family: var(--sa-font);
236
+ font-size: 16px;
237
+ color: var(--sa-text);
238
+ outline: none;
239
+ }
240
+ .sa-input::placeholder {
241
+ color: var(--sa-text-muted);
242
+ }
243
+
244
+ .sa-close {
245
+ flex-shrink: 0;
246
+ width: 28px;
247
+ height: 28px;
248
+ border-radius: var(--sa-radius-sm);
249
+ border: 1px solid var(--sa-border);
250
+ background: var(--sa-bg-elevated);
251
+ color: var(--sa-text-muted);
252
+ cursor: pointer;
253
+ display: flex;
254
+ align-items: center;
255
+ justify-content: center;
256
+ font-family: var(--sa-font-mono);
257
+ font-size: 11px;
258
+ line-height: 1;
259
+ transition: border-color 0.1s;
260
+ }
261
+ .sa-close:hover {
262
+ border-color: var(--sa-text-muted);
263
+ }
264
+
265
+ .sa-heart {
266
+ flex-shrink: 0;
267
+ width: 14px;
268
+ height: 8px;
269
+ image-rendering: pixelated;
270
+ image-rendering: crisp-edges;
271
+ opacity: 0.95;
272
+ display: block;
273
+ }
274
+
275
+ .sa-status {
276
+ padding: 12px 16px;
277
+ font-size: 13px;
278
+ color: var(--sa-text-muted);
279
+ display: none;
280
+ align-items: center;
281
+ gap: 10px;
282
+ border-bottom: 1px solid var(--sa-border);
283
+ }
284
+ .sa-status.sa-visible {
285
+ display: flex;
286
+ }
287
+
288
+ .sa-progress-bar {
289
+ flex: 1;
290
+ height: 3px;
291
+ background: var(--sa-bg-elevated);
292
+ border-radius: 2px;
293
+ overflow: hidden;
294
+ }
295
+ .sa-progress-fill {
296
+ height: 100%;
297
+ background: var(--sa-accent);
298
+ border-radius: 2px;
299
+ width: 0%;
300
+ transition: width 0.2s ease;
301
+ }
302
+ .sa-progress-indeterminate .sa-progress-fill {
303
+ width: 40%;
304
+ animation: sa-indeterminate 1.2s ease-in-out infinite;
305
+ }
306
+ @keyframes sa-indeterminate {
307
+ 0% { transform: translateX(-100%); }
308
+ 100% { transform: translateX(350%); }
309
+ }
310
+
311
+ .sa-results {
312
+ flex: 1;
313
+ overflow-y: auto;
314
+ list-style: none;
315
+ }
316
+
317
+ .sa-answer {
318
+ display: none;
319
+ border-bottom: 1px solid var(--sa-border);
320
+ background: var(--sa-bg-elevated);
321
+ padding: 12px 16px;
322
+ gap: 6px;
323
+ flex-direction: column;
324
+ }
325
+ .sa-answer.sa-visible {
326
+ display: flex;
327
+ }
328
+ .sa-answer-label {
329
+ font-size: 11px;
330
+ letter-spacing: 0.08em;
331
+ text-transform: uppercase;
332
+ color: var(--sa-text-muted);
333
+ font-family: var(--sa-font-mono);
334
+ }
335
+ .sa-answer-text {
336
+ font-size: 14px;
337
+ line-height: 1.45;
338
+ color: var(--sa-text);
339
+ }
340
+ .sa-answer-cites {
341
+ display: flex;
342
+ flex-wrap: wrap;
343
+ gap: 8px;
344
+ margin-top: 2px;
345
+ }
346
+ .sa-answer-cite {
347
+ font-size: 11px;
348
+ color: var(--sa-accent);
349
+ text-decoration: none;
350
+ border: 1px solid var(--sa-border);
351
+ border-radius: 999px;
352
+ padding: 2px 8px;
353
+ }
354
+ .sa-answer-cite:hover {
355
+ border-color: var(--sa-accent);
356
+ }
357
+
358
+ .sa-result {
359
+ display: block;
360
+ padding: 12px 16px;
361
+ border-bottom: 1px solid var(--sa-border);
362
+ cursor: pointer;
363
+ text-decoration: none;
364
+ color: inherit;
365
+ transition: background 0.08s;
366
+ }
367
+ .sa-result:last-child {
368
+ border-bottom: none;
369
+ }
370
+ .sa-result:hover,
371
+ .sa-result[aria-selected="true"] {
372
+ background: var(--sa-accent-soft);
373
+ }
374
+ .sa-result-title {
375
+ font-size: 14px;
376
+ font-weight: 600;
377
+ color: var(--sa-text);
378
+ margin-bottom: 2px;
379
+ }
380
+ .sa-result-url {
381
+ font-family: var(--sa-font-mono);
382
+ font-size: 11px;
383
+ color: var(--sa-accent);
384
+ margin-bottom: 4px;
385
+ }
386
+ .sa-result-section {
387
+ font-size: 11px;
388
+ color: var(--sa-text-muted);
389
+ margin-bottom: 4px;
390
+ }
391
+ .sa-result-snippet {
392
+ font-size: 13px;
393
+ color: var(--sa-text-muted);
394
+ line-height: 1.45;
395
+ }
396
+
397
+ .sa-empty {
398
+ padding: 32px 16px;
399
+ text-align: center;
400
+ color: var(--sa-text-muted);
401
+ font-size: 14px;
402
+ }
403
+
404
+ .sa-error {
405
+ padding: 12px 16px;
406
+ font-size: 13px;
407
+ color: #dc2626;
408
+ display: none;
409
+ }
410
+ .sa-error.sa-visible {
411
+ display: block;
412
+ }
413
+
414
+ .sa-footer {
415
+ padding: 8px 16px;
416
+ border-top: 1px solid var(--sa-border);
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: space-between;
420
+ font-size: 11px;
421
+ color: var(--sa-text-muted);
422
+ }
423
+ .sa-footer kbd {
424
+ display: inline-block;
425
+ padding: 1px 5px;
426
+ font-family: var(--sa-font-mono);
427
+ font-size: 10px;
428
+ border: 1px solid var(--sa-border);
429
+ border-radius: 3px;
430
+ background: var(--sa-bg-elevated);
431
+ margin: 0 2px;
432
+ }
433
+
434
+ .sa-brand {
435
+ display: inline-flex;
436
+ align-items: center;
437
+ gap: 6px;
438
+ letter-spacing: 0.08em;
439
+ font-weight: 600;
440
+ }
441
+
442
+ .sa-brand-link {
443
+ color: var(--sa-text-muted);
444
+ text-decoration: none;
445
+ border: 1px solid var(--sa-border);
446
+ border-radius: 999px;
447
+ padding: 2px 8px;
448
+ transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
449
+ }
450
+ .sa-brand-link:hover {
451
+ border-color: var(--sa-text-muted);
452
+ color: var(--sa-text);
453
+ background: var(--sa-bg-elevated);
454
+ }
455
+ .sa-brand-link:focus-visible {
456
+ outline: 1px solid var(--sa-accent);
457
+ outline-offset: 2px;
458
+ }
459
+
460
+ /* Mobile: bottom sheet */
461
+ @media (max-width: 640px) {
462
+ .sa-backdrop {
463
+ padding-top: 0;
464
+ align-items: flex-end;
465
+ }
466
+ .sa-modal {
467
+ max-width: 100%;
468
+ max-height: 85vh;
469
+ border-radius: var(--sa-radius) var(--sa-radius) 0 0;
470
+ animation-name: sa-slide-up;
471
+ }
472
+ @keyframes sa-slide-up {
473
+ from { opacity: 0; transform: translateY(40px); }
474
+ to { opacity: 1; transform: translateY(0); }
475
+ }
476
+ }
477
+ `;
478
+ shadow.appendChild(style);
479
+
480
+ // -- SVG helper --
481
+ function createSearchSvg(className) {
482
+ const ns = "http://www.w3.org/2000/svg";
483
+ const svg = document.createElementNS(ns, "svg");
484
+ svg.setAttribute("viewBox", "0 0 24 24");
485
+ if (className) svg.setAttribute("class", className);
486
+ const circle = document.createElementNS(ns, "circle");
487
+ circle.setAttribute("cx", "11");
488
+ circle.setAttribute("cy", "11");
489
+ circle.setAttribute("r", "8");
490
+ const line = document.createElementNS(ns, "line");
491
+ line.setAttribute("x1", "21");
492
+ line.setAttribute("y1", "21");
493
+ line.setAttribute("x2", "16.65");
494
+ line.setAttribute("y2", "16.65");
495
+ svg.appendChild(circle);
496
+ svg.appendChild(line);
497
+ return svg;
498
+ }
499
+
500
+ // -- Trigger button --
501
+ const trigger = document.createElement("button");
502
+ trigger.className = `sa-trigger sa-pos-${config.position}`;
503
+ trigger.setAttribute("aria-label", "Search");
504
+ trigger.appendChild(createSearchSvg());
505
+ trigger.addEventListener("click", openModal);
506
+ applyTriggerOffsets();
507
+ shadow.appendChild(trigger);
508
+
509
+ // -- Backdrop --
510
+ const backdrop = document.createElement("div");
511
+ backdrop.className = "sa-backdrop";
512
+ backdrop.addEventListener("click", (e) => {
513
+ if (e.target === backdrop) closeModal();
514
+ });
515
+ shadow.appendChild(backdrop);
516
+
517
+ // -- Modal --
518
+ const modal = document.createElement("div");
519
+ modal.className = "sa-modal";
520
+ modal.setAttribute("role", "dialog");
521
+ modal.setAttribute("aria-modal", "true");
522
+ modal.setAttribute("aria-label", "Search");
523
+ backdrop.appendChild(modal);
524
+
525
+ // Header
526
+ const header = document.createElement("div");
527
+ header.className = "sa-header";
528
+ header.appendChild(createSearchSvg("sa-search-icon"));
529
+ modal.appendChild(header);
530
+
531
+ const input = document.createElement("input");
532
+ input.className = "sa-input";
533
+ input.type = "text";
534
+ input.setAttribute("role", "searchbox");
535
+ input.setAttribute("aria-label", "Search query");
536
+ input.placeholder = "Search\u2026";
537
+ header.appendChild(input);
538
+
539
+ const closeBtn = document.createElement("button");
540
+ closeBtn.className = "sa-close";
541
+ closeBtn.setAttribute("aria-label", "Close");
542
+ closeBtn.textContent = "esc";
543
+ closeBtn.addEventListener("click", closeModal);
544
+
545
+ const heart = document.createElement("canvas");
546
+ heart.className = "sa-heart";
547
+ heart.width = 7;
548
+ heart.height = 4;
549
+ heart.setAttribute("aria-hidden", "true");
550
+ drawHeartSprite(0);
551
+ header.appendChild(closeBtn);
552
+
553
+ // Status bar
554
+ const status = document.createElement("div");
555
+ status.className = "sa-status";
556
+ modal.appendChild(status);
557
+
558
+ const statusText = document.createElement("span");
559
+ status.appendChild(statusText);
560
+
561
+ const progressBar = document.createElement("div");
562
+ progressBar.className = "sa-progress-bar";
563
+ const progressFill = document.createElement("div");
564
+ progressFill.className = "sa-progress-fill";
565
+ progressBar.appendChild(progressFill);
566
+ status.appendChild(progressBar);
567
+
568
+ // Error area
569
+ const errorEl = document.createElement("div");
570
+ errorEl.className = "sa-error";
571
+ modal.appendChild(errorEl);
572
+
573
+ // Answer area (experimental factual mode)
574
+ const answerEl = document.createElement("div");
575
+ answerEl.className = "sa-answer";
576
+ modal.appendChild(answerEl);
577
+
578
+ // Results
579
+ const resultsList = document.createElement("ul");
580
+ resultsList.className = "sa-results";
581
+ resultsList.setAttribute("role", "listbox");
582
+ modal.appendChild(resultsList);
583
+
584
+ // Footer (built with DOM, not innerHTML)
585
+ const footer = document.createElement("div");
586
+ footer.className = "sa-footer";
587
+
588
+ const footerNav = document.createElement("span");
589
+ const keys = [
590
+ ["\u2191", ""],
591
+ ["\u2193", " navigate "],
592
+ ["enter", " open"],
593
+ ];
594
+ keys.forEach(([key, after]) => {
595
+ const kbd = document.createElement("kbd");
596
+ kbd.textContent = key;
597
+ footerNav.appendChild(kbd);
598
+ if (after) footerNav.appendChild(document.createTextNode(after));
599
+ });
600
+ footer.appendChild(footerNav);
601
+
602
+ const footerBrandLink = document.createElement("a");
603
+ footerBrandLink.className = "sa-brand-link";
604
+ footerBrandLink.href = "https://github.com/jt55401/eddie";
605
+ footerBrandLink.target = "_blank";
606
+ footerBrandLink.rel = "noopener noreferrer";
607
+ footerBrandLink.setAttribute("aria-label", "Eddie on GitHub (opens in a new tab)");
608
+
609
+ const footerBrand = document.createElement("span");
610
+ footerBrand.className = "sa-brand";
611
+ footerBrand.appendChild(document.createTextNode("EDDIE"));
612
+ footerBrand.appendChild(heart);
613
+ footerBrandLink.appendChild(footerBrand);
614
+ footer.appendChild(footerBrandLink);
615
+
616
+ modal.appendChild(footer);
617
+
618
+ // -- Keyboard handling --
619
+ input.addEventListener("keydown", (e) => {
620
+ if (e.key === "Enter") {
621
+ e.preventDefault();
622
+ if (selectedIndex >= 0 && selectedIndex < currentResults.length) {
623
+ navigateToResult(currentResults[selectedIndex]);
624
+ } else if (input.value.trim()) {
625
+ doSearch(input.value.trim());
626
+ }
627
+ } else if (e.key === "ArrowDown") {
628
+ e.preventDefault();
629
+ moveSelection(1);
630
+ } else if (e.key === "ArrowUp") {
631
+ e.preventDefault();
632
+ moveSelection(-1);
633
+ } else if (e.key === "Escape") {
634
+ e.preventDefault();
635
+ closeModal();
636
+ }
637
+ });
638
+
639
+ // Debounced search-as-you-type
640
+ let searchTimer = null;
641
+ input.addEventListener("input", () => {
642
+ clearTimeout(searchTimer);
643
+ const q = input.value.trim();
644
+ if (q.length >= 2 && engineState === "ready") {
645
+ searchTimer = setTimeout(() => doSearch(q), 200);
646
+ } else if (q.length === 0) {
647
+ clearResults();
648
+ }
649
+ });
650
+
651
+ // Focus trap
652
+ modal.addEventListener("keydown", (e) => {
653
+ if (e.key === "Escape") {
654
+ e.preventDefault();
655
+ closeModal();
656
+ return;
657
+ }
658
+ if (e.key !== "Tab") return;
659
+
660
+ const focusable = [input, closeBtn];
661
+ const first = focusable[0];
662
+ const last = focusable[focusable.length - 1];
663
+
664
+ if (e.shiftKey) {
665
+ if (shadow.activeElement === first) {
666
+ e.preventDefault();
667
+ last.focus();
668
+ }
669
+ } else {
670
+ if (shadow.activeElement === last) {
671
+ e.preventDefault();
672
+ first.focus();
673
+ }
674
+ }
675
+ });
676
+
677
+ // -- Worker communication --
678
+ function ensureWorker() {
679
+ if (worker) return;
680
+
681
+ worker = new Worker(resolveAsset("eddie-worker.js"));
682
+ worker.onmessage = (e) => {
683
+ const msg = e.data;
684
+
685
+ if (msg.type === "status") {
686
+ handleStatus(msg);
687
+ } else if (msg.type === "search_result") {
688
+ handleSearchResult(msg);
689
+ } else if (msg.type === "error") {
690
+ handleError(msg);
691
+ }
692
+ };
693
+
694
+ engineState = "loading";
695
+ worker.postMessage({
696
+ type: "init",
697
+ indexUrl: new URL(config.indexUrl, location.href).href,
698
+ baseUrl: baseUrl,
699
+ });
700
+ }
701
+
702
+ function handleStatus(msg) {
703
+ const stateLabels = {
704
+ loading_wasm: "Loading search engine\u2026",
705
+ loading_index: "Loading index\u2026",
706
+ checking_cache: "Checking model cache\u2026",
707
+ downloading_model: "Downloading model\u2026",
708
+ initializing: "Initializing\u2026",
709
+ ready: "Ready",
710
+ };
711
+
712
+ if (msg.state === "ready") {
713
+ engineState = "ready";
714
+ showStatus(false);
715
+ // If there's already a query waiting, run it
716
+ if (input.value.trim().length >= 2) {
717
+ doSearch(input.value.trim());
718
+ }
719
+ return;
720
+ }
721
+
722
+ if (msg.state === "error") {
723
+ engineState = "error";
724
+ showError(msg.error || "Failed to initialize");
725
+ showStatus(false);
726
+ return;
727
+ }
728
+
729
+ engineState = "loading";
730
+ statusText.textContent = stateLabels[msg.state] || msg.state;
731
+ showStatus(true);
732
+
733
+ if (msg.state === "downloading_model" && msg.progress != null) {
734
+ progressBar.classList.remove("sa-progress-indeterminate");
735
+ progressFill.style.width = Math.round(msg.progress * 100) + "%";
736
+ statusText.textContent =
737
+ "Downloading " + (msg.file || "model") + "\u2026 " +
738
+ Math.round(msg.progress * 100) + "%";
739
+ } else {
740
+ progressBar.classList.add("sa-progress-indeterminate");
741
+ progressFill.style.width = "";
742
+ }
743
+ }
744
+
745
+ function handleSearchResult(msg) {
746
+ if (msg.requestId !== activeRequestId) return;
747
+ currentResults = msg.results || [];
748
+ currentAnswer = msg.answer || null;
749
+ searchPending = false;
750
+ maybeFinalizeQuery();
751
+ }
752
+
753
+ function maybeFinalizeQuery() {
754
+ if (searchPending) {
755
+ return;
756
+ }
757
+
758
+ selectedIndex = -1;
759
+ showStatus(false);
760
+ renderResults();
761
+ }
762
+
763
+ function handleError(msg) {
764
+ if (msg.requestId && msg.requestId !== activeRequestId) return;
765
+ showError(msg.error || "Search failed");
766
+ }
767
+
768
+ function doSearch(query) {
769
+ if (!worker || engineState !== "ready") return;
770
+ searchRequestId += 1;
771
+ activeRequestId = searchRequestId;
772
+ currentResults = [];
773
+ currentAnswer = null;
774
+ const answerMode = shouldUseAnswerMode(query);
775
+ searchPending = true;
776
+
777
+ if (answerMode) {
778
+ statusText.textContent = "Searching and grounding answer...";
779
+ progressBar.classList.add("sa-progress-indeterminate");
780
+ progressFill.style.width = "";
781
+ showStatus(true);
782
+ }
783
+
784
+ worker.postMessage({
785
+ type: "search",
786
+ requestId: activeRequestId,
787
+ query: query,
788
+ topK: config.resultTopK,
789
+ answerTopK: config.answerTopK,
790
+ answerMode: answerMode,
791
+ qaSubject: config.qaSubject || "",
792
+ mode: "hybrid",
793
+ });
794
+ }
795
+
796
+ // -- Rendering --
797
+ function renderResults() {
798
+ resultsList.textContent = "";
799
+ errorEl.classList.remove("sa-visible");
800
+ renderAnswer();
801
+
802
+ if (currentResults.length === 0 && input.value.trim()) {
803
+ const empty = document.createElement("div");
804
+ empty.className = "sa-empty";
805
+ empty.textContent = "No results found.";
806
+ resultsList.appendChild(empty);
807
+ return;
808
+ }
809
+
810
+ currentResults.forEach((r, i) => {
811
+ const li = document.createElement("a");
812
+ li.className = "sa-result";
813
+ li.href = r.url;
814
+ li.setAttribute("role", "option");
815
+ li.setAttribute("aria-selected", i === selectedIndex ? "true" : "false");
816
+
817
+ const titleEl = document.createElement("div");
818
+ titleEl.className = "sa-result-title";
819
+ titleEl.textContent = r.title;
820
+ li.appendChild(titleEl);
821
+
822
+ const urlEl = document.createElement("div");
823
+ urlEl.className = "sa-result-url";
824
+ urlEl.textContent = r.url;
825
+ li.appendChild(urlEl);
826
+
827
+ if (
828
+ r.section &&
829
+ r.section !== "Semantic Segment" &&
830
+ r.section !== "Summary Lane"
831
+ ) {
832
+ const sectionEl = document.createElement("div");
833
+ sectionEl.className = "sa-result-section";
834
+ sectionEl.textContent = r.section;
835
+ li.appendChild(sectionEl);
836
+ }
837
+
838
+ if (r.snippet) {
839
+ const snippetEl = document.createElement("div");
840
+ snippetEl.className = "sa-result-snippet";
841
+ snippetEl.textContent = r.snippet;
842
+ li.appendChild(snippetEl);
843
+ }
844
+
845
+ li.addEventListener("click", (e) => {
846
+ e.preventDefault();
847
+ navigateToResult(r);
848
+ });
849
+
850
+ resultsList.appendChild(li);
851
+ });
852
+ }
853
+
854
+ function moveSelection(delta) {
855
+ if (currentResults.length === 0) return;
856
+
857
+ selectedIndex += delta;
858
+ if (selectedIndex < 0) selectedIndex = currentResults.length - 1;
859
+ if (selectedIndex >= currentResults.length) selectedIndex = 0;
860
+
861
+ const items = resultsList.querySelectorAll(".sa-result");
862
+ items.forEach((el, i) => {
863
+ el.setAttribute("aria-selected", i === selectedIndex ? "true" : "false");
864
+ });
865
+
866
+ // Scroll selected into view
867
+ const selected = items[selectedIndex];
868
+ if (selected) {
869
+ selected.scrollIntoView({ block: "nearest" });
870
+ }
871
+ }
872
+
873
+ function navigateToResult(r) {
874
+ closeModal();
875
+ window.location.href = r.url;
876
+ }
877
+
878
+ function clearResults() {
879
+ currentResults = [];
880
+ currentAnswer = null;
881
+ selectedIndex = -1;
882
+ searchPending = false;
883
+ resultsList.textContent = "";
884
+ answerEl.classList.remove("sa-visible");
885
+ }
886
+
887
+ function applyTriggerOffsets() {
888
+ const baseInset = 24;
889
+ const vertical = `${baseInset + config.offsetY}px`;
890
+ const horizontal = `${baseInset + config.offsetX}px`;
891
+
892
+ trigger.style.top = "";
893
+ trigger.style.bottom = "";
894
+ trigger.style.left = "";
895
+ trigger.style.right = "";
896
+
897
+ switch (config.position) {
898
+ case "top-left":
899
+ trigger.style.top = vertical;
900
+ trigger.style.left = horizontal;
901
+ break;
902
+ case "top-right":
903
+ trigger.style.top = vertical;
904
+ trigger.style.right = horizontal;
905
+ break;
906
+ case "bottom-left":
907
+ trigger.style.bottom = vertical;
908
+ trigger.style.left = horizontal;
909
+ break;
910
+ default:
911
+ trigger.style.bottom = vertical;
912
+ trigger.style.right = horizontal;
913
+ break;
914
+ }
915
+ }
916
+
917
+ // -- Modal open/close --
918
+ function openModal() {
919
+ isOpen = true;
920
+ rotateHeartSprite();
921
+ backdrop.classList.add("sa-open");
922
+ trigger.style.display = "none";
923
+ input.value = "";
924
+ clearResults();
925
+ ensureWorker();
926
+ // Focus after animation frame so the browser paints first
927
+ requestAnimationFrame(() => input.focus());
928
+ }
929
+
930
+ function closeModal() {
931
+ isOpen = false;
932
+ backdrop.classList.remove("sa-open");
933
+ trigger.style.display = "";
934
+ trigger.focus();
935
+ }
936
+
937
+ function rotateHeartSprite() {
938
+ if (HEART_SPRITES.length === 0) return;
939
+ let idx = Math.floor(Math.random() * HEART_SPRITES.length);
940
+ if (HEART_SPRITES.length > 1 && idx === lastHeartIndex) {
941
+ idx = (idx + 1) % HEART_SPRITES.length;
942
+ }
943
+ lastHeartIndex = idx;
944
+ drawHeartSprite(idx);
945
+ }
946
+
947
+ function drawHeartSprite(idx) {
948
+ const sprite = HEART_SPRITES[idx];
949
+ const ctx = heart.getContext("2d");
950
+ if (!sprite || !ctx) return;
951
+ ctx.clearRect(0, 0, heart.width, heart.height);
952
+
953
+ for (let y = 0; y < sprite.length; y++) {
954
+ const row = sprite[y];
955
+ for (let x = 0; x < row.length; x++) {
956
+ const key = row[x];
957
+ if (key === ".") continue;
958
+ const color = HEART_PALETTE[key];
959
+ if (!color) continue;
960
+ ctx.fillStyle = color;
961
+ ctx.fillRect(x, y, 1, 1);
962
+ }
963
+ }
964
+ }
965
+
966
+ // -- Helpers --
967
+ function showStatus(visible) {
968
+ status.classList.toggle("sa-visible", visible);
969
+ }
970
+
971
+ function showError(message) {
972
+ errorEl.textContent = message;
973
+ errorEl.classList.add("sa-visible");
974
+ }
975
+
976
+ function shouldUseAnswerMode(query) {
977
+ if (config.qaMode === "off") return false;
978
+ if (config.qaMode === "always") return true;
979
+ return looksFactualQuery(query);
980
+ }
981
+
982
+ function looksFactualQuery(query) {
983
+ const q = query.toLowerCase().trim();
984
+ if (!q) return false;
985
+ if (q.includes("?")) return true;
986
+ if (/^(who|what|when|where|why|how|does|do|is|are|can|could|should)\b/i.test(q)) {
987
+ return true;
988
+ }
989
+ return q.split(/\s+/).length >= 5;
990
+ }
991
+
992
+ function renderAnswer() {
993
+ answerEl.textContent = "";
994
+ if (!currentAnswer || !currentAnswer.text) {
995
+ answerEl.classList.remove("sa-visible");
996
+ return;
997
+ }
998
+
999
+ const label = document.createElement("div");
1000
+ label.className = "sa-answer-label";
1001
+ label.textContent = "Experimental Answer";
1002
+ answerEl.appendChild(label);
1003
+
1004
+ const text = document.createElement("div");
1005
+ text.className = "sa-answer-text";
1006
+ text.textContent = currentAnswer.text;
1007
+ answerEl.appendChild(text);
1008
+
1009
+ if (currentAnswer.citations && currentAnswer.citations.length > 0) {
1010
+ const cites = document.createElement("div");
1011
+ cites.className = "sa-answer-cites";
1012
+ currentAnswer.citations.slice(0, 3).forEach((url) => {
1013
+ const a = document.createElement("a");
1014
+ a.className = "sa-answer-cite";
1015
+ a.href = url;
1016
+ a.textContent = "source";
1017
+ a.addEventListener("click", (e) => {
1018
+ e.preventDefault();
1019
+ navigateToResult({ url: url });
1020
+ });
1021
+ cites.appendChild(a);
1022
+ });
1023
+ answerEl.appendChild(cites);
1024
+ }
1025
+
1026
+ answerEl.classList.add("sa-visible");
1027
+ }
1028
+
1029
+ // -- Global keyboard shortcut: Ctrl+K or Cmd+K --
1030
+ document.addEventListener("keydown", (e) => {
1031
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
1032
+ e.preventDefault();
1033
+ if (isOpen) {
1034
+ closeModal();
1035
+ } else {
1036
+ openModal();
1037
+ }
1038
+ }
1039
+ });
1040
+
1041
+ // -- Mount --
1042
+ document.body.appendChild(host);
1043
+ })();