@harness-fe/runtime 3.3.0 → 3.4.1

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.
package/dist/index.d.ts CHANGED
@@ -6,3 +6,5 @@
6
6
  */
7
7
  export { RuntimeClient, tryInheritFromParent } from './client.js';
8
8
  export type { ClientOptions, ParentInheritance } from './client.js';
9
+ export { registerOverlayPlugin, getOverlayPlugins, subscribeOverlayPlugins, } from './pluginRegistry.js';
10
+ export type { OverlayPlugin, OverlayPluginContext, OverlayPluginSelectedElement, OverlayPluginSelector, OverlayPluginLogs, OverlayPluginGetLogsOptions, } from './pluginRegistry.js';
package/dist/index.js CHANGED
@@ -6,9 +6,17 @@
6
6
  */
7
7
  import { installOverlay } from './overlay.js';
8
8
  import { RuntimeClient, readInjectedConfig } from './client.js';
9
+ import { registerOverlayPlugin, drainPluginQueue, } from './pluginRegistry.js';
10
+ // Informational; keep in sync with package.json on release.
11
+ const VERSION = '3.3.0';
9
12
  const w = window;
10
13
  if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
11
14
  w.__harness_fe_started__ = true;
15
+ // Public global for runtime plugin registration. Works before or after the
16
+ // overlay mounts — the registry buffers and the overlay subscribes.
17
+ w.HarnessFE = { registerOverlayPlugin, version: VERSION };
18
+ // Drain any plugins queued before the runtime loaded.
19
+ drainPluginQueue(w.__HARNESS_FE_PLUGINS__);
12
20
  const cfg = readInjectedConfig();
13
21
  const client = new RuntimeClient(cfg);
14
22
  client.start();
@@ -21,3 +29,4 @@ if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
21
29
  w.__hfe_session_id__ = client.sessionId;
22
30
  }
23
31
  export { RuntimeClient, tryInheritFromParent } from './client.js';
32
+ export { registerOverlayPlugin, getOverlayPlugins, subscribeOverlayPlugins, } from './pluginRegistry.js';
package/dist/overlay.d.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * or out. State machine: idle → info → (picker → question) → flash → idle.
15
15
  */
16
16
  import { type TaskAttachment } from '@harness-fe/protocol';
