copilot-tap-extension 2.0.8 → 2.0.9

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.
Files changed (54) hide show
  1. package/README.md +2 -1
  2. package/SOUL.md +51 -0
  3. package/bin/install.mjs +2 -1
  4. package/dist/copilot-instructions.md +5 -0
  5. package/dist/extension.mjs +361 -20
  6. package/dist/version.json +1 -1
  7. package/docs/adr/0001-persistent-config-default-ownership.md +33 -0
  8. package/docs/adr/0002-local-provider-gateway-runtime-security.md +36 -0
  9. package/docs/adr/0003-emitter-delivery-lifecycle.md +68 -0
  10. package/docs/adr/0004-persistent-config-canonical-streams.md +86 -0
  11. package/docs/adr/0005-provider-sdk-push-and-dynamic-tools.md +48 -0
  12. package/docs/adr/0006-command-emitter-cwd-workspace-boundary.md +46 -0
  13. package/docs/adr/0007-runtime-session-workspace-context.md +62 -0
  14. package/docs/evals.md +41 -0
  15. package/docs/evolution-of-tap-icon.html +989 -0
  16. package/docs/providers.md +242 -0
  17. package/docs/recipes/adaptive-agent.md +303 -0
  18. package/docs/recipes/agent-brainstorm/100-extension-ideas.md +288 -0
  19. package/docs/recipes/agent-brainstorm/deep-ideas.md +216 -0
  20. package/docs/recipes/ambient-guardian.md +314 -0
  21. package/docs/recipes/browser-bridge.md +162 -0
  22. package/docs/recipes/codex-goals-for-tap-goal.md +136 -0
  23. package/docs/recipes/copilot-sdk-canvas.md +147 -0
  24. package/docs/recipes/deferred-cognition.md +310 -0
  25. package/docs/recipes/provider-integration-patterns.md +93 -0
  26. package/docs/recipes/provider-interface-advanced.md +1364 -0
  27. package/docs/recipes/provider-interface-core-profile.md +568 -0
  28. package/docs/recipes/tap-control-plane-roadmap.md +60 -0
  29. package/docs/recipes/universal-tool-gateway.md +202 -0
  30. package/docs/reference.md +229 -0
  31. package/docs/use-cases.md +348 -0
  32. package/package.json +4 -1
  33. package/providers/detour/README.md +84 -0
  34. package/providers/detour/bridge.js +219 -0
  35. package/providers/detour/index.mjs +322 -0
  36. package/providers/detour/package-lock.json +577 -0
  37. package/providers/detour/package.json +19 -0
  38. package/providers/detour/scripts/build.mjs +31 -0
  39. package/providers/detour/src/bridge.js +256 -0
  40. package/providers/detour/src/contracts.js +40 -0
  41. package/providers/detour/src/inspector.js +260 -0
  42. package/providers/detour/src/inspector.test.mjs +53 -0
  43. package/providers/detour/src/panel.js +465 -0
  44. package/providers/detour/src/provider-core.js +233 -0
  45. package/providers/detour/src/provider-core.test.mjs +185 -0
  46. package/providers/detour/src/react-context-core.js +143 -0
  47. package/providers/detour/src/react-context.js +44 -0
  48. package/providers/detour/src/react-context.test.mjs +41 -0
  49. package/providers/templates/README.md +23 -0
  50. package/providers/templates/ci-review-provider.mjs +46 -0
  51. package/providers/templates/detour-workflow-provider.mjs +41 -0
  52. package/providers/templates/jira-github-provider.mjs +42 -0
  53. package/providers/templates/provider-utils.mjs +45 -0
  54. package/providers/templates/sast-triage-provider.mjs +51 -0
