/**
* In-page overlay — single floating "H" mark in the bottom-right corner.
*
* Click → expands into an info card that surfaces:
* - project / buildId / connection status
* - sessionId / tabId (click-to-copy)
* - current URL
* - "Copy snapshot" — key fields as markdown for sharing with a teammate
* or pasting into an agent prompt
* - "Report a problem" — enters element-picker mode (the legacy annotation
* flow, now reachable from inside the card so users don't see two FABs)
*
* Single Shadow DOM root attached to
; host page styles never leak in
* or out. State machine: idle → info → (picker → question) → flash → idle.
*/
import { EVENT_NAME, type TaskSubmitPayload, type TaskAttachment } from '@harnessa-fe/protocol';
import { snapdom } from '@zumer/snapdom';
const HOST_ID = '__harnessa_fe_overlay__';
const MAX_OUTER_HTML = 2048;
// Internal instrumentation attributes injected by our build plugin. Must be
// stripped before sending outerHTML to an agent — otherwise the agent treats
// them as business code and writes them back into the JSX source.
const INTERNAL_ATTR_RE = /\s+data-morphix-[\w-]*(="[^"]*")?/g;
function stripInternalAttrs(html: string): string {
return html.replace(INTERNAL_ATTR_RE, '');
}
/**
* Mark an element so common session-recording / RUM vendors skip it. Our
* overlay must not pollute the host app's analytics or replay data, and must
* not leak our session/visitor ids to third-party servers.
*/
function hideFromThirdParties(el: Element): void {
// rrweb / PostHog
el.setAttribute('data-rr-block', '');
el.setAttribute('data-rr-ignore', '');
el.setAttribute('data-rr-mask', '');
el.classList.add('ph-no-capture');
// Sentry session replay
el.setAttribute('data-sentry-block', '');
el.setAttribute('data-sentry-ignore', '');
el.setAttribute('data-sentry-mask', '');
// Datadog RUM
el.setAttribute('data-dd-privacy', 'hidden');
// FullStory
el.setAttribute('data-fs-exclude', '');
// LogRocket
el.setAttribute('data-lr-exclude', '');
// Hotjar
el.setAttribute('data-hj-suppress', '');
// Smartlook
el.setAttribute('data-recording-disable', '');
// Microsoft Clarity
el.setAttribute('data-clarity-mask', 'true');
// Heap
el.setAttribute('data-heap-redact-text', '');
}
export interface OverlayClient {
readonly projectId: string;
readonly buildId?: string;
readonly displayName?: string;
readonly tabId: string;
readonly sessionId: string;
readonly visitorId?: string;
readonly userId?: string;
readonly parentProjectId?: string;
getConnectionState(): 'connecting' | 'open' | 'closed';
sendEvent(name: string, payload: unknown): void;
/**
* RPC channel to the daemon. Used to fetch and mutate the visitor's own
* tasks. Resolves with `result`, rejects with the remote error message.
*/
query?(method: string, args?: unknown): Promise;
}
/** Subset of @harnessa-fe/protocol Task that the overlay renders. */
interface TaskSummary {
id: string;
status: 'pending' | 'claimed' | 'resolved';
question: string;
url: string;
selector: { loc?: string; comp?: string; css?: string };
createdAt: number;
updatedAt?: number;
claimedAt?: number;
resolvedAt?: number;
note?: string;
/** Attachment pointers or (for fresh tasks.mine) first-attachment inline base64. */
attachments?: Array<{ id: string; kind: string; width: number; height: number; path?: string; data?: string }>;
}
export function installOverlay(client: OverlayClient): void {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
if (document.getElementById(HOST_ID)) return;
const host = document.createElement('div');
host.id = HOST_ID;
host.style.cssText = 'all: initial;';
hideFromThirdParties(host);
const root = host.attachShadow({ mode: 'open' });
root.appendChild(buildStyle());
const fab = buildFab();
const infoCard = buildInfoCard();
const reportsCard = buildReportsCard();
const pickerBar = buildPickerBar();
const annotateModal = buildAnnotateModal();
const questionPanel = buildQuestionPanel();
const highlight = buildHighlight();
root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, highlight);
const mount = () => {
if (!document.body) return false;
document.body.appendChild(host);
return true;
};
if (!mount()) {
document.addEventListener('DOMContentLoaded', () => mount(), { once: true });
}
// ─── FAB position (drag-and-drop + persistence) ──────────────────────
//
// The FAB lives in a fixed position the user can drag anywhere on
// screen, with the location stashed in localStorage so it survives
// reloads. Follower cards (info / reports / question) anchor relative
// to the FAB and flip side based on available space.
const FAB_SIZE = 40;
const FAB_MARGIN = 16;
const FAB_POS_KEY = '__harnessa_fe_fab_pos__';
const DRAG_THRESHOLD = 5; // px before we treat a pointerdown as a drag
interface FabPos { x: number; y: number }
const readPersistedPos = (): FabPos | undefined => {
try {
const raw = window.localStorage?.getItem(FAB_POS_KEY);
if (!raw) return undefined;
const parsed = JSON.parse(raw) as { x?: unknown; y?: unknown };
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
return { x: parsed.x, y: parsed.y };
}
} catch {
/* swallow — storage unavailable, missing, or malformed */
}
return undefined;
};
const persistPos = (pos: FabPos): void => {
try {
window.localStorage?.setItem(FAB_POS_KEY, JSON.stringify(pos));
} catch {
/* swallow — quota / sandboxed iframe / etc. */
}
};
const clampPos = (pos: FabPos): FabPos => {
const w = window.innerWidth || 1024;
const h = window.innerHeight || 768;
return {
x: Math.max(FAB_MARGIN, Math.min(w - FAB_SIZE - FAB_MARGIN, pos.x)),
y: Math.max(FAB_MARGIN, Math.min(h - FAB_SIZE - FAB_MARGIN, pos.y)),
};
};
const defaultPos = (): FabPos => {
const w = window.innerWidth || 1024;
const h = window.innerHeight || 768;
return { x: w - FAB_SIZE - FAB_MARGIN, y: h - FAB_SIZE - FAB_MARGIN };
};
let fabPos: FabPos = clampPos(readPersistedPos() ?? defaultPos());
const applyFabPosition = (): void => {
fab.style.left = `${fabPos.x}px`;
fab.style.top = `${fabPos.y}px`;
fab.style.right = 'auto';
fab.style.bottom = 'auto';
};
/**
* Position the visible follower card anchored to the current FAB
* location. Cards prefer to sit above the FAB; flip below when there
* isn't enough room above. Horizontal alignment mirrors which half of
* the viewport the FAB lives in (so a FAB in the left half gets cards
* extending right, and vice-versa).
*/
const repositionCards = (): void => {
const rect = fab.getBoundingClientRect();
const halfW = (window.innerWidth || 1024) / 2;
const winH = window.innerHeight || 768;
const gap = 12;
const cards: Array<{ el: HTMLElement; estimatedHeight: number }> = [
{ el: infoCard, estimatedHeight: 280 },
{ el: reportsCard, estimatedHeight: 460 },
{ el: questionPanel, estimatedHeight: 320 },
];
for (const { el, estimatedHeight } of cards) {
if (el.style.display === 'none') continue;
const cardW = el.offsetWidth || 320;
const cardH = el.offsetHeight || estimatedHeight;
const onLeftHalf = rect.left + rect.width / 2 < halfW;
// Horizontal: align the card's left or right edge to the FAB's
let left = onLeftHalf ? rect.left : rect.right - cardW;
left = Math.max(8, Math.min((window.innerWidth || 1024) - cardW - 8, left));
// Vertical: prefer above the FAB; flip below when constrained.
let top: number;
if (rect.top - gap - cardH >= 8) {
top = rect.top - gap - cardH;
} else if (rect.bottom + gap + cardH <= winH - 8) {
top = rect.bottom + gap;
} else {
// Both directions constrained: center vertically.
top = Math.max(8, Math.min(winH - cardH - 8, rect.top - cardH / 2));
}
el.style.left = `${left}px`;
el.style.top = `${top}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
}
};
applyFabPosition();
// ─── Drag handlers ───────────────────────────────────────────────────
let dragOriginPos: FabPos | null = null;
let pointerDownAt: { x: number; y: number } | null = null;
let dragging = false;
let suppressNextClick = false;
const onFabPointerDown = (ev: PointerEvent) => {
if (ev.button !== 0) return;
try { fab.setPointerCapture(ev.pointerId); } catch { /* swallow */ }
dragOriginPos = { x: fabPos.x, y: fabPos.y };
pointerDownAt = { x: ev.clientX, y: ev.clientY };
dragging = false;
};
const onFabPointerMove = (ev: PointerEvent) => {
if (!pointerDownAt || !dragOriginPos) return;
const dx = ev.clientX - pointerDownAt.x;
const dy = ev.clientY - pointerDownAt.y;
if (!dragging && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
dragging = true;
suppressNextClick = true;
fab.dataset.dragging = '1';
fabPos = clampPos({ x: dragOriginPos.x + dx, y: dragOriginPos.y + dy });
applyFabPosition();
repositionCards();
};
const onFabPointerUp = (ev: PointerEvent) => {
try { fab.releasePointerCapture(ev.pointerId); } catch { /* swallow */ }
const wasDragging = dragging;
dragOriginPos = null;
pointerDownAt = null;
dragging = false;
delete fab.dataset.dragging;
if (wasDragging) persistPos(fabPos);
};
fab.addEventListener('pointerdown', onFabPointerDown);
fab.addEventListener('pointermove', onFabPointerMove);
fab.addEventListener('pointerup', onFabPointerUp);
fab.addEventListener('pointercancel', onFabPointerUp);
// Keep things on-screen when the viewport changes (window resize,
// dev tools open, mobile keyboard, …).
const onWindowResize = () => {
fabPos = clampPos(fabPos);
applyFabPosition();
repositionCards();
};
window.addEventListener('resize', onWindowResize);
// ─── State machine ────────────────────────────────────────────────────
type State = 'idle' | 'info' | 'reports' | 'picker' | 'annotate' | 'question';
let state: State = 'idle';
let hoveredEl: Element | null = null;
let lockedEl: Element | null = null;
let statusPollTimer: number | undefined;
/** Flattened PNG from the annotate step; null if user skipped. */
let pendingAttachment: TaskAttachment | null = null;
const setState = (next: State) => {
state = next;
infoCard.style.display = next === 'info' ? 'flex' : 'none';
reportsCard.style.display = next === 'reports' ? 'flex' : 'none';
pickerBar.style.display = next === 'picker' ? 'flex' : 'none';
annotateModal.style.display = next === 'annotate' ? 'flex' : 'none';
questionPanel.style.display = next === 'question' ? 'flex' : 'none';
document.documentElement.style.cursor = next === 'picker' ? 'crosshair' : '';
fab.dataset.state = (next === 'picker' || next === 'annotate') ? 'active' : 'idle';
if (next !== 'picker' && next !== 'question' && next !== 'annotate') {
highlight.style.display = 'none';
}
if (next === 'info') {
renderInfo();
if (!statusPollTimer) {
statusPollTimer = window.setInterval(renderConnectionDot, 1000);
}
} else if (statusPollTimer) {
window.clearInterval(statusPollTimer);
statusPollTimer = undefined;
}
if (next === 'reports') {
void refreshReports();
}
if (next === 'annotate' && lockedEl) {
void enterAnnotate(lockedEl);
}
// Anchor the just-revealed follower card relative to the FAB. Wait
// one frame so `display: flex` has applied and offsetWidth/Height
// are real (otherwise the first render uses fallback estimates).
if (next === 'info' || next === 'reports' || next === 'question') {
requestAnimationFrame(() => repositionCards());
}
};
// ─── Picker handlers ─────────────────────────────────────────────────
const setHighlight = (el: Element | null) => {
if (!el || !(el instanceof HTMLElement || el instanceof SVGElement)) {
highlight.style.display = 'none';
return;
}
const rect = el.getBoundingClientRect();
highlight.style.display = 'block';
highlight.style.left = `${rect.left + window.scrollX}px`;
highlight.style.top = `${rect.top + window.scrollY}px`;
highlight.style.width = `${rect.width}px`;
highlight.style.height = `${rect.height}px`;
};
const onMouseMove = (ev: MouseEvent) => {
if (state !== 'picker') return;
const target = document.elementFromPoint(ev.clientX, ev.clientY);
if (!target || target === host || host.contains(target)) {
hoveredEl = null;
highlight.style.display = 'none';
return;
}
hoveredEl = target;
setHighlight(target);
};
const onClickCapture = (ev: MouseEvent) => {
if (state !== 'picker') return;
if (ev.target === host || host.contains(ev.target as Node)) return;
ev.preventDefault();
ev.stopPropagation();
ev.stopImmediatePropagation();
if (!hoveredEl) return;
lockedEl = hoveredEl;
setHighlight(lockedEl);
// Go straight to the question step. Screenshots are now opt-in via
// the "Add screenshot" button inside the question panel — users
// shouldn't have to draw on every report.
pendingAttachment = null;
const info = questionPanel.querySelector('[data-role=info]')!;
info.textContent = describeElement(lockedEl);
const textarea = questionPanel.querySelector('textarea')!;
textarea.value = '';
renderAttachmentPreview();
setState('question');
setTimeout(() => textarea.focus(), 0);
};
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
if (state === 'annotate') {
// Esc in annotate: keep any prior attachment, just discard the
// in-progress strokes and return to the question step where
// the user came from. Re-entering annotate later will start
// a fresh canvas.
resetAnnotateStrokes();
setState('question');
} else if (state === 'picker' || state === 'question') {
lockedEl = null;
pendingAttachment = null;
setState('info');
} else if (state === 'info') {
setState('idle');
}
return;
}
// Cmd/Ctrl + Shift + H toggles the info card.
const meta = ev.metaKey || ev.ctrlKey;
if (meta && ev.shiftKey && (ev.key === 'h' || ev.key === 'H')) {
ev.preventDefault();
setState(state === 'idle' ? 'info' : 'idle');
}
};
// ─── Info card rendering ─────────────────────────────────────────────
const renderInfo = () => {
const proj = infoCard.querySelector('[data-role=project]')!;
const build = infoCard.querySelector('[data-role=build]')!;
const session = infoCard.querySelector('[data-role=session]')!;
const tab = infoCard.querySelector('[data-role=tab]')!;
const url = infoCard.querySelector('[data-role=url]')!;
proj.textContent = client.displayName ?? client.projectId;
build.textContent = client.buildId ? abbr(client.buildId) : '—';
build.title = client.buildId ?? 'No buildId — set HarnessaScript buildId prop in prod';
session.textContent = abbr(client.sessionId);
session.title = client.sessionId;
tab.textContent = abbr(client.tabId);
tab.title = client.tabId;
url.textContent = shortenUrl(location.href);
url.title = location.href;
renderConnectionDot();
};
const renderConnectionDot = () => {
const dot = infoCard.querySelector('[data-role=dot]')!;
const label = infoCard.querySelector('[data-role=conn]')!;
const state = client.getConnectionState();
dot.dataset.state = state;
label.textContent =
state === 'open' ? 'connected' :
state === 'connecting' ? 'connecting' : 'disconnected';
};
// ─── Copy buttons ────────────────────────────────────────────────────
const copyText = async (text: string, feedback?: HTMLElement) => {
try {
await navigator.clipboard.writeText(text);
} catch {
// Fallback for non-secure contexts.
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch { /* swallow */ }
ta.remove();
}
if (feedback) {
const orig = feedback.textContent;
feedback.dataset.copied = '1';
feedback.textContent = '✓ copied';
setTimeout(() => {
delete feedback.dataset.copied;
feedback.textContent = orig ?? '';
}, 1200);
}
};
const buildSnapshot = (): string => {
const lines: string[] = [];
lines.push(`### Harnessa-FE snapshot`);
lines.push('');
lines.push(`- project: \`${client.projectId}\`${client.displayName ? ` (${client.displayName})` : ''}`);
if (client.buildId) lines.push(`- build: \`${client.buildId}\``);
lines.push(`- session: \`${client.sessionId}\``);
lines.push(`- tab: \`${client.tabId}\``);
if (client.parentProjectId) lines.push(`- parent project: \`${client.parentProjectId}\``);
lines.push(`- url: ${location.href}`);
lines.push(`- time: ${new Date().toISOString()}`);
lines.push(`- daemon: ${client.getConnectionState()}`);
return lines.join('\n') + '\n';
};
// ─── Reports rendering ───────────────────────────────────────────────
let editingTaskId: string | null = null;
let deleteConfirmId: string | null = null;
let deleteConfirmTimer: number | undefined;
const refreshReports = async (): Promise => {
const list = reportsCard.querySelector('[data-role=list]')!;
const countEl = reportsCard.querySelector('[data-role=count]')!;
list.innerHTML = '
Loading…
';
countEl.textContent = '';
if (!client.query) {
list.innerHTML = `
RPC channel not available — daemon may be too old.