17
+ import { type OverlayPluginLogs, type OverlayPluginGetLogsOptions } from './pluginRegistry.js';
17
18
  export interface OverlayClient {
18
19
  readonly projectId: string;
19
20
  readonly buildId?: string;
@@ -60,6 +61,14 @@ export declare function replayStrokes(ctx: CanvasRenderingContext2D, bgCanvas: H
60
61
  * Exported for testing.
61
62
  */
62
63
  export declare function finalizeAnnotation(): Promise<TaskAttachment | null>;
64
+ /**
65
+ * Rasterize an element to a PNG TaskAttachment via snapdom. Returns null on
66
+ * failure (cross-origin, test env, …) so plugins can degrade gracefully.
67
+ * Exported for testing.
68
+ */
69
+ export declare function captureElementPng(el: Element): Promise<TaskAttachment | null>;
70
+ /** Read recent buffered logs for the plugin context. Redacts network by default. */
71
+ export declare function collectLogs(opts?: OverlayPluginGetLogsOptions): OverlayPluginLogs;
63
72
  /**
64
73
  * Best-effort CSS path. Depth cap 12, id anchor short-circuits, ` >>> `
65
74
  * separates shadow-DOM boundaries.
package/dist/overlay.js CHANGED
@@ -13,9 +13,14 @@
13
13
  * Single Shadow DOM root attached to <body>; host page styles never leak in
14
14
  * or out. State machine: idle → info → (picker → question) → flash → idle.
15
15
  */
16
- import { EVENT_NAME } from '@harness-fe/protocol';
16
+ import { EVENT_NAME, } from '@harness-fe/protocol';
17
17
  import { snapdom } from '@zumer/snapdom';
18
18
  import { deriveDashboardUrl } from './dashboardUrl.js';
19
+ import { getCaptureStore } from './capture.js';
20
+ import { collectPageLoadSnapshot } from './snapshot.js';
21
+ import { getOverlayPlugins, subscribeOverlayPlugins, } from './pluginRegistry.js';
22
+ /** Repo URL surfaced in the overlay footer. */
23
+ const GITHUB_URL = 'https://github.com/Morphicai/harness-fe';
19
24
  const HOST_ID = '__harness_fe_overlay__';
20
25
  const MAX_OUTER_HTML = 2048;
21
26
  // Internal instrumentation attributes injected by our build plugin. Must be
@@ -242,6 +247,14 @@ export function installOverlay(client) {
242
247
  let statusPollTimer;
243
248
  /** Flattened PNG from the annotate step; null if user skipped. */
244
249
  let pendingAttachment = null;
250
+ /** Set while the picker is collecting an element for a `requiresElement` plugin. */
251
+ let pluginAwaitingElement = null;
252
+ /**
253
+ * Purpose of the current picker session:
254
+ * - 'copy': copy element info to clipboard for use with an agent (default)
255
+ * - 'report': legacy report-a-problem flow (still used internally by plugins)
256
+ */
257
+ let pickerPurpose = 'copy';
245
258
  const setState = (next) => {
246
259
  state = next;
247
260
  infoCard.style.display = next === 'info' ? 'flex' : 'none';
@@ -314,9 +327,29 @@ export function installOverlay(client) {
314
327
  return;
315
328
  lockedEl = hoveredEl;
316
329
  setHighlight(lockedEl);
317
- // Go straight to the question step. Screenshots are now opt-in via
318
- // the "Add screenshot" button inside the question panel — users
319
- // shouldn't have to draw on every report.
330
+ // A plugin requested the element hand it straight to its onClick and
331
+ // skip all other flows.
332
+ if (pluginAwaitingElement) {
333
+ const plugin = pluginAwaitingElement;
334
+ pluginAwaitingElement = null;
335
+ const el = lockedEl;
336
+ lockedEl = null;
337
+ setState('idle');
338
+ void invokePlugin(plugin, el);
339
+ return;
340
+ }
341
+ // Copy mode: build element info and copy to clipboard for agent use.
342
+ if (pickerPurpose === 'copy') {
343
+ const el = lockedEl;
344
+ lockedEl = null;
345
+ const text = buildElementCopyText(el);
346
+ void copyText(text).then(() => {
347
+ showToast('✓ Element info copied');
348
+ });
349
+ setState('idle');
350
+ return;
351
+ }
352
+ // Report mode (legacy, no longer exposed in UI but kept for plugin compatibility).
320
353
  pendingAttachment = null;
321
354
  const info = questionPanel.querySelector('[data-role=info]');
322
355
  info.textContent = describeElement(lockedEl);
@@ -339,6 +372,8 @@ export function installOverlay(client) {
339
372
  else if (state === 'picker' || state === 'question') {
340
373
  lockedEl = null;
341
374
  pendingAttachment = null;
375
+ pluginAwaitingElement = null;
376
+ pickerPurpose = 'copy';
342
377
  setState('info');
343
378
  }
344
379
  else if (state === 'info') {
@@ -409,6 +444,31 @@ export function installOverlay(client) {
409
444
  }, 1200);
410
445
  }
411
446
  };
447
+ /**
448
+ * Build a compact element-info block for pasting into an agent prompt.
449
+ * Omits HTML (too verbose); includes source location, component name, css
450
+ * path, and session context — enough for the agent to locate and fix the
451
+ * element without any further investigation.
452
+ */
453
+ const buildElementCopyText = (el) => {
454
+ const tag = el.tagName.toLowerCase();
455
+ const comp = el.getAttribute('data-morphix-comp');
456
+ const loc = el.getAttribute('data-morphix-loc');
457
+ const css = buildCssPath(el);
458
+ const lines = [];
459
+ lines.push(`### Element context`);
460
+ lines.push('');
461
+ if (comp)
462
+ lines.push(`- component: \`${comp}\``);
463
+ if (loc)
464
+ lines.push(`- source: \`${loc}\``);
465
+ lines.push(`- tag: \`${tag}\``);
466
+ lines.push(`- css: \`${css}\``);
467
+ lines.push(`- project: \`${client.projectId}\`${client.displayName ? ` (${client.displayName})` : ''}`);
468
+ lines.push(`- session: \`${client.sessionId}\``);
469
+ lines.push(`- url: ${location.href}`);
470
+ return lines.join('\n') + '\n';
471
+ };
412
472
  const buildSnapshot = () => {
413
473
  const lines = [];
414
474
  lines.push(`### Harness-FE snapshot`);
@@ -425,6 +485,107 @@ export function installOverlay(client) {
425
485
  lines.push(`- daemon: ${client.getConnectionState()}`);
426
486
  return lines.join('\n') + '\n';
427
487
  };
488
+ // ─── Plugin support ──────────────────────────────────────────────────
489
+ const dashboardUrl = client.mcpUrl
490
+ ? deriveDashboardUrl({ mcpUrl: client.mcpUrl, sessionId: client.sessionId })
491
+ : undefined;
492
+ /** Brief transient toast anchored near the FAB. */
493
+ const showToast = (message, kind = 'ok') => {
494
+ const el = document.createElement('div');
495
+ el.className = 'hfe-toast';
496
+ el.dataset.kind = kind;
497
+ el.textContent = message;
498
+ root.appendChild(el);
499
+ requestAnimationFrame(() => { el.dataset.show = '1'; });
500
+ setTimeout(() => {
501
+ el.dataset.show = '';
502
+ setTimeout(() => el.remove(), 250);
503
+ }, 2400);
504
+ };
505
+ const buildPluginContext = (selectedEl) => {
506
+ const selectedElement = selectedEl
507
+ ? {
508
+ el: selectedEl,
509
+ selector: {
510
+ comp: selectedEl.getAttribute('data-morphix-comp') ?? undefined,
511
+ loc: selectedEl.getAttribute('data-morphix-loc') ?? undefined,
512
+ css: buildCssPath(selectedEl),
513
+ },
514
+ outerHTML: truncate(stripInternalAttrs(selectedEl.outerHTML), MAX_OUTER_HTML),
515
+ rect: (() => {
516
+ const r = selectedEl.getBoundingClientRect();
517
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
518
+ })(),
519
+ }
520
+ : undefined;
521
+ return {
522
+ projectId: client.projectId,
523
+ displayName: client.displayName,
524
+ buildId: client.buildId,
525
+ parentProjectId: client.parentProjectId,
526
+ sessionId: client.sessionId,
527
+ tabId: client.tabId,
528
+ visitorId: client.visitorId,
529
+ userId: client.userId,
530
+ url: location.href,
531
+ connectionState: client.getConnectionState(),
532
+ dashboardUrl,
533
+ selectedElement,
534
+ snapshotMarkdown: buildSnapshot,
535
+ snapshot: () => collectPageLoadSnapshot(client.sessionId),
536
+ getLogs: (opts) => collectLogs(opts),
537
+ captureScreenshot: (el) => captureElementPng(el ?? selectedEl ?? document.body),
538
+ query: client.query ? client.query.bind(client) : undefined,
539
+ copyToClipboard: (text) => copyText(text),
540
+ toast: showToast,
541
+ };
542
+ };
543
+ const invokePlugin = async (plugin, selectedEl) => {
544
+ try {
545
+ await plugin.onClick(buildPluginContext(selectedEl));
546
+ }
547
+ catch (err) {
548
+ const msg = err instanceof Error ? err.message : String(err);
549
+ showToast(`${plugin.label}: ${msg}`, 'error');
550
+ }
551
+ };
552
+ /** (Re)render the plugin button group in the info card. */
553
+ const renderPluginButtons = () => {
554
+ const slot = infoCard.querySelector('[data-role=plugin-actions]');
555
+ if (!slot)
556
+ return;
557
+ const list = getOverlayPlugins();
558
+ slot.innerHTML = '';
559
+ slot.style.display = list.length ? '' : 'none';
560
+ for (const plugin of list) {
561
+ const btn = document.createElement('button');
562
+ btn.className = 'secondary';
563
+ btn.type = 'button';
564
+ btn.dataset.pluginId = plugin.id;
565
+ btn.textContent = `${plugin.icon ? plugin.icon + ' ' : ''}${plugin.label}`;
566
+ btn.addEventListener('click', () => {
567
+ if (plugin.requiresElement) {
568
+ pluginAwaitingElement = plugin;
569
+ pickerPurpose = 'report'; // plugins use the legacy element-selection flow
570
+ const label = pickerBar.querySelector('[data-role=picker-label]');
571
+ if (label)
572
+ label.textContent = '🎯 Click an element';
573
+ setState('picker');
574
+ }
575
+ else {
576
+ setState('idle');
577
+ void invokePlugin(plugin);
578
+ }
579
+ });
580
+ slot.appendChild(btn);
581
+ }
582
+ };
583
+ renderPluginButtons();
584
+ const unsubscribePlugins = subscribeOverlayPlugins(() => {
585
+ // Only the info card shows plugin buttons; re-render whenever the set changes.
586
+ renderPluginButtons();
587
+ });
588
+ void unsubscribePlugins; // overlay lives for the page lifetime; no teardown path
428
589
  // ─── Reports rendering ───────────────────────────────────────────────
429
590
  let editingTaskId = null;
430
591
  let deleteConfirmId = null;
@@ -637,25 +798,23 @@ export function installOverlay(client) {
637
798
  setState(state === 'idle' ? 'info' : 'idle');
638
799
  });
639
800
  infoCard.querySelector('[data-role=close]').addEventListener('click', () => setState('idle'));
640
- infoCard.querySelector('[data-role=report]').addEventListener('click', () => {
801
+ infoCard.querySelector('[data-role=pick-element]').addEventListener('click', () => {
802
+ pickerPurpose = 'copy';
803
+ const label = pickerBar.querySelector('[data-role=picker-label]');
804
+ if (label)
805
+ label.textContent = '🔍 Click element to copy info';
641
806
  setState('picker');
642
807
  });
643
808
  infoCard.querySelector('[data-role=copy-snapshot]').addEventListener('click', (ev) => {
644
809
  const btn = ev.currentTarget;
645
810
  void copyText(buildSnapshot(), btn);
646
811
  });
647
- infoCard.querySelector('[data-role=view-reports]').addEventListener('click', () => {
648
- setState('reports');
649
- });
650
812
  // "Open dashboard" — derive the daemon's dashboard URL from mcpUrl and
651
813
  // pop it in a new tab, deep-linked to this session. Show the button
652
814
  // only when we actually know the daemon address (mcpUrl was supplied by
653
815
  // the plugin / runtime config).
654
816
  {
655
817
  const dashboardBtn = infoCard.querySelector('[data-role=open-dashboard]');
656
- const dashboardUrl = client.mcpUrl
657
- ? deriveDashboardUrl({ mcpUrl: client.mcpUrl, sessionId: client.sessionId })
658
- : undefined;
659
818
  if (dashboardUrl) {
660
819
  dashboardBtn.style.display = '';
661
820
  dashboardBtn.title = `Open ${dashboardUrl} in a new tab`;
@@ -671,6 +830,19 @@ export function installOverlay(client) {
671
830
  });
672
831
  }
673
832
  }
833
+ // GitHub promo link — anchor navigates natively; this fallback covers
834
+ // sandboxed iframes / popup blockers by copying the URL instead.
835
+ infoCard.querySelector('[data-role=github]').addEventListener('click', (ev) => {
836
+ try {
837
+ const opened = window.open(GITHUB_URL, '_blank', 'noopener,noreferrer');
838
+ if (opened)
839
+ ev.preventDefault();
840
+ }
841
+ catch {
842
+ ev.preventDefault();
843
+ void copyText(GITHUB_URL, ev.currentTarget);
844
+ }
845
+ });
674
846
  reportsCard.querySelector('[data-role=back]').addEventListener('click', () => setState('info'));
675
847
  reportsCard.querySelector('[data-role=close]').addEventListener('click', () => setState('idle'));
676
848
  reportsCard.querySelector('[data-role=refresh]').addEventListener('click', () => void refreshReports());
@@ -688,6 +860,8 @@ export function installOverlay(client) {
688
860
  }
689
861
  pickerBar.querySelector('[data-role=cancel]').addEventListener('click', () => {
690
862
  lockedEl = null;
863
+ pluginAwaitingElement = null;
864
+ pickerPurpose = 'copy';
691
865
  setState('info');
692
866
  });
693
867
  questionPanel.querySelector('[data-role=cancel]').addEventListener('click', () => {
@@ -1037,6 +1211,62 @@ export async function finalizeAnnotation() {
1037
1211
  resetAnnotateStrokes();
1038
1212
  return att;
1039
1213
  }
1214
+ // ─── Plugin helpers (screenshot / logs) ───────────────────────────────────
1215
+ /**
1216
+ * Rasterize an element to a PNG TaskAttachment via snapdom. Returns null on
1217
+ * failure (cross-origin, test env, …) so plugins can degrade gracefully.
1218
+ * Exported for testing.
1219
+ */
1220
+ export async function captureElementPng(el) {
1221
+ try {
1222
+ const result = await snapdom(el, { fast: true });
1223
+ const canvas = await result.toCanvas();
1224
+ const dataUrl = canvas.toDataURL('image/png', 0.85);
1225
+ const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
1226
+ return {
1227
+ id: `att_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1228
+ kind: 'screenshot',
1229
+ data: base64,
1230
+ width: canvas.width || 1,
1231
+ height: canvas.height || 1,
1232
+ };
1233
+ }
1234
+ catch {
1235
+ return null;
1236
+ }
1237
+ }
1238
+ const SENSITIVE_HEADER_RE = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
1239
+ /** Strip bodies + auth/cookie headers from a network entry. */
1240
+ function redactNetworkEntry(e) {
1241
+ const out = { ...e };
1242
+ delete out.requestBody;
1243
+ delete out.responseBody;
1244
+ delete out.requestBodyTruncated;
1245
+ delete out.responseBodyTruncated;
1246
+ for (const key of ['requestHeaders', 'responseHeaders']) {
1247
+ const h = out[key];
1248
+ if (h) {
1249
+ const safe = {};
1250
+ for (const [k, v] of Object.entries(h)) {
1251
+ if (!SENSITIVE_HEADER_RE.test(k))
1252
+ safe[k] = v;
1253
+ }
1254
+ out[key] = safe;
1255
+ }
1256
+ }
1257
+ return out;
1258
+ }
1259
+ /** Read recent buffered logs for the plugin context. Redacts network by default. */
1260
+ export function collectLogs(opts = {}) {
1261
+ const store = getCaptureStore();
1262
+ const redact = opts.redact !== false;
1263
+ const network = store.network.tail(opts.network ?? 0);
1264
+ return {
1265
+ console: store.console.tail(opts.console ?? 0),
1266
+ errors: store.errors.tail(opts.errors ?? 0),
1267
+ network: redact ? network.map(redactNetworkEntry) : network,
1268
+ };
1269
+ }
1040
1270
  // ─── DOM builders ────────────────────────────────────────────────────────
1041
1271
  function buildStyle() {
1042
1272
  const style = document.createElement('style');
@@ -1263,6 +1493,47 @@ function buildStyle() {
1263
1493
  border-color: rgba(52, 211, 153, 0.3);
1264
1494
  }
1265
1495
 
1496
+ .info-card .plugin-actions {
1497
+ display: flex;
1498
+ flex-direction: column;
1499
+ gap: 8px;
1500
+ margin-top: 8px;
1501
+ padding-top: 8px;
1502
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
1503
+ }
1504
+ .info-card .promo {
1505
+ margin-top: 8px;
1506
+ text-align: center;
1507
+ }
1508
+ .info-card .promo-link {
1509
+ color: #a1a1aa;
1510
+ font-size: 11px;
1511
+ text-decoration: none;
1512
+ opacity: 0.7;
1513
+ }
1514
+ .info-card .promo-link:hover { color: #f4f4f5; opacity: 1; }
1515
+
1516
+ .hfe-toast {
1517
+ position: fixed;
1518
+ bottom: 64px;
1519
+ left: 50%;
1520
+ transform: translate(-50%, 8px);
1521
+ max-width: 320px;
1522
+ padding: 8px 14px;
1523
+ background: #111827;
1524
+ color: #f4f4f5;
1525
+ border: 1px solid rgba(255, 255, 255, 0.12);
1526
+ border-radius: 10px;
1527
+ font: 500 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1528
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
1529
+ opacity: 0;
1530
+ transition: opacity 0.2s ease, transform 0.2s ease;
1531
+ z-index: 2147483647;
1532
+ pointer-events: none;
1533
+ }
1534
+ .hfe-toast[data-show="1"] { opacity: 1; transform: translate(-50%, 0); }
1535
+ .hfe-toast[data-kind="error"] { border-color: rgba(248, 113, 113, 0.4); color: #fca5a5; }
1536
+
1266
1537
  .picker-bar {
1267
1538
  position: fixed;
1268
1539
  top: 12px;
@@ -1715,18 +1986,21 @@ function buildInfoCard() {
1715
1986
  <div class="row"><span class="key">url</span><span class="pill url" data-role="url"></span></div>
1716
1987
  </div>
1717
1988
  <div class="actions">
1718
- <button class="primary" data-role="report" type="button">
1719
- <span class="icon">🎯</span>
1720
- <span class="label">Report a problem</span>
1721
- <span class="hint">Pick an element →</span>
1989
+ <button class="primary" data-role="pick-element" type="button">
1990
+ <span class="icon">🔍</span>
1991
+ <span class="label">Copy element info</span>
1992
+ <span class="hint">pick element →</span>
1722
1993
  </button>
1723
1994
  <button class="secondary" data-role="open-dashboard" type="button" style="display:none">
1724
1995
  <span class="icon">↗</span>
1725
1996
  <span>Open dashboard</span>
1726
1997
  </button>
1727
- <button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
1728
1998
  <button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
1729
1999
  </div>
2000
+ <div class="plugin-actions" data-role="plugin-actions" style="display:none"></div>
2001
+ <div class="promo">
2002
+ <a class="promo-link" data-role="github" href="${GITHUB_URL}" target="_blank" rel="noopener noreferrer">⭐ Harness-FE on GitHub</a>
2003
+ </div>
1730
2004
  `;
1731
2005
  return card;
1732
2006
  }
@@ -1751,7 +2025,7 @@ function buildPickerBar() {
1751
2025
  const bar = document.createElement('div');
1752
2026
  bar.className = 'picker-bar';
1753
2027
  bar.innerHTML = `
1754
- <span class="label">🎯 Click an element to flag it</span>
2028
+ <span class="label" data-role="picker-label">🔍 Click element to copy info</span>
1755
2029
  <span class="hint">esc to cancel</span>
1756
2030
  <button data-role="cancel" type="button">Cancel</button>
1757
2031
  `;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Overlay plugin registry.
3
+ *
4
+ * Lets developers extend the in-page "H" overlay with their own action buttons
5
+ * — e.g. "send this scene to Slack" / "create a Jira issue" — without forking
6
+ * `@harness-fe/runtime`. A plugin is just a button label + an `onClick(ctx)`
7
+ * handler; the handler receives an {@link OverlayPluginContext} with on-demand,
8
+ * redaction-aware access to the current scene, logs, screenshot, and selected
9
+ * element.
10
+ *
11
+ * Registration order doesn't matter: plugins registered before the overlay
12
+ * mounts are buffered here, and the overlay subscribes so anything registered
13
+ * later renders immediately. See `index.ts` for the `window.HarnessFE` global
14
+ * and the `window.__HARNESS_FE_PLUGINS__` pre-boot queue.
15
+ */
16
+ import type { PageLoadPayload, ConsoleEntry, NetworkEntry, ErrorEntry, TaskAttachment } from '@harness-fe/protocol';
17
+ /** Selector descriptor for a picked element (mirrors TaskSubmitPayload.selector). */
18
+ export interface OverlayPluginSelector {
19
+ /** Source location `file:line:col` from the build transform, if present. */
20
+ loc?: string;
21
+ /** Component display name from the build transform, if present. */
22
+ comp?: string;
23
+ /** Best-effort CSS path. */
24
+ css: string;
25
+ }
26
+ /** The element the user picked (only present for `requiresElement` plugins). */
27
+ export interface OverlayPluginSelectedElement {
28
+ /** Live DOM node. */
29
+ el: Element;
30
+ selector: OverlayPluginSelector;
31
+ /** outerHTML with internal instrumentation attrs stripped + truncated. */
32
+ outerHTML: string;
33
+ rect: {
34
+ x: number;
35
+ y: number;
36
+ width: number;
37
+ height: number;
38
+ };
39
+ }
40
+ export interface OverlayPluginLogs {
41
+ console: ConsoleEntry[];
42
+ network: NetworkEntry[];
43
+ errors: ErrorEntry[];
44
+ }
45
+ export interface OverlayPluginGetLogsOptions {
46
+ /** Max console entries (newest last). Default 0 — pass a count to include. */
47
+ console?: number;
48
+ /** Max network entries. Default 0. */
49
+ network?: number;
50
+ /** Max error entries. Default 0. */
51
+ errors?: number;
52
+ /**
53
+ * When `true` (the default), network entries are reduced to metadata:
54
+ * request/response bodies are dropped and `authorization` / `cookie`
55
+ * headers are stripped. Set `false` to receive raw entries — only do this
56
+ * when you control the destination, as bodies may contain secrets.
57
+ */
58
+ redact?: boolean;
59
+ }
60
+ /**
61
+ * Per-invocation context handed to a plugin's `onClick`. Getters are lazy: the
62
+ * snapshot / logs / screenshot are only computed when you call them, so a
63
+ * plugin pays for exactly what it uses.
64
+ */
65
+ export interface OverlayPluginContext {
66
+ readonly projectId: string;
67
+ readonly displayName?: string;
68
+ readonly buildId?: string;
69
+ readonly parentProjectId?: string;
70
+ readonly sessionId: string;
71
+ readonly tabId: string;
72
+ readonly visitorId?: string;
73
+ readonly userId?: string;
74
+ /** `location.href` at click time. */
75
+ readonly url: string;
76
+ readonly connectionState: 'connecting' | 'open' | 'closed';
77
+ /** Deep link to this session in the daemon dashboard, if the address is known. */
78
+ readonly dashboardUrl?: string;
79
+ /** Present only for plugins declared with `requiresElement: true`. */
80
+ readonly selectedElement?: OverlayPluginSelectedElement;
81
+ /** Shareable markdown summary (project / build / session / tab / url / time / conn). */
82
+ snapshotMarkdown(): string;
83
+ /** Structured page-load snapshot: page / viewport / storage / performance. */
84
+ snapshot(): PageLoadPayload;
85
+ /** Recent buffered logs. Redacted by default — see {@link OverlayPluginGetLogsOptions}. */
86
+ getLogs(opts?: OverlayPluginGetLogsOptions): OverlayPluginLogs;
87
+ /** Rasterize an element (default: the picked element, else `document.body`) to a PNG attachment. */
88
+ captureScreenshot(el?: Element): Promise<TaskAttachment | null>;
89
+ /** Daemon RPC over the whitelisted channel (e.g. `tasks.mine`). Undefined if unavailable. */
90
+ query?: <TResult = unknown>(method: string, args?: unknown) => Promise<TResult>;
91
+ /** Copy text to the clipboard (best-effort). */
92
+ copyToClipboard(text: string): Promise<void>;
93
+ /** Show a brief feedback toast on the overlay. */
94
+ toast(message: string, kind?: 'ok' | 'error'): void;
95
+ }
96
+ export interface OverlayPlugin {
97
+ /** Unique id. Re-registering the same id replaces the previous plugin. */
98
+ id: string;
99
+ /** Button label shown in the info card. */
100
+ label: string;
101
+ /** Optional leading icon (emoji or single char). */
102
+ icon?: string;
103
+ /**
104
+ * When `true`, clicking the button first enters element-picker mode; the
105
+ * picked element is then available as `ctx.selectedElement` in `onClick`.
106
+ */
107
+ requiresElement?: boolean;
108
+ /** Invoked when the button is clicked. May be async; rejections are toasted. */
109
+ onClick(ctx: OverlayPluginContext): void | Promise<void>;
110
+ }
111
+ /**
112
+ * Register an overlay plugin. Returns an unregister function (handy for HMR
113
+ * cleanup). Registering an id that already exists replaces it.
114
+ */
115
+ export declare function registerOverlayPlugin(plugin: OverlayPlugin): () => void;
116
+ /** Current plugins, in registration order. */
117
+ export declare function getOverlayPlugins(): OverlayPlugin[];
118
+ /** Subscribe to registry changes (add / replace / remove). Returns an unsubscribe fn. */
119
+ export declare function subscribeOverlayPlugins(fn: () => void): () => void;
120
+ /**
121
+ * Drain a pre-boot queue of plugins. Developers whose script may run before the
122
+ * runtime loads can push plain plugin objects onto
123
+ * `window.__HARNESS_FE_PLUGINS__`; `index.ts` calls this once on boot.
124
+ */
125
+ export declare function drainPluginQueue(queue: unknown): void;
126
+ /** Test-only: clear all plugins and listeners. */
127
+ export declare function __resetOverlayPlugins(): void;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Overlay plugin registry.
3
+ *
4
+ * Lets developers extend the in-page "H" overlay with their own action buttons
5
+ * — e.g. "send this scene to Slack" / "create a Jira issue" — without forking
6
+ * `@harness-fe/runtime`. A plugin is just a button label + an `onClick(ctx)`
7
+ * handler; the handler receives an {@link OverlayPluginContext} with on-demand,
8
+ * redaction-aware access to the current scene, logs, screenshot, and selected
9
+ * element.
10
+ *
11
+ * Registration order doesn't matter: plugins registered before the overlay
12
+ * mounts are buffered here, and the overlay subscribes so anything registered
13
+ * later renders immediately. See `index.ts` for the `window.HarnessFE` global
14
+ * and the `window.__HARNESS_FE_PLUGINS__` pre-boot queue.
15
+ */
16
+ const plugins = new Map();
17
+ const listeners = new Set();
18
+ function notify() {
19
+ for (const fn of listeners) {
20
+ try {
21
+ fn();
22
+ }
23
+ catch {
24
+ /* a bad listener must not break registration */
25
+ }
26
+ }
27
+ }
28
+ /**
29
+ * Register an overlay plugin. Returns an unregister function (handy for HMR
30
+ * cleanup). Registering an id that already exists replaces it.
31
+ */
32
+ export function registerOverlayPlugin(plugin) {
33
+ if (!plugin || typeof plugin.id !== 'string' || plugin.id === '') {
34
+ throw new Error('registerOverlayPlugin: plugin.id is required');
35
+ }
36
+ if (typeof plugin.onClick !== 'function') {
37
+ throw new Error(`registerOverlayPlugin: plugin "${plugin.id}" needs an onClick handler`);
38
+ }
39
+ plugins.set(plugin.id, plugin);
40
+ notify();
41
+ return () => {
42
+ if (plugins.get(plugin.id) === plugin) {
43
+ plugins.delete(plugin.id);
44
+ notify();
45
+ }
46
+ };
47
+ }
48
+ /** Current plugins, in registration order. */
49
+ export function getOverlayPlugins() {
50
+ return [...plugins.values()];
51
+ }
52
+ /** Subscribe to registry changes (add / replace / remove). Returns an unsubscribe fn. */
53
+ export function subscribeOverlayPlugins(fn) {
54
+ listeners.add(fn);
55
+ return () => {
56
+ listeners.delete(fn);
57
+ };
58
+ }
59
+ /**
60
+ * Drain a pre-boot queue of plugins. Developers whose script may run before the
61
+ * runtime loads can push plain plugin objects onto
62
+ * `window.__HARNESS_FE_PLUGINS__`; `index.ts` calls this once on boot.
63
+ */
64
+ export function drainPluginQueue(queue) {
65
+ if (!Array.isArray(queue))
66
+ return;
67
+ for (const p of queue) {
68
+ try {
69
+ registerOverlayPlugin(p);
70
+ }
71
+ catch {
72
+ /* skip malformed queued entries */
73
+ }
74
+ }
75
+ }
76
+ /** Test-only: clear all plugins and listeners. */
77
+ export function __resetOverlayPlugins() {
78
+ plugins.clear();
79
+ listeners.clear();
80
+ }