@@ -0,0 +1,256 @@
1
+ import { MESSAGE_TYPES } from "./contracts.js";
2
+
3
+ /**
4
+ * Detour ↔ Agent Bridge (v2)
5
+ *
6
+ * Injected by the Detour Chrome extension via "Inject on load" rules.
7
+ * Single bundled file that includes:
8
+ * - WebSocket connection to provider
9
+ * - Console log capture
10
+ * - Remote JS eval from agent
11
+ * - Context panel (element picker, annotations, chat, detail chooser)
12
+ * - React component extraction (best-effort via bippy)
13
+ */
14
+
15
+ var _createPanel = null;
16
+ try {
17
+ var panelModule = require("./panel.js");
18
+ _createPanel = panelModule.createPanel;
19
+ } catch (e) {
20
+ // Panel module failed to load — core bridge still works
21
+ if (typeof console !== "undefined") console.warn("[Detour] Panel module failed to load:", e.message);
22
+ }
23
+
24
+ (function () {
25
+ "use strict";
26
+ if (window.__detourBridge) return;
27
+
28
+ var WS_URL = "__DET0UR_WS_URL__";
29
+ var RECONNECT_MS = 3000;
30
+ var ws = null;
31
+
32
+ var bridgeAPI = {
33
+ connected: false,
34
+ send: null,
35
+ ask: null,
36
+ sendMessage: sendMessage,
37
+ };
38
+ window.__detourBridge = bridgeAPI;
39
+
40
+ // ── Console intercept ─────────────────────────────────────────────────
41
+ var orig = {};
42
+ ["log", "warn", "error", "info", "debug"].forEach(function (level) {
43
+ orig[level] = console[level].bind(console);
44
+ console[level] = function () {
45
+ var args = Array.prototype.slice.call(arguments);
46
+ orig[level].apply(console, args);
47
+ sendConsole(level, args);
48
+ };
49
+ });
50
+
51
+ window.addEventListener("error", function (e) {
52
+ sendConsole("error", ["Uncaught: " + e.message + " at " + e.filename + ":" + e.lineno + ":" + e.colno]);
53
+ });
54
+
55
+ window.addEventListener("unhandledrejection", function (e) {
56
+ sendConsole("error", ["Unhandled rejection: " + e.reason]);
57
+ });
58
+
59
+ function sendConsole(level, args) {
60
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
61
+ var serialized = args.map(function (a) {
62
+ try {
63
+ if (typeof a === "string") return a;
64
+ if (a instanceof Error) return a.name + ": " + a.message;
65
+ if (a instanceof HTMLElement) return a.outerHTML.slice(0, 200);
66
+ return JSON.stringify(a);
67
+ } catch (e) { return String(a); }
68
+ });
69
+ try {
70
+ ws.send(JSON.stringify({
71
+ type: MESSAGE_TYPES.CONSOLE,
72
+ level: level,
73
+ args: serialized,
74
+ timestamp: new Date().toISOString(),
75
+ }));
76
+ } catch (e) { /* ignore */ }
77
+ }
78
+
79
+ // ── Serializer ────────────────────────────────────────────────────────
80
+ function serialize(value) {
81
+ if (value === undefined) return "undefined";
82
+ if (value === null) return "null";
83
+ if (value instanceof HTMLElement) return value.outerHTML.slice(0, 5000);
84
+ if (typeof value === "object") {
85
+ try { return JSON.stringify(value, null, 2); }
86
+ catch (e) { return String(value); }
87
+ }
88
+ return String(value);
89
+ }
90
+
91
+ // ── Eval handler ──────────────────────────────────────────────────────
92
+ function handleEval(msg) {
93
+ var result = { type: MESSAGE_TYPES.EVAL_RESULT, id: msg.id };
94
+ try {
95
+ var value = (0, eval)(msg.code);
96
+ if (value && typeof value.then === "function") {
97
+ value.then(
98
+ function (resolved) { result.value = serialize(resolved); ws.send(JSON.stringify(result)); },
99
+ function (rejected) { result.error = String(rejected); ws.send(JSON.stringify(result)); }
100
+ );
101
+ } else {
102
+ result.value = serialize(value);
103
+ ws.send(JSON.stringify(result));
104
+ }
105
+ } catch (err) {
106
+ result.error = err.name + ": " + err.message;
107
+ ws.send(JSON.stringify(result));
108
+ }
109
+ }
110
+
111
+ // ── Messaging ─────────────────────────────────────────────────────────
112
+ function sendMessage(type, data) {
113
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
114
+ orig.warn("[Detour] Not connected — message not sent");
115
+ return false;
116
+ }
117
+ try {
118
+ var payload = JSON.stringify({ type: type, ...data });
119
+ ws.send(payload);
120
+ orig.log("[Detour] Sent:", type, "(" + payload.length + " bytes)");
121
+ return true;
122
+ } catch (e) {
123
+ orig.error("[Detour] Send failed:", e.message);
124
+ return false;
125
+ }
126
+ }
127
+
128
+ var pendingAsks = {};
129
+ var askIdCounter = 0;
130
+
131
+ bridgeAPI.send = function (message) {
132
+ sendMessage(MESSAGE_TYPES.PAGE_MESSAGE, { message });
133
+ };
134
+
135
+ bridgeAPI.ask = function (message, timeoutMs) {
136
+ return new Promise(function (resolve, reject) {
137
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
138
+ return reject(new Error("Detour bridge not connected"));
139
+ }
140
+ var id = "ask-" + (++askIdCounter);
141
+ var timer = setTimeout(function () {
142
+ delete pendingAsks[id];
143
+ reject(new Error("Ask timed out"));
144
+ }, timeoutMs || 30000);
145
+ pendingAsks[id] = { resolve, reject, timer };
146
+ ws.send(JSON.stringify({ type: MESSAGE_TYPES.PAGE_ASK, id, message }));
147
+ });
148
+ };
149
+
150
+ // ── Status badge ──────────────────────────────────────────────────────
151
+ // Minimal badge shown before panel loads, replaced by FAB once panel mounts
152
+ var badge = document.createElement("div");
153
+ badge.id = "__detour-badge";
154
+ badge.setAttribute("style",
155
+ "position:fixed;bottom:12px;right:12px;z-index:2147483646;" +
156
+ "padding:6px 12px;border-radius:20px;font:600 12px/1 system-ui,sans-serif;" +
157
+ "color:#fff;background:#d44;opacity:0.92;pointer-events:none;" +
158
+ "transition:background .3s,opacity .3s;box-shadow:0 2px 8px rgba(0,0,0,.3);"
159
+ );
160
+ badge.textContent = "⚡ connecting…";
161
+
162
+ function showBadge() {
163
+ if (!badge.parentNode) (document.body || document.documentElement).appendChild(badge);
164
+ }
165
+ function hideBadge() {
166
+ if (badge.parentNode) badge.parentNode.removeChild(badge);
167
+ }
168
+ function setBadgeState(state) {
169
+ if (state === "connected") {
170
+ badge.textContent = "⚡ Detour: connected";
171
+ badge.style.background = "#1a8c3a";
172
+ badge.style.opacity = "0.92";
173
+ clearTimeout(badge._t);
174
+ badge._t = setTimeout(function () { badge.style.opacity = "0"; }, 3000);
175
+ } else if (state === "disconnected") {
176
+ badge.textContent = "⚡ Detour: disconnected";
177
+ badge.style.background = "#d44";
178
+ badge.style.opacity = "0.92";
179
+ clearTimeout(badge._t);
180
+ } else {
181
+ badge.textContent = "⚡ connecting…";
182
+ badge.style.background = "#c90";
183
+ badge.style.opacity = "0.92";
184
+ clearTimeout(badge._t);
185
+ }
186
+ }
187
+
188
+ // ── Context panel ─────────────────────────────────────────────────────
189
+ var panel = null;
190
+ function initPanel() {
191
+ if (panel || !_createPanel) return;
192
+ try {
193
+ panel = _createPanel(bridgeAPI);
194
+ panel.mount();
195
+ hideBadge();
196
+ } catch (e) {
197
+ orig.warn("[Detour] Panel init failed:", e.message);
198
+ }
199
+ }
200
+
201
+ // ── WebSocket connection ──────────────────────────────────────────────
202
+ function connect() {
203
+ setBadgeState("connecting");
204
+ showBadge();
205
+ try { ws = new WebSocket(WS_URL); } catch (e) {
206
+ setBadgeState("disconnected");
207
+ setTimeout(connect, RECONNECT_MS);
208
+ return;
209
+ }
210
+
211
+ ws.onopen = function () {
212
+ bridgeAPI.connected = true;
213
+ setBadgeState("connected");
214
+ orig.log("%c⚡ Detour bridge connected", "color:#0f0;font-weight:bold;font-size:13px");
215
+ orig.log("%c Panel: click ⚡ FAB (bottom-right)", "color:#aaa");
216
+ ws.send(JSON.stringify({
217
+ type: MESSAGE_TYPES.IDENTIFY,
218
+ url: location.href,
219
+ title: document.title || location.hostname,
220
+ }));
221
+ // Init panel once connected
222
+ setTimeout(initPanel, 100);
223
+ };
224
+
225
+ ws.onmessage = function (event) {
226
+ var msg;
227
+ try { msg = JSON.parse(event.data); } catch (e) { return; }
228
+
229
+ if (msg.type === MESSAGE_TYPES.EVAL) handleEval(msg);
230
+ if (msg.type === MESSAGE_TYPES.ASK_REPLY) {
231
+ var pending = pendingAsks[msg.id];
232
+ if (pending) {
233
+ clearTimeout(pending.timer);
234
+ delete pendingAsks[msg.id];
235
+ if (msg.error) pending.reject(new Error(msg.error));
236
+ else pending.resolve(msg.reply);
237
+ }
238
+ }
239
+ if (msg.type === MESSAGE_TYPES.AGENT_REPLY && panel) {
240
+ panel.showAgentReply(msg.message);
241
+ }
242
+ };
243
+
244
+ ws.onclose = function () {
245
+ bridgeAPI.connected = false;
246
+ setBadgeState("disconnected");
247
+ showBadge();
248
+ orig.log("%c⚡ Detour bridge disconnected, reconnecting...", "color:#f80");
249
+ setTimeout(connect, RECONNECT_MS);
250
+ };
251
+
252
+ ws.onerror = function () { /* onclose fires */ };
253
+ }
254
+
255
+ connect();
256
+ })();
@@ -0,0 +1,40 @@
1
+ export const MESSAGE_TYPES = Object.freeze({
2
+ IDENTIFY: "identify",
3
+ CONSOLE: "console",
4
+ EVAL: "eval",
5
+ EVAL_RESULT: "eval.result",
6
+ PAGE_MESSAGE: "page.message",
7
+ PAGE_ASK: "page.ask",
8
+ ASK_REPLY: "ask.reply",
9
+ AGENT_REPLY: "agent.reply",
10
+ PAGE_CONTEXT: "page.context",
11
+ PAGE_ANNOTATE: "page.annotate",
12
+ });
13
+
14
+ export const DETAIL_LEVELS = Object.freeze({
15
+ COMPACT: "compact",
16
+ STANDARD: "standard",
17
+ DETAILED: "detailed",
18
+ FORENSIC: "forensic",
19
+ });
20
+
21
+ export const DETAIL_LEVEL_OPTIONS = Object.freeze([
22
+ { value: DETAIL_LEVELS.COMPACT, label: "Compact" },
23
+ { value: DETAIL_LEVELS.STANDARD, label: "Standard" },
24
+ { value: DETAIL_LEVELS.DETAILED, label: "Detailed" },
25
+ { value: DETAIL_LEVELS.FORENSIC, label: "Forensic" },
26
+ ]);
27
+
28
+ export const INTENT_TOKENS = Object.freeze({
29
+ FIX: "fix",
30
+ CHANGE: "change",
31
+ QUESTION: "question",
32
+ APPROVE: "approve",
33
+ });
34
+
35
+ export const INTENT_OPTIONS = Object.freeze([
36
+ { value: INTENT_TOKENS.FIX, icon: "🔧", label: "Fix" },
37
+ { value: INTENT_TOKENS.CHANGE, icon: "✏️", label: "Change" },
38
+ { value: INTENT_TOKENS.QUESTION, icon: "❓", label: "Question" },
39
+ { value: INTENT_TOKENS.APPROVE, icon: "✅", label: "OK" },
40
+ ]);
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Element Inspector — extracts structured context from DOM elements.
3
+ *
4
+ * 4 detail levels:
5
+ * compact: element name + tag
6
+ * standard: + selector, bounding box, viewport, tag path
7
+ * detailed: + CSS classes, key styles, nearby text
8
+ * forensic: + full DOM path, accessibility, React context, source file
9
+ */
10
+
11
+ import { getReactContext, isReactDetected, getNearestComponentName } from "./react-context.js";
12
+ import { DETAIL_LEVELS } from "./contracts.js";
13
+
14
+ // ── Element identification ──────────────────────────────────────────────
15
+
16
+ /**
17
+ * Deep element from point — pierces shadow DOM.
18
+ */
19
+ export function deepElementFromPoint(x, y) {
20
+ let element = document.elementFromPoint(x, y);
21
+ while (element && element.shadowRoot) {
22
+ const deeper = element.shadowRoot.elementFromPoint(x, y);
23
+ if (!deeper || deeper === element) break;
24
+ element = deeper;
25
+ }
26
+ return element;
27
+ }
28
+
29
+ /**
30
+ * Build a concise CSS selector for an element.
31
+ */
32
+ export function buildSelector(el) {
33
+ if (!el || el === document.body || el === document.documentElement) return el ? el.tagName.toLowerCase() : "";
34
+
35
+ if (el.id) return `#${CSS.escape(el.id)}`;
36
+
37
+ const tag = el.tagName.toLowerCase();
38
+ const classes = Array.from(el.classList)
39
+ .filter((c) => c.length < 30 && !/^[a-z]{6,}$/.test(c)) // skip hash classes
40
+ .slice(0, 3);
41
+
42
+ if (classes.length > 0) {
43
+ const sel = `${tag}.${classes.map(CSS.escape).join(".")}`;
44
+ if (document.querySelectorAll(sel).length === 1) return sel;
45
+ }
46
+
47
+ // Fallback: nth-child path
48
+ const parent = el.parentElement;
49
+ if (!parent) return tag;
50
+ const siblings = Array.from(parent.children).filter((c) => c.tagName === el.tagName);
51
+ if (siblings.length === 1) return `${buildSelector(parent)} > ${tag}`;
52
+ const idx = siblings.indexOf(el) + 1;
53
+ return `${buildSelector(parent)} > ${tag}:nth-child(${idx})`;
54
+ }
55
+
56
+ /**
57
+ * Build a human-readable tag path: article > section > div > button
58
+ */
59
+ function buildTagPath(el) {
60
+ const parts = [];
61
+ let current = el;
62
+ let depth = 0;
63
+ while (current && current !== document.body && depth < 8) {
64
+ const tag = current.tagName.toLowerCase();
65
+ const cls = current.classList.length > 0 ? `.${Array.from(current.classList).slice(0, 2).join(".")}` : "";
66
+ parts.unshift(tag + cls);
67
+ current = current.parentElement;
68
+ depth++;
69
+ }
70
+ return parts.join(" > ");
71
+ }
72
+
73
+ /**
74
+ * Build full DOM path from document root.
75
+ */
76
+ function buildFullPath(el) {
77
+ const parts = [];
78
+ let current = el;
79
+ while (current && current !== document) {
80
+ const tag = current.tagName ? current.tagName.toLowerCase() : "";
81
+ if (tag) parts.unshift(tag + (current.id ? `#${current.id}` : ""));
82
+ current = current.parentNode;
83
+ }
84
+ return parts.join(" > ");
85
+ }
86
+
87
+ // ── Style extraction ────────────────────────────────────────────────────
88
+
89
+ const KEY_STYLE_PROPS = [
90
+ "display", "position", "color", "backgroundColor", "fontSize",
91
+ "fontWeight", "padding", "margin", "border", "borderRadius",
92
+ "width", "height", "overflow", "opacity", "zIndex",
93
+ ];
94
+
95
+ function getKeyStyles(el) {
96
+ const computed = getComputedStyle(el);
97
+ const styles = {};
98
+ for (const prop of KEY_STYLE_PROPS) {
99
+ const val = computed[prop];
100
+ if (val && val !== "none" && val !== "normal" && val !== "auto" && val !== "0px" && val !== "rgba(0, 0, 0, 0)") {
101
+ styles[prop] = val;
102
+ }
103
+ }
104
+ return Object.keys(styles).length > 0 ? styles : undefined;
105
+ }
106
+
107
+ // ── Text extraction ─────────────────────────────────────────────────────
108
+
109
+ function getNearbyText(el) {
110
+ const own = el.textContent || "";
111
+ const trimmed = own.replace(/\s+/g, " ").trim().slice(0, 150);
112
+ return trimmed || undefined;
113
+ }
114
+
115
+ // ── Accessibility ───────────────────────────────────────────────────────
116
+
117
+ function getAccessibility(el) {
118
+ const info = {};
119
+ const role = el.getAttribute("role");
120
+ const ariaLabel = el.getAttribute("aria-label");
121
+ const ariaDescribedBy = el.getAttribute("aria-describedby");
122
+ const altText = el.getAttribute("alt");
123
+ const title = el.getAttribute("title");
124
+
125
+ if (role) info.role = role;
126
+ if (ariaLabel) info.ariaLabel = ariaLabel;
127
+ if (ariaDescribedBy) info.ariaDescribedBy = ariaDescribedBy;
128
+ if (altText) info.alt = altText;
129
+ if (title) info.title = title;
130
+
131
+ return Object.keys(info).length > 0 ? info : undefined;
132
+ }
133
+
134
+ // ── Element display name ────────────────────────────────────────────────
135
+
136
+ export function getElementDisplayName(el) {
137
+ const tag = el.tagName.toLowerCase();
138
+ const reactName = getNearestComponentName(el);
139
+ if (reactName) return `<${reactName}> (${tag})`;
140
+ if (el.id) return `${tag}#${el.id}`;
141
+ if (el.classList.length > 0) return `${tag}.${Array.from(el.classList).slice(0, 2).join(".")}`;
142
+ return tag;
143
+ }
144
+
145
+ // ── Main extraction function ────────────────────────────────────────────
146
+
147
+ /**
148
+ * Extract structured context from an element at a given detail level.
149
+ *
150
+ * @param {HTMLElement} element
151
+ * @param {string} detailLevel — "compact" | "standard" | "detailed" | "forensic"
152
+ * @returns {object} context
153
+ */
154
+ export function extractElementContext(element, detailLevel = DETAIL_LEVELS.STANDARD) {
155
+ if (!element) return null;
156
+
157
+ const tag = element.tagName.toLowerCase();
158
+
159
+ // Compact: minimal info
160
+ const context = {
161
+ tag,
162
+ displayName: getElementDisplayName(element),
163
+ };
164
+
165
+ if (detailLevel === DETAIL_LEVELS.COMPACT) return context;
166
+
167
+ // Standard: + selector, bounding box, viewport, tag path
168
+ context.selector = buildSelector(element);
169
+ context.tagPath = buildTagPath(element);
170
+ const rect = element.getBoundingClientRect();
171
+ context.boundingBox = {
172
+ x: Math.round(rect.x),
173
+ y: Math.round(rect.y),
174
+ width: Math.round(rect.width),
175
+ height: Math.round(rect.height),
176
+ };
177
+ context.viewport = { width: window.innerWidth, height: window.innerHeight };
178
+ context.text = getNearbyText(element);
179
+
180
+ if (detailLevel === DETAIL_LEVELS.STANDARD) return context;
181
+
182
+ // Detailed: + classes, styles, nearby text
183
+ context.classes = element.className || undefined;
184
+ context.styles = getKeyStyles(element);
185
+
186
+ if (detailLevel === DETAIL_LEVELS.DETAILED) return context;
187
+
188
+ // Forensic: + full DOM path, accessibility, React context, source
189
+ context.fullDOMPath = buildFullPath(element);
190
+ context.accessibility = getAccessibility(element);
191
+
192
+ const reactCtx = getReactContext(element);
193
+ if (reactCtx) {
194
+ context.reactComponent = reactCtx.component;
195
+ context.reactHierarchy = reactCtx.hierarchy;
196
+ if (reactCtx.source) {
197
+ context.sourceFile = reactCtx.source.fileName;
198
+ if (reactCtx.source.lineNumber) context.sourceLine = reactCtx.source.lineNumber;
199
+ }
200
+ }
201
+
202
+ return context;
203
+ }
204
+
205
+ /**
206
+ * Generate structured markdown from annotations for AI consumption.
207
+ */
208
+ function appendPageMarkdownHeader(lines) {
209
+ const url = location.href;
210
+ const title = document.title;
211
+
212
+ lines.push(`## Page Feedback: ${title}`);
213
+ lines.push(`**URL:** ${url}`);
214
+ lines.push(`**Viewport:** ${window.innerWidth}×${window.innerHeight}`);
215
+ if (isReactDetected()) lines.push(`**Framework:** React detected`);
216
+ lines.push("");
217
+ }
218
+
219
+ function formatAnnotationSource(context) {
220
+ if (!context?.sourceFile) return "";
221
+ return ` (${context.sourceFile}${context.sourceLine ? ":" + context.sourceLine : ""})`;
222
+ }
223
+
224
+ function annotationHeading(ann, num) {
225
+ const header = ann.context?.selector || ann.context?.displayName || `Annotation ${num}`;
226
+ return `${header}${formatAnnotationSource(ann.context)}`;
227
+ }
228
+
229
+ function appendAnnotationContextMarkdown(lines, ctx) {
230
+ if (ctx.tagPath) lines.push(`**Path:** ${ctx.tagPath}`);
231
+ if (ctx.classes) lines.push(`**Classes:** ${ctx.classes}`);
232
+ if (ctx.boundingBox) lines.push(`**Position:** ${ctx.boundingBox.x},${ctx.boundingBox.y} (${ctx.boundingBox.width}×${ctx.boundingBox.height}px)`);
233
+ if (ctx.reactComponent) lines.push(`**React:** ${ctx.reactHierarchy ? ctx.reactHierarchy.join(" > ") : ctx.reactComponent}`);
234
+ if (ctx.styles) lines.push(`**Styles:** ${JSON.stringify(ctx.styles)}`);
235
+ if (ctx.accessibility) lines.push(`**A11y:** ${JSON.stringify(ctx.accessibility)}`);
236
+ if (ctx.text) lines.push(`**Text:** "${ctx.text}"`);
237
+ }
238
+
239
+ function appendAnnotationMarkdown(lines, ann, num) {
240
+ lines.push(`### ${num}. ${annotationHeading(ann, num)}`);
241
+
242
+ if (ann.context) {
243
+ appendAnnotationContextMarkdown(lines, ann.context);
244
+ }
245
+
246
+ if (ann.intent) lines.push(`**Intent:** ${ann.intent}`);
247
+ if (ann.comment) lines.push(`**Feedback:** ${ann.comment}`);
248
+ lines.push("");
249
+ }
250
+
251
+ export function generateAnnotationMarkdown(annotations, detailLevel = DETAIL_LEVELS.STANDARD) {
252
+ const lines = [];
253
+ appendPageMarkdownHeader(lines);
254
+
255
+ for (let i = 0; i < annotations.length; i++) {
256
+ appendAnnotationMarkdown(lines, annotations[i], i + 1);
257
+ }
258
+
259
+ return lines.join("\n");
260
+ }
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { generateAnnotationMarkdown } from "./inspector.js";
5
+
6
+ test("generateAnnotationMarkdown preserves ordering, fallbacks, and formatting", () => {
7
+ globalThis.document = { title: "Checkout" };
8
+ globalThis.location = { href: "https://example.test/cart?step=pay" };
9
+ globalThis.window = { innerWidth: 1024, innerHeight: 768 };
10
+
11
+ const markdown = generateAnnotationMarkdown([
12
+ {
13
+ context: {
14
+ selector: "#pay",
15
+ displayName: "button.primary",
16
+ sourceFile: "/src/Checkout.jsx",
17
+ sourceLine: 42,
18
+ tagPath: "main > form > button.primary",
19
+ classes: "primary cta",
20
+ boundingBox: { x: 10, y: 20, width: 200, height: 40 },
21
+ reactComponent: "PayButton",
22
+ reactHierarchy: ["CheckoutPage", "PaymentForm", "PayButton"],
23
+ styles: { color: "rgb(255, 255, 255)", backgroundColor: "rgb(0, 0, 0)" },
24
+ accessibility: { role: "button", ariaLabel: "Pay now" },
25
+ text: "Pay now",
26
+ },
27
+ intent: "fix",
28
+ comment: "Make the loading state clearer.",
29
+ },
30
+ {
31
+ comment: "This annotation intentionally has no context.",
32
+ },
33
+ ]);
34
+
35
+ assert.equal(markdown, `## Page Feedback: Checkout
36
+ **URL:** https://example.test/cart?step=pay
37
+ **Viewport:** 1024×768
38
+
39
+ ### 1. #pay (/src/Checkout.jsx:42)
40
+ **Path:** main > form > button.primary
41
+ **Classes:** primary cta
42
+ **Position:** 10,20 (200×40px)
43
+ **React:** CheckoutPage > PaymentForm > PayButton
44
+ **Styles:** {"color":"rgb(255, 255, 255)","backgroundColor":"rgb(0, 0, 0)"}
45
+ **A11y:** {"role":"button","ariaLabel":"Pay now"}
46
+ **Text:** "Pay now"
47
+ **Intent:** fix
48
+ **Feedback:** Make the loading state clearer.
49
+
50
+ ### 2. Annotation 2
51
+ **Feedback:** This annotation intentionally has no context.
52
+ `);
53
+ });