copilot-tap-extension 2.0.7 → 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.
- package/README.md +4 -1
- package/SOUL.md +51 -0
- package/bin/install.mjs +7 -1
- package/dist/copilot-instructions.md +15 -0
- package/dist/extension.mjs +823 -29
- package/dist/skills/tap-goal/SKILL.md +13 -2
- package/dist/skills/tap-loop/SKILL.md +6 -0
- package/dist/skills/tap-monitor/SKILL.md +19 -3
- package/dist/skills/tap-orchestrate/SKILL.md +81 -0
- package/dist/version.json +1 -1
- package/docs/adr/0001-persistent-config-default-ownership.md +33 -0
- package/docs/adr/0002-local-provider-gateway-runtime-security.md +36 -0
- package/docs/adr/0003-emitter-delivery-lifecycle.md +68 -0
- package/docs/adr/0004-persistent-config-canonical-streams.md +86 -0
- package/docs/adr/0005-provider-sdk-push-and-dynamic-tools.md +48 -0
- package/docs/adr/0006-command-emitter-cwd-workspace-boundary.md +46 -0
- package/docs/adr/0007-runtime-session-workspace-context.md +62 -0
- package/docs/evals.md +41 -0
- package/docs/evolution-of-tap-icon.html +989 -0
- package/docs/providers.md +242 -0
- package/docs/recipes/adaptive-agent.md +303 -0
- package/docs/recipes/agent-brainstorm/100-extension-ideas.md +288 -0
- package/docs/recipes/agent-brainstorm/deep-ideas.md +216 -0
- package/docs/recipes/ambient-guardian.md +314 -0
- package/docs/recipes/browser-bridge.md +162 -0
- package/docs/recipes/codex-goals-for-tap-goal.md +136 -0
- package/docs/recipes/copilot-sdk-canvas.md +147 -0
- package/docs/recipes/deferred-cognition.md +310 -0
- package/docs/recipes/provider-integration-patterns.md +93 -0
- package/docs/recipes/provider-interface-advanced.md +1364 -0
- package/docs/recipes/provider-interface-core-profile.md +568 -0
- package/docs/recipes/tap-control-plane-roadmap.md +60 -0
- package/docs/recipes/universal-tool-gateway.md +202 -0
- package/docs/reference.md +229 -0
- package/docs/use-cases.md +348 -0
- package/package.json +4 -1
- package/providers/detour/README.md +84 -0
- package/providers/detour/bridge.js +219 -0
- package/providers/detour/index.mjs +322 -0
- package/providers/detour/package-lock.json +577 -0
- package/providers/detour/package.json +19 -0
- package/providers/detour/scripts/build.mjs +31 -0
- package/providers/detour/src/bridge.js +256 -0
- package/providers/detour/src/contracts.js +40 -0
- package/providers/detour/src/inspector.js +260 -0
- package/providers/detour/src/inspector.test.mjs +53 -0
- package/providers/detour/src/panel.js +465 -0
- package/providers/detour/src/provider-core.js +233 -0
- package/providers/detour/src/provider-core.test.mjs +185 -0
- package/providers/detour/src/react-context-core.js +143 -0
- package/providers/detour/src/react-context.js +44 -0
- package/providers/detour/src/react-context.test.mjs +41 -0
- package/providers/templates/README.md +23 -0
- package/providers/templates/ci-review-provider.mjs +46 -0
- package/providers/templates/detour-workflow-provider.mjs +41 -0
- package/providers/templates/jira-github-provider.mjs +42 -0
- package/providers/templates/provider-utils.mjs +45 -0
- 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
|
+
});
|