@cyber-dash-tech/revela 0.9.0 → 0.11.0
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 +54 -9
- package/README.zh-CN.md +54 -9
- package/designs/monet/DESIGN.md +9 -9
- package/designs/starter/DESIGN.md +8 -8
- package/designs/summit/DESIGN.md +9 -9
- package/lib/commands/help.ts +2 -0
- package/lib/commands/inspect.ts +23 -0
- package/lib/commands/pdf.ts +33 -5
- package/lib/commands/pptx.ts +14 -9
- package/lib/commands/refine.ts +26 -0
- package/lib/commands/review.ts +8 -2
- package/lib/deck-html/contract.ts +252 -0
- package/lib/decks-state.ts +574 -31
- package/lib/document-materials/extract.ts +20 -0
- package/lib/edit/resolve-deck.ts +13 -2
- package/lib/inspect/open.ts +63 -0
- package/lib/inspect/prompt.ts +32 -0
- package/lib/inspect/request.ts +70 -0
- package/lib/inspect/requests.ts +86 -0
- package/lib/inspect/server.ts +1063 -0
- package/lib/inspect/slide-index.ts +12 -0
- package/lib/inspection-context/compile.ts +346 -0
- package/lib/inspection-context/match.ts +169 -0
- package/lib/inspection-context/project.ts +263 -0
- package/lib/inspection-context/result.ts +160 -0
- package/lib/qa/export-gate.ts +8 -1
- package/lib/refine/open.ts +70 -0
- package/lib/refine/server.ts +1581 -0
- package/lib/workspace-state/actions.ts +71 -0
- package/lib/workspace-state/compat.ts +10 -0
- package/lib/workspace-state/evidence-status.ts +267 -0
- package/lib/workspace-state/graph.ts +426 -0
- package/lib/workspace-state/render-targets.ts +182 -0
- package/lib/workspace-state/rendered-artifacts.ts +43 -0
- package/lib/workspace-state/repository.ts +43 -0
- package/lib/workspace-state/research-attachments.ts +130 -0
- package/lib/workspace-state/review-snapshots.ts +127 -0
- package/lib/workspace-state/types.ts +119 -0
- package/package.json +1 -1
- package/plugin.ts +48 -1
- package/skill/SKILL.md +10 -5
- package/tools/decks.ts +61 -2
- package/tools/inspection-context.ts +22 -0
- package/tools/inspection-result.ts +63 -0
- package/tools/pdf.ts +9 -1
- package/tools/pptx.ts +10 -0
- package/tools/research-save.ts +15 -0
- package/tools/workspace-scan.ts +15 -0
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
import { randomBytes } from "crypto"
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "fs"
|
|
3
|
+
import { dirname, extname, isAbsolute, resolve, sep } from "path"
|
|
4
|
+
import type { EditableDeck } from "../edit/resolve-deck"
|
|
5
|
+
import type { InspectionElementSnapshot } from "../inspection-context/match"
|
|
6
|
+
import { buildInspectionPrompt } from "./prompt"
|
|
7
|
+
import { projectWorkspaceElement } from "./request"
|
|
8
|
+
import { createInspectRequest, failInspectRequest, getInspectRequest } from "./requests"
|
|
9
|
+
|
|
10
|
+
const TOKEN_BYTES = 24
|
|
11
|
+
const SESSION_TTL_MS = 2 * 60 * 60 * 1000
|
|
12
|
+
const IDLE_STOP_MS = 30 * 60 * 1000
|
|
13
|
+
|
|
14
|
+
interface InspectAsset {
|
|
15
|
+
id: string
|
|
16
|
+
absoluteFile: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface InspectSession {
|
|
20
|
+
token: string
|
|
21
|
+
client: any
|
|
22
|
+
sessionID: string
|
|
23
|
+
deck: string
|
|
24
|
+
file: string
|
|
25
|
+
absoluteFile: string
|
|
26
|
+
workspaceRoot: string
|
|
27
|
+
assets: Map<string, InspectAsset>
|
|
28
|
+
assetKeys: Map<string, string>
|
|
29
|
+
nextAssetId: number
|
|
30
|
+
lastActiveAt: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface InspectServerHandle {
|
|
34
|
+
baseUrl: string
|
|
35
|
+
getOrCreateSession(input: { client: any; sessionID: string; workspaceRoot: string; deck: EditableDeck }): InspectServerSessionResult
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface InspectServerSessionResult {
|
|
39
|
+
token: string
|
|
40
|
+
reused: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let server: ReturnType<typeof Bun.serve> | undefined
|
|
44
|
+
let baseUrl = ""
|
|
45
|
+
let idleTimer: Timer | undefined
|
|
46
|
+
const sessions = new Map<string, InspectSession>()
|
|
47
|
+
|
|
48
|
+
export function startInspectServer(): InspectServerHandle {
|
|
49
|
+
if (!server) {
|
|
50
|
+
server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handleRequest })
|
|
51
|
+
baseUrl = `http://127.0.0.1:${server.port}`
|
|
52
|
+
scheduleIdleStop()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
baseUrl,
|
|
57
|
+
getOrCreateSession(input) {
|
|
58
|
+
cleanupExpiredSessions()
|
|
59
|
+
const existing = findSessionForDeck(input.deck.absoluteFile)
|
|
60
|
+
if (existing) {
|
|
61
|
+
existing.session.client = input.client
|
|
62
|
+
existing.session.sessionID = input.sessionID
|
|
63
|
+
existing.session.deck = input.deck.slug
|
|
64
|
+
existing.session.file = input.deck.file
|
|
65
|
+
existing.session.workspaceRoot = resolve(input.workspaceRoot)
|
|
66
|
+
return { token: existing.token, reused: true }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const token = randomBytes(TOKEN_BYTES).toString("base64url")
|
|
70
|
+
sessions.set(token, {
|
|
71
|
+
token,
|
|
72
|
+
client: input.client,
|
|
73
|
+
sessionID: input.sessionID,
|
|
74
|
+
deck: input.deck.slug,
|
|
75
|
+
file: input.deck.file,
|
|
76
|
+
absoluteFile: input.deck.absoluteFile,
|
|
77
|
+
workspaceRoot: resolve(input.workspaceRoot),
|
|
78
|
+
assets: new Map(),
|
|
79
|
+
assetKeys: new Map(),
|
|
80
|
+
nextAssetId: 1,
|
|
81
|
+
lastActiveAt: Date.now(),
|
|
82
|
+
})
|
|
83
|
+
return { token, reused: false }
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function stopInspectServer(): void {
|
|
89
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
90
|
+
idleTimer = undefined
|
|
91
|
+
sessions.clear()
|
|
92
|
+
server?.stop()
|
|
93
|
+
server = undefined
|
|
94
|
+
baseUrl = ""
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function handleRequest(req: Request): Promise<Response> {
|
|
98
|
+
cleanupExpiredSessions()
|
|
99
|
+
const url = new URL(req.url)
|
|
100
|
+
|
|
101
|
+
if (url.pathname === "/health") return textResponse("ok")
|
|
102
|
+
|
|
103
|
+
if (url.pathname === "/inspect" && req.method === "GET") {
|
|
104
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
105
|
+
if (!session.ok) return session.response
|
|
106
|
+
return htmlResponse(renderInspectorShell(session.value.token))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (url.pathname === "/deck" && req.method === "GET") {
|
|
110
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
111
|
+
if (!session.ok) return session.response
|
|
112
|
+
return handleDeck(session.value)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (url.pathname === "/__revela_asset" && (req.method === "GET" || req.method === "HEAD")) {
|
|
116
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
117
|
+
if (!session.ok) return session.response
|
|
118
|
+
return handleAsset(session.value, url.searchParams.get("id"), req.method)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (url.pathname === "/api/inspect" && req.method === "POST") {
|
|
122
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
123
|
+
if (!session.ok) return session.response
|
|
124
|
+
return handleInspect(req, session.value)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (url.pathname === "/api/inspect-result" && req.method === "GET") {
|
|
128
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
129
|
+
if (!session.ok) return session.response
|
|
130
|
+
return handleInspectResult(url.searchParams.get("requestId"), session.value)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (url.pathname === "/api/deck-version" && req.method === "GET") {
|
|
134
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
135
|
+
if (!session.ok) return session.response
|
|
136
|
+
return jsonResponse({ ok: true, ...readDeckVersion(session.value) })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return textResponse("Not found", 404)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function handleDeck(session: InspectSession): Response {
|
|
143
|
+
session.assets.clear()
|
|
144
|
+
session.assetKeys.clear()
|
|
145
|
+
session.nextAssetId = 1
|
|
146
|
+
const html = readFileSync(session.absoluteFile, "utf-8")
|
|
147
|
+
return htmlResponse(rewriteLocalAssetRefs(html, session, session.absoluteFile, "html"))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function handleAsset(session: InspectSession, id: string | null, method: string): Response {
|
|
151
|
+
if (!id) return textResponse("Missing asset id", 400)
|
|
152
|
+
const asset = session.assets.get(id)
|
|
153
|
+
if (!asset) return textResponse("Asset not found", 404)
|
|
154
|
+
if (!existsSync(asset.absoluteFile)) return textResponse("Asset file not found", 404)
|
|
155
|
+
if (!statSync(asset.absoluteFile).isFile()) return textResponse("Asset is not a file", 404)
|
|
156
|
+
|
|
157
|
+
const mime = mimeTypeForPath(asset.absoluteFile)
|
|
158
|
+
const headers = { "content-type": mime, "cache-control": "no-store, max-age=0" }
|
|
159
|
+
if (method === "HEAD") return new Response(null, { status: 200, headers })
|
|
160
|
+
|
|
161
|
+
if (mime === "text/css") {
|
|
162
|
+
const css = readFileSync(asset.absoluteFile, "utf-8")
|
|
163
|
+
return new Response(rewriteLocalAssetRefs(css, session, asset.absoluteFile, "css"), { status: 200, headers })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return new Response(new Uint8Array(readFileSync(asset.absoluteFile)), { status: 200, headers })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function handleInspect(req: Request, session: InspectSession): Promise<Response> {
|
|
170
|
+
let body: any
|
|
171
|
+
try {
|
|
172
|
+
body = await req.json()
|
|
173
|
+
} catch {
|
|
174
|
+
return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const snapshot = normalizeSnapshot(body?.snapshot ?? body)
|
|
178
|
+
const requestId = typeof body?.requestId === "string" && body.requestId.trim() ? body.requestId.trim() : randomBytes(10).toString("base64url")
|
|
179
|
+
const version = readDeckVersion(session).version
|
|
180
|
+
const staleReason = typeof body?.deckVersion === "string" && body.deckVersion !== version
|
|
181
|
+
? "Deck changed after the browser captured this selection. Re-select the element for the freshest inspection."
|
|
182
|
+
: undefined
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const { projection, preprocess } = projectWorkspaceElement(session.workspaceRoot, snapshot, { requestId })
|
|
186
|
+
createInspectRequest({ requestId, projection, deckVersion: version })
|
|
187
|
+
|
|
188
|
+
session.lastActiveAt = Date.now()
|
|
189
|
+
scheduleIdleStop()
|
|
190
|
+
|
|
191
|
+
void session.client.session.prompt({
|
|
192
|
+
path: { id: session.sessionID },
|
|
193
|
+
body: {
|
|
194
|
+
parts: [{
|
|
195
|
+
type: "text",
|
|
196
|
+
text: buildInspectionPrompt({
|
|
197
|
+
requestId,
|
|
198
|
+
file: session.file,
|
|
199
|
+
projection: staleReason
|
|
200
|
+
? { ...projection, stale: { stale: true, reason: staleReason } } as any
|
|
201
|
+
: projection,
|
|
202
|
+
}),
|
|
203
|
+
}],
|
|
204
|
+
},
|
|
205
|
+
}).catch((error: unknown) => {
|
|
206
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
207
|
+
failInspectRequest(requestId, message)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
return jsonResponse({ ok: true, requestId, deckVersion: version, status: "pending", preprocess })
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
213
|
+
failInspectRequest(requestId, message)
|
|
214
|
+
return jsonResponse({ ok: false, requestId, deckVersion: version, error: message }, 400)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function handleInspectResult(requestId: string | null, session: InspectSession): Response {
|
|
219
|
+
if (!requestId) return jsonResponse({ ok: false, error: "Missing requestId" }, 400)
|
|
220
|
+
const request = getInspectRequest(requestId)
|
|
221
|
+
if (!request) return jsonResponse({ ok: false, requestId, error: "Inspection request not found" }, 404)
|
|
222
|
+
session.lastActiveAt = Date.now()
|
|
223
|
+
scheduleIdleStop()
|
|
224
|
+
if (request.status === "completed") {
|
|
225
|
+
return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion, result: request.result })
|
|
226
|
+
}
|
|
227
|
+
if (request.status === "failed" || request.status === "expired") {
|
|
228
|
+
return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion, error: request.error || "Inspection failed" })
|
|
229
|
+
}
|
|
230
|
+
return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function normalizeSnapshot(input: any): InspectionElementSnapshot {
|
|
234
|
+
return {
|
|
235
|
+
scope: input?.scope === "selection" || input?.scope === "slide" || input?.scope === "element" ? input.scope : undefined,
|
|
236
|
+
slideIndex: typeof input?.slideIndex === "number" ? input.slideIndex : undefined,
|
|
237
|
+
text: typeof input?.text === "string" ? input.text : undefined,
|
|
238
|
+
selectedText: typeof input?.selectedText === "string" ? input.selectedText : undefined,
|
|
239
|
+
tagName: typeof input?.tagName === "string" ? input.tagName : undefined,
|
|
240
|
+
slideTitle: typeof input?.slideTitle === "string" ? input.slideTitle : undefined,
|
|
241
|
+
selector: typeof input?.selector === "string" ? input.selector : undefined,
|
|
242
|
+
domPath: typeof input?.domPath === "string" ? input.domPath : undefined,
|
|
243
|
+
id: typeof input?.id === "string" ? input.id : undefined,
|
|
244
|
+
classList: Array.isArray(input?.classList) ? input.classList.filter((item: unknown) => typeof item === "string") : [],
|
|
245
|
+
role: typeof input?.role === "string" ? input.role : undefined,
|
|
246
|
+
outerHTMLExcerpt: typeof input?.outerHTMLExcerpt === "string" ? input.outerHTMLExcerpt : undefined,
|
|
247
|
+
nearbyText: typeof input?.nearbyText === "string" ? input.nearbyText : undefined,
|
|
248
|
+
elements: Array.isArray(input?.elements) ? input.elements.map((item: any) => ({
|
|
249
|
+
text: typeof item?.text === "string" ? item.text : undefined,
|
|
250
|
+
tagName: typeof item?.tagName === "string" ? item.tagName : undefined,
|
|
251
|
+
slideIndex: typeof item?.slideIndex === "number" ? item.slideIndex : undefined,
|
|
252
|
+
slideTitle: typeof item?.slideTitle === "string" ? item.slideTitle : undefined,
|
|
253
|
+
selector: typeof item?.selector === "string" ? item.selector : undefined,
|
|
254
|
+
domPath: typeof item?.domPath === "string" ? item.domPath : undefined,
|
|
255
|
+
id: typeof item?.id === "string" ? item.id : undefined,
|
|
256
|
+
classList: Array.isArray(item?.classList) ? item.classList.filter((className: unknown) => typeof className === "string") : [],
|
|
257
|
+
role: typeof item?.role === "string" ? item.role : undefined,
|
|
258
|
+
outerHTMLExcerpt: typeof item?.outerHTMLExcerpt === "string" ? item.outerHTMLExcerpt : undefined,
|
|
259
|
+
nearbyText: typeof item?.nearbyText === "string" ? item.nearbyText : undefined,
|
|
260
|
+
boundingBox: item?.boundingBox && typeof item.boundingBox === "object" ? item.boundingBox : undefined,
|
|
261
|
+
viewport: item?.viewport && typeof item.viewport === "object" ? item.viewport : undefined,
|
|
262
|
+
})) : undefined,
|
|
263
|
+
boundingBox: input?.boundingBox && typeof input.boundingBox === "object" ? input.boundingBox : undefined,
|
|
264
|
+
viewport: input?.viewport && typeof input.viewport === "object" ? input.viewport : undefined,
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function readDeckVersion(session: InspectSession): { mtimeMs: number; size: number; version: string } {
|
|
269
|
+
const stat = statSync(session.absoluteFile)
|
|
270
|
+
return { mtimeMs: stat.mtimeMs, size: stat.size, version: `${stat.mtimeMs}:${stat.size}` }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function findSessionForDeck(absoluteFile: string): { token: string; session: InspectSession } | undefined {
|
|
274
|
+
for (const [token, session] of sessions) {
|
|
275
|
+
if (session.absoluteFile === absoluteFile) return { token, session }
|
|
276
|
+
}
|
|
277
|
+
return undefined
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function validateSession(token: string | null): { ok: true; value: InspectSession } | { ok: false; response: Response } {
|
|
281
|
+
if (!token) return { ok: false, response: textResponse("Missing token", 401) }
|
|
282
|
+
const session = sessions.get(token)
|
|
283
|
+
if (!session) return { ok: false, response: textResponse("Invalid or expired token", 401) }
|
|
284
|
+
if (Date.now() - session.lastActiveAt > SESSION_TTL_MS) {
|
|
285
|
+
sessions.delete(token)
|
|
286
|
+
return { ok: false, response: textResponse("Expired token", 401) }
|
|
287
|
+
}
|
|
288
|
+
session.lastActiveAt = Date.now()
|
|
289
|
+
return { ok: true, value: session }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function cleanupExpiredSessions(): void {
|
|
293
|
+
const now = Date.now()
|
|
294
|
+
for (const [token, session] of sessions) {
|
|
295
|
+
if (now - session.lastActiveAt > SESSION_TTL_MS) sessions.delete(token)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function scheduleIdleStop(): void {
|
|
300
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
301
|
+
idleTimer = setTimeout(() => {
|
|
302
|
+
const now = Date.now()
|
|
303
|
+
const active = [...sessions.values()].some((session) => now - session.lastActiveAt < IDLE_STOP_MS)
|
|
304
|
+
if (active) {
|
|
305
|
+
scheduleIdleStop()
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
sessions.clear()
|
|
309
|
+
server?.stop()
|
|
310
|
+
server = undefined
|
|
311
|
+
baseUrl = ""
|
|
312
|
+
idleTimer = undefined
|
|
313
|
+
}, IDLE_STOP_MS)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function htmlResponse(body: string, status = 200): Response {
|
|
317
|
+
return new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store, max-age=0" } })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function textResponse(body: string, status = 200): Response {
|
|
321
|
+
return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
325
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8" } })
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function rewriteLocalAssetRefs(content: string, session: InspectSession, sourceFile: string, contentType: "html" | "css"): string {
|
|
329
|
+
const baseDir = dirname(sourceFile)
|
|
330
|
+
let rewritten = rewriteCssUrls(content, session, baseDir)
|
|
331
|
+
if (contentType === "css") return rewritten
|
|
332
|
+
rewritten = rewriteHtmlAssetAttributes(rewritten, session, baseDir)
|
|
333
|
+
return rewriteSrcsetAttributes(rewritten, session, baseDir)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function rewriteHtmlAssetAttributes(html: string, session: InspectSession, baseDir: string): string {
|
|
337
|
+
const attrPattern = /\b(src|href|poster)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi
|
|
338
|
+
return html.replace(attrPattern, (match, name: string, raw: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => {
|
|
339
|
+
const value = doubleQuoted ?? singleQuoted ?? unquoted ?? ""
|
|
340
|
+
const assetUrl = assetUrlForRef(value, session, baseDir)
|
|
341
|
+
if (!assetUrl) return match
|
|
342
|
+
const quote = doubleQuoted !== undefined ? '"' : singleQuoted !== undefined ? "'" : ""
|
|
343
|
+
const escaped = quote ? assetUrl.replace(/&/g, "&") : assetUrl
|
|
344
|
+
return `${name}=${quote}${escaped}${quote}`
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function rewriteSrcsetAttributes(html: string, session: InspectSession, baseDir: string): string {
|
|
349
|
+
const srcsetPattern = /\bsrcset\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi
|
|
350
|
+
return html.replace(srcsetPattern, (match, raw: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => {
|
|
351
|
+
const value = doubleQuoted ?? singleQuoted ?? unquoted ?? ""
|
|
352
|
+
const rewritten = value.split(",").map((part) => {
|
|
353
|
+
const trimmed = part.trim()
|
|
354
|
+
if (!trimmed) return part
|
|
355
|
+
const pieces = trimmed.split(/\s+/)
|
|
356
|
+
const assetUrl = assetUrlForRef(pieces[0], session, baseDir)
|
|
357
|
+
return assetUrl ? [assetUrl, ...pieces.slice(1)].join(" ") : part
|
|
358
|
+
}).join(", ")
|
|
359
|
+
if (rewritten === value) return match
|
|
360
|
+
const quote = doubleQuoted !== undefined ? '"' : singleQuoted !== undefined ? "'" : ""
|
|
361
|
+
const escaped = quote ? rewritten.replace(/&/g, "&") : rewritten
|
|
362
|
+
return `srcset=${quote}${escaped}${quote}`
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function rewriteCssUrls(content: string, session: InspectSession, baseDir: string): string {
|
|
367
|
+
const cssUrlPattern = /url\(\s*("([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/gi
|
|
368
|
+
return content.replace(cssUrlPattern, (match, raw: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => {
|
|
369
|
+
const value = doubleQuoted ?? singleQuoted ?? unquoted ?? ""
|
|
370
|
+
const assetUrl = assetUrlForRef(value, session, baseDir)
|
|
371
|
+
if (!assetUrl) return match
|
|
372
|
+
const quote = doubleQuoted !== undefined ? '"' : singleQuoted !== undefined ? "'" : ""
|
|
373
|
+
return `url(${quote}${assetUrl}${quote})`
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function assetUrlForRef(value: string, session: InspectSession, baseDir: string): string | undefined {
|
|
378
|
+
const trimmed = value.trim()
|
|
379
|
+
if (!trimmed || trimmed.startsWith("#") || /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(trimmed)) return undefined
|
|
380
|
+
const [pathPart, suffix = ""] = splitAssetSuffix(trimmed)
|
|
381
|
+
const absoluteFile = resolveAssetPath(pathPart, session.workspaceRoot, baseDir)
|
|
382
|
+
if (!absoluteFile) return undefined
|
|
383
|
+
const root = resolve(session.workspaceRoot)
|
|
384
|
+
if (absoluteFile !== root && !absoluteFile.startsWith(root.endsWith(sep) ? root : root + sep)) return undefined
|
|
385
|
+
if (!existsSync(absoluteFile) || !statSync(absoluteFile).isFile()) return undefined
|
|
386
|
+
const key = absoluteFile
|
|
387
|
+
let id = session.assetKeys.get(key)
|
|
388
|
+
if (!id) {
|
|
389
|
+
id = String(session.nextAssetId++)
|
|
390
|
+
session.assetKeys.set(key, id)
|
|
391
|
+
session.assets.set(id, { id, absoluteFile })
|
|
392
|
+
}
|
|
393
|
+
return `/__revela_asset?token=${encodeURIComponent(session.token)}&id=${encodeURIComponent(id)}${suffix}`
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function splitAssetSuffix(value: string): [string, string] {
|
|
397
|
+
const hashIndex = value.indexOf("#")
|
|
398
|
+
const queryIndex = value.indexOf("?")
|
|
399
|
+
const indexes = [hashIndex, queryIndex].filter((index) => index >= 0)
|
|
400
|
+
if (indexes.length === 0) return [value, ""]
|
|
401
|
+
const splitAt = Math.min(...indexes)
|
|
402
|
+
return [value.slice(0, splitAt), value.slice(splitAt)]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function resolveAssetPath(value: string, workspaceRoot: string, baseDir: string): string | undefined {
|
|
406
|
+
if (!value) return undefined
|
|
407
|
+
if (value.startsWith("/")) return resolve(workspaceRoot, "." + value)
|
|
408
|
+
return isAbsolute(value) ? resolve(value) : resolve(baseDir, value)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function mimeTypeForPath(filePath: string): string {
|
|
412
|
+
switch (extname(filePath).toLowerCase()) {
|
|
413
|
+
case ".html": return "text/html"
|
|
414
|
+
case ".css": return "text/css"
|
|
415
|
+
case ".js": return "text/javascript"
|
|
416
|
+
case ".json": return "application/json"
|
|
417
|
+
case ".jpg":
|
|
418
|
+
case ".jpeg": return "image/jpeg"
|
|
419
|
+
case ".png": return "image/png"
|
|
420
|
+
case ".gif": return "image/gif"
|
|
421
|
+
case ".webp": return "image/webp"
|
|
422
|
+
case ".svg": return "image/svg+xml"
|
|
423
|
+
case ".woff": return "font/woff"
|
|
424
|
+
case ".woff2": return "font/woff2"
|
|
425
|
+
case ".ttf": return "font/ttf"
|
|
426
|
+
case ".otf": return "font/otf"
|
|
427
|
+
default: return "application/octet-stream"
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function renderInspectorShell(token: string): string {
|
|
432
|
+
const encodedToken = JSON.stringify(token)
|
|
433
|
+
return `<!doctype html>
|
|
434
|
+
<html lang="en">
|
|
435
|
+
<head>
|
|
436
|
+
<meta charset="utf-8" />
|
|
437
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
438
|
+
<title>Evidence Inspector</title>
|
|
439
|
+
<style>
|
|
440
|
+
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
441
|
+
* { box-sizing: border-box; }
|
|
442
|
+
body { margin: 0; height: 100vh; overflow: hidden; background: #eef3f8; color: #172033; }
|
|
443
|
+
.app { display: grid; grid-template-columns: minmax(0, 1fr) 390px; height: 100vh; }
|
|
444
|
+
.preview { position: relative; min-width: 0; background: #eef3f8; }
|
|
445
|
+
iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
|
|
446
|
+
.hitbox { position: absolute; inset: 0; z-index: 2; cursor: crosshair; background: transparent; }
|
|
447
|
+
aside { display: flex; flex-direction: column; gap: 14px; padding: 18px; background: linear-gradient(180deg, #fff 0%, #f8fafc 100%); border-left: 1px solid #dbe4ee; overflow: auto; }
|
|
448
|
+
h1 { margin: 0; font-size: 18px; line-height: 1.2; color: #0f172a; }
|
|
449
|
+
.hint { margin: 0; color: #64748b; font-size: 13px; line-height: 1.5; }
|
|
450
|
+
.selection { padding: 12px; border: 1px solid #d7e0ea; border-radius: 14px; background: #fff; box-shadow: 0 10px 24px rgba(15,23,42,.05); }
|
|
451
|
+
.selection strong { display: block; margin-bottom: 5px; color: #334155; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
|
|
452
|
+
.selection p { margin: 0; color: #0f172a; font-size: 13px; line-height: 1.45; }
|
|
453
|
+
.refs { display: flex; flex-direction: column; gap: 7px; margin-top: 10px; }
|
|
454
|
+
.ref-chip { display: inline-flex; align-items: center; width: fit-content; padding: 2px 8px; border-radius: 999px; background: var(--ref-bg, #e0f2fe); color: var(--ref-text, #075985); border: 1px solid var(--ref-border, #7dd3fc); font-size: 12px; font-weight: 800; }
|
|
455
|
+
.inspect-btn { width: 100%; margin-top: 10px; padding: 10px 12px; border: 0; border-radius: 12px; background: #2563eb; color: #fff; font-weight: 800; cursor: pointer; box-shadow: 0 10px 24px rgba(37,99,235,.22); }
|
|
456
|
+
.inspect-btn:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; }
|
|
457
|
+
.cards { display: flex; flex-direction: column; gap: 12px; }
|
|
458
|
+
.card { border: 1px solid #d7e0ea; border-radius: 16px; background: #fff; padding: 13px; box-shadow: 0 10px 24px rgba(15,23,42,.05); }
|
|
459
|
+
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
|
460
|
+
.card h2 { margin: 0; font-size: 13px; color: #0f172a; }
|
|
461
|
+
.badge { border-radius: 999px; padding: 3px 8px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; background: #e2e8f0; color: #475569; }
|
|
462
|
+
.badge.supported, .badge.found, .badge.present, .badge.known, .badge.suggested { background: #dcfce7; color: #166534; }
|
|
463
|
+
.badge.weak, .badge.missing { background: #fef3c7; color: #92400e; }
|
|
464
|
+
.badge.unsupported { background: #fee2e2; color: #991b1b; }
|
|
465
|
+
.card p { margin: 0; color: #475569; font-size: 12px; line-height: 1.5; }
|
|
466
|
+
.items { margin-top: 8px; display: flex; flex-direction: column; gap: 7px; }
|
|
467
|
+
.item { padding: 8px; border-radius: 10px; background: #f8fafc; color: #334155; font-size: 12px; line-height: 1.45; }
|
|
468
|
+
.item b { color: #0f172a; }
|
|
469
|
+
.warning { margin-top: 8px; padding: 8px; border-radius: 10px; background: #fff7ed; color: #9a3412; font-size: 12px; line-height: 1.45; }
|
|
470
|
+
.stale { padding: 10px; border-radius: 12px; background: #fff7ed; color: #9a3412; font-size: 12px; line-height: 1.45; }
|
|
471
|
+
.empty { padding: 24px 12px; text-align: center; color: #64748b; font-size: 13px; line-height: 1.5; }
|
|
472
|
+
.loading { padding: 22px 12px; text-align: center; color: #334155; font-size: 13px; line-height: 1.5; border: 1px dashed #cbd5e1; border-radius: 16px; background: #f8fafc; }
|
|
473
|
+
.loading b { display: block; margin-bottom: 4px; color: #0f172a; }
|
|
474
|
+
.bind-status { padding: 8px 10px; border-radius: 10px; background: #f8fafc; color: #64748b; font-size: 11px; line-height: 1.35; }
|
|
475
|
+
.bind-status.ready { background: #ecfdf5; color: #047857; }
|
|
476
|
+
.bind-status.error { background: #fef2f2; color: #b91c1c; }
|
|
477
|
+
.kbd { display: inline-block; padding: 1px 5px; border: 1px solid #cbd5e1; border-radius: 5px; background: #f8fafc; color: #334155; font-size: 11px; font-weight: 700; }
|
|
478
|
+
@media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: 56vh 44vh; } aside { border-left: 0; border-top: 1px solid #dbe4ee; } }
|
|
479
|
+
</style>
|
|
480
|
+
</head>
|
|
481
|
+
<body>
|
|
482
|
+
<main class="app">
|
|
483
|
+
<section class="preview"><iframe id="deck" src="/deck?token=${encodeURIComponent(token)}"></iframe><div id="hitbox" class="hitbox" aria-label="Deck element selection layer"></div></section>
|
|
484
|
+
<aside>
|
|
485
|
+
<div>
|
|
486
|
+
<h1>Evidence Inspector</h1>
|
|
487
|
+
<p class="hint">Cmd/Ctrl-click slide elements to attach them as references, exactly like /revela edit. Then click <b>Inspect Selection</b>. This is not chat.</p>
|
|
488
|
+
</div>
|
|
489
|
+
<div id="selection" class="selection">
|
|
490
|
+
<strong>Selection</strong>
|
|
491
|
+
<p>No references selected.</p>
|
|
492
|
+
<div id="refs" class="refs"></div>
|
|
493
|
+
<button id="inspectButton" class="inspect-btn" disabled>Inspect Selection</button>
|
|
494
|
+
</div>
|
|
495
|
+
<div id="bindingStatus" class="bind-status">Selection binding: starting...</div>
|
|
496
|
+
<div id="stale"></div>
|
|
497
|
+
<div id="cards" class="cards"><div class="empty">Cmd/Ctrl-click text, cards, charts, or slide objects to choose what to inspect.</div></div>
|
|
498
|
+
</aside>
|
|
499
|
+
</main>
|
|
500
|
+
<script>
|
|
501
|
+
(function initInspectErrorPrelude() {
|
|
502
|
+
function statusEl() { return document.getElementById('bindingStatus'); }
|
|
503
|
+
function showInspectShellError(error) {
|
|
504
|
+
const el = statusEl();
|
|
505
|
+
const message = error?.message || String(error || 'Unknown inspect shell error');
|
|
506
|
+
if (el) {
|
|
507
|
+
el.className = 'bind-status error';
|
|
508
|
+
el.textContent = 'Inspect shell error: ' + message;
|
|
509
|
+
}
|
|
510
|
+
console.error('Revela inspect shell error', error);
|
|
511
|
+
}
|
|
512
|
+
window.__revelaInspectDebug = {
|
|
513
|
+
ready: false,
|
|
514
|
+
error: null,
|
|
515
|
+
bindingState: function () {
|
|
516
|
+
const iframe = document.getElementById('deck');
|
|
517
|
+
const doc = iframe?.contentDocument;
|
|
518
|
+
return {
|
|
519
|
+
ready: false,
|
|
520
|
+
hasIframe: !!iframe,
|
|
521
|
+
hasDoc: !!doc,
|
|
522
|
+
readyState: doc?.readyState,
|
|
523
|
+
hasBody: !!doc?.body,
|
|
524
|
+
bound: doc?.body?.getAttribute('data-revela-inspect-bound') || null,
|
|
525
|
+
status: statusEl()?.textContent || '',
|
|
526
|
+
error: window.__revelaInspectDebug?.error || null,
|
|
527
|
+
};
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
window.addEventListener('error', function (event) {
|
|
531
|
+
window.__revelaInspectDebug.error = event.error?.message || event.message || 'Unknown script error';
|
|
532
|
+
showInspectShellError(event.error || event.message);
|
|
533
|
+
});
|
|
534
|
+
window.addEventListener('unhandledrejection', function (event) {
|
|
535
|
+
window.__revelaInspectDebug.error = event.reason?.message || String(event.reason || 'Unhandled promise rejection');
|
|
536
|
+
showInspectShellError(event.reason);
|
|
537
|
+
});
|
|
538
|
+
})();
|
|
539
|
+
</script>
|
|
540
|
+
<script>
|
|
541
|
+
const token = ${encodedToken};
|
|
542
|
+
const iframe = document.getElementById('deck');
|
|
543
|
+
const hitbox = document.getElementById('hitbox');
|
|
544
|
+
const cards = document.getElementById('cards');
|
|
545
|
+
const selection = document.getElementById('selection');
|
|
546
|
+
const refs = document.getElementById('refs');
|
|
547
|
+
const inspectButton = document.getElementById('inspectButton');
|
|
548
|
+
const staleBox = document.getElementById('stale');
|
|
549
|
+
const bindingStatus = document.getElementById('bindingStatus');
|
|
550
|
+
const REFERENCE_COLORS = [
|
|
551
|
+
{ border: '#7aa6d8', fill: 'rgba(122,166,216,.18)', bg: '#eaf2fb', text: '#244f78' },
|
|
552
|
+
{ border: '#a99bd9', fill: 'rgba(169,155,217,.18)', bg: '#f1eefb', text: '#574985' },
|
|
553
|
+
{ border: '#83b99a', fill: 'rgba(131,185,154,.18)', bg: '#edf7f1', text: '#2f6848' },
|
|
554
|
+
{ border: '#d7a775', fill: 'rgba(215,167,117,.18)', bg: '#fbf1e7', text: '#7a4d22' },
|
|
555
|
+
{ border: '#d493b0', fill: 'rgba(212,147,176,.18)', bg: '#faedf3', text: '#7b3f5b' },
|
|
556
|
+
];
|
|
557
|
+
let deckVersion = '';
|
|
558
|
+
let locked = false;
|
|
559
|
+
let activeRequestId = '';
|
|
560
|
+
let hoverOutline = null;
|
|
561
|
+
let hoverEl = null;
|
|
562
|
+
let references = [];
|
|
563
|
+
let referenceOutlines = [];
|
|
564
|
+
let nextReferenceId = 1;
|
|
565
|
+
let bindTimer = 0;
|
|
566
|
+
let bindAttempts = 0;
|
|
567
|
+
|
|
568
|
+
async function refreshDeckVersion() {
|
|
569
|
+
try {
|
|
570
|
+
const res = await fetch('/api/deck-version?token=' + encodeURIComponent(token));
|
|
571
|
+
const data = await res.json();
|
|
572
|
+
if (data.ok) deckVersion = data.version;
|
|
573
|
+
} catch {}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function setBindingStatus(kind, message) {
|
|
577
|
+
if (!bindingStatus) return;
|
|
578
|
+
bindingStatus.className = 'bind-status' + (kind ? ' ' + kind : '');
|
|
579
|
+
bindingStatus.textContent = message;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function attachDeckHandlers() {
|
|
583
|
+
const doc = iframe.contentDocument;
|
|
584
|
+
if (!doc?.body) return false;
|
|
585
|
+
if (doc.body.getAttribute('data-revela-inspect-bound') === 'true') {
|
|
586
|
+
setBindingStatus('ready', 'Selection ready: Cmd/Ctrl-click to reference elements.');
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
doc.body.setAttribute('data-revela-inspect-bound', 'true');
|
|
591
|
+
if (!hoverOutline || hoverOutline.ownerDocument !== doc) hoverOutline = createOutline(doc, '#38bdf8', 'rgba(56,189,248,.12)');
|
|
592
|
+
doc.addEventListener('scroll', () => renderSelectionOutline(), true);
|
|
593
|
+
hitbox.addEventListener('pointermove', onHover);
|
|
594
|
+
hitbox.addEventListener('pointerdown', onPointerDown);
|
|
595
|
+
hitbox.addEventListener('click', onClick);
|
|
596
|
+
hitbox.addEventListener('contextmenu', (event) => { if (event.ctrlKey || event.metaKey) event.preventDefault(); });
|
|
597
|
+
hitbox.addEventListener('wheel', (event) => {
|
|
598
|
+
const win = iframe.contentWindow;
|
|
599
|
+
if (!win) return;
|
|
600
|
+
event.preventDefault();
|
|
601
|
+
win.scrollBy({ top: event.deltaY, left: event.deltaX, behavior: 'auto' });
|
|
602
|
+
renderSelectionOutline();
|
|
603
|
+
renderReferenceOutlines();
|
|
604
|
+
}, { passive: false });
|
|
605
|
+
doc.body.style.cursor = 'default';
|
|
606
|
+
setBindingStatus('ready', 'Selection ready: Ctrl/Cmd-click to reference elements.');
|
|
607
|
+
return true;
|
|
608
|
+
} catch (error) {
|
|
609
|
+
doc.body.removeAttribute('data-revela-inspect-bound');
|
|
610
|
+
console.error('Revela inspect selection binding failed', error);
|
|
611
|
+
setBindingStatus('error', 'Selection binding failed: ' + (error?.message || String(error)));
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function startBindingLoop() {
|
|
617
|
+
bindAttempts = 0;
|
|
618
|
+
setBindingStatus('', 'Selection binding: waiting for deck...');
|
|
619
|
+
if (bindTimer) clearInterval(bindTimer);
|
|
620
|
+
bindTimer = window.setInterval(() => {
|
|
621
|
+
bindAttempts += 1;
|
|
622
|
+
try {
|
|
623
|
+
if (attachDeckHandlers()) {
|
|
624
|
+
clearInterval(bindTimer);
|
|
625
|
+
bindTimer = 0;
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
} catch (error) {
|
|
629
|
+
console.error('Revela inspect binding loop failed', error);
|
|
630
|
+
setBindingStatus('error', 'Selection binding failed: ' + (error?.message || String(error)));
|
|
631
|
+
}
|
|
632
|
+
if (bindAttempts >= 80) {
|
|
633
|
+
clearInterval(bindTimer);
|
|
634
|
+
bindTimer = 0;
|
|
635
|
+
setBindingStatus('error', 'Selection binding timed out. Reopen /revela inspect or reload this page.');
|
|
636
|
+
}
|
|
637
|
+
}, 150);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function retryAttachDeckHandlers() {
|
|
641
|
+
startBindingLoop();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
inspectButton.addEventListener('click', () => {
|
|
645
|
+
if (!references.length || locked) return;
|
|
646
|
+
inspectSnapshot(collectReferenceSnapshot());
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
document.addEventListener('keydown', handleSelectionKeydown);
|
|
650
|
+
|
|
651
|
+
function handleSelectionKeydown(event) {
|
|
652
|
+
if (locked) return;
|
|
653
|
+
if (event.key === 'Escape') {
|
|
654
|
+
event.preventDefault();
|
|
655
|
+
clearCurrentSelection();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function onHover(event) {
|
|
660
|
+
hoverEl = selectable(targetFromPointer(event));
|
|
661
|
+
renderSelectionOutline();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function onClick(event) {
|
|
665
|
+
if (event.ctrlKey || event.metaKey) event.preventDefault();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function onPointerDown(event) {
|
|
669
|
+
if (locked || (!event.ctrlKey && !event.metaKey)) return;
|
|
670
|
+
event.preventDefault();
|
|
671
|
+
event.stopPropagation();
|
|
672
|
+
toggleReference(selectable(targetFromPointer(event)));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function targetFromPointer(event) {
|
|
676
|
+
const doc = iframe.contentDocument;
|
|
677
|
+
if (!doc || doc.location.href === 'about:blank') return null;
|
|
678
|
+
const frameRect = iframe.getBoundingClientRect();
|
|
679
|
+
const x = event.clientX - frameRect.left;
|
|
680
|
+
const y = event.clientY - frameRect.top;
|
|
681
|
+
if (x < 0 || y < 0 || x > frameRect.width || y > frameRect.height) return null;
|
|
682
|
+
return doc.elementFromPoint(x, y);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function selectable(node) {
|
|
686
|
+
if (!node || node.nodeType !== 1) return null;
|
|
687
|
+
if (node === hoverOutline || referenceOutlines.includes(node)) return null;
|
|
688
|
+
return canonicalSlideRoot(node) ? node : null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function toggleReference(target) {
|
|
692
|
+
if (!target) {
|
|
693
|
+
setBindingStatus('error', 'No selectable deck element found under pointer.');
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const existing = references.findIndex((reference) => reference.target === target);
|
|
697
|
+
if (existing >= 0) {
|
|
698
|
+
references.splice(existing, 1);
|
|
699
|
+
renderSelectionPreview();
|
|
700
|
+
renderReferenceOutlines();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const payload = collectPayload(target);
|
|
704
|
+
const color = REFERENCE_COLORS[(nextReferenceId - 1) % REFERENCE_COLORS.length];
|
|
705
|
+
references.push({ id: 'ref-' + nextReferenceId++, target, label: nextReferenceLabel(payload), payload, color });
|
|
706
|
+
renderSelectionPreview();
|
|
707
|
+
renderReferenceOutlines();
|
|
708
|
+
cards.innerHTML = '<div class="empty">References ready. Click Inspect Selection to show deterministic Source/Purpose first, then lazy generated cards.</div>';
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function clearCurrentSelection() {
|
|
712
|
+
references = [];
|
|
713
|
+
renderSelectionPreview();
|
|
714
|
+
if (hoverOutline) hoverOutline.style.display = 'none';
|
|
715
|
+
referenceOutlines.forEach((outline) => outline.style.display = 'none');
|
|
716
|
+
cards.innerHTML = '<div class="empty">Cmd/Ctrl-click text, cards, charts, or slide objects to choose what to inspect.</div>';
|
|
717
|
+
bindSelectionControls();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function bindSelectionControls() {
|
|
721
|
+
const button = document.getElementById('inspectButton');
|
|
722
|
+
if (button) button.addEventListener('click', () => {
|
|
723
|
+
if (!references.length || locked) return;
|
|
724
|
+
inspectSnapshot(collectReferenceSnapshot());
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function renderSelectionPreview() {
|
|
729
|
+
if (!references.length) {
|
|
730
|
+
selection.innerHTML = '<strong>Selection</strong><p>No references selected.</p><div id="refs" class="refs"></div><button id="inspectButton" class="inspect-btn" disabled>Inspect Selection</button>';
|
|
731
|
+
bindSelectionControls();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
selection.innerHTML = '<strong>Selection</strong><p>' + references.length + ' referenced element' + (references.length === 1 ? '' : 's') + ' selected.</p><div id="refs" class="refs"></div><button id="inspectButton" class="inspect-btn">Inspect Selection</button>';
|
|
735
|
+
const list = document.getElementById('refs');
|
|
736
|
+
references.forEach((reference) => {
|
|
737
|
+
const chip = document.createElement('span');
|
|
738
|
+
chip.className = 'ref-chip';
|
|
739
|
+
chip.style.setProperty('--ref-bg', reference.color.bg);
|
|
740
|
+
chip.style.setProperty('--ref-border', reference.color.border);
|
|
741
|
+
chip.style.setProperty('--ref-text', reference.color.text);
|
|
742
|
+
chip.textContent = '@' + reference.label;
|
|
743
|
+
list.appendChild(chip);
|
|
744
|
+
});
|
|
745
|
+
bindSelectionControls();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function renderSelectionOutline() {
|
|
749
|
+
renderBox(hoverOutline, hoverEl);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function createOutline(doc, border, fill) {
|
|
753
|
+
const outline = doc.createElement('div');
|
|
754
|
+
outline.setAttribute('data-revela-inspect-outline', 'true');
|
|
755
|
+
outline.style.cssText = 'position:fixed;z-index:2147483646;pointer-events:none;border:2px solid ' + border + ';background:' + fill + ';border-radius:8px;display:none;';
|
|
756
|
+
doc.body.appendChild(outline);
|
|
757
|
+
return outline;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function renderBox(outline, target) {
|
|
761
|
+
if (!outline || !target || !target.getBoundingClientRect) {
|
|
762
|
+
if (outline) outline.style.display = 'none';
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const rect = target.getBoundingClientRect();
|
|
766
|
+
outline.style.display = 'block';
|
|
767
|
+
outline.style.left = rect.left + 'px';
|
|
768
|
+
outline.style.top = rect.top + 'px';
|
|
769
|
+
outline.style.width = rect.width + 'px';
|
|
770
|
+
outline.style.height = rect.height + 'px';
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function setOutlineColor(outline, color) {
|
|
774
|
+
if (!outline || !color) return;
|
|
775
|
+
outline.style.borderColor = color.border;
|
|
776
|
+
outline.style.background = color.fill;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function renderReferenceOutlines() {
|
|
780
|
+
const doc = iframe.contentDocument;
|
|
781
|
+
if (!doc || doc.location.href === 'about:blank') return;
|
|
782
|
+
while (referenceOutlines.length < references.length) referenceOutlines.push(createOutline(doc, '#7aa6d8', 'rgba(122,166,216,.18)'));
|
|
783
|
+
referenceOutlines.forEach((outline, index) => {
|
|
784
|
+
const reference = references[index];
|
|
785
|
+
setOutlineColor(outline, reference?.color);
|
|
786
|
+
renderBox(outline, reference?.target);
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function collectPayload(el) {
|
|
791
|
+
const slide = canonicalSlideRoot(el);
|
|
792
|
+
const box = el.getBoundingClientRect();
|
|
793
|
+
const win = iframe.contentWindow;
|
|
794
|
+
return {
|
|
795
|
+
slideIndex: slideIndex(slide),
|
|
796
|
+
slideTitle: slide ? cleanText((slide.querySelector('h1,h2,h3,[data-title]') || {}).textContent).slice(0, 160) : undefined,
|
|
797
|
+
selector: buildSelector(el, slide),
|
|
798
|
+
domPath: buildDomPath(el, slide),
|
|
799
|
+
tagName: el.tagName.toLowerCase(),
|
|
800
|
+
id: el.id || undefined,
|
|
801
|
+
classList: Array.from(el.classList || []).slice(0, 30),
|
|
802
|
+
text: cleanText(el.innerText || el.textContent).slice(0, 700),
|
|
803
|
+
outerHTMLExcerpt: cleanText(el.outerHTML).slice(0, 1200),
|
|
804
|
+
nearbyText: slide ? cleanText(slide.innerText || slide.textContent).slice(0, 1200) : undefined,
|
|
805
|
+
boundingBox: { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) },
|
|
806
|
+
viewport: { width: win ? win.innerWidth : undefined, height: win ? win.innerHeight : undefined },
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function collectReferenceSnapshot() {
|
|
811
|
+
const elements = references.map((reference) => reference.payload);
|
|
812
|
+
const first = elements[0] || {};
|
|
813
|
+
return {
|
|
814
|
+
scope: elements.length > 1 ? 'selection' : 'element',
|
|
815
|
+
slideIndex: first.slideIndex,
|
|
816
|
+
slideTitle: first.slideTitle,
|
|
817
|
+
text: elements.map((item) => item.text).filter(Boolean).join(' | ').slice(0, 1200),
|
|
818
|
+
selectedText: elements.map((item) => item.text).filter(Boolean).join(String.fromCharCode(10)).slice(0, 1600),
|
|
819
|
+
tagName: elements.length === 1 ? first.tagName : undefined,
|
|
820
|
+
selector: elements.length === 1 ? first.selector : undefined,
|
|
821
|
+
domPath: elements.length === 1 ? first.domPath : undefined,
|
|
822
|
+
id: elements.length === 1 ? first.id : undefined,
|
|
823
|
+
classList: elements.length === 1 ? first.classList : [],
|
|
824
|
+
role: elements.length === 1 ? humanElementName(first) : 'Selection',
|
|
825
|
+
outerHTMLExcerpt: elements.length === 1 ? first.outerHTMLExcerpt : undefined,
|
|
826
|
+
nearbyText: first.nearbyText,
|
|
827
|
+
elements,
|
|
828
|
+
boundingBox: first.boundingBox,
|
|
829
|
+
viewport: first.viewport,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function nextReferenceLabel(payload) {
|
|
834
|
+
return humanElementName(payload) + ' ' + (references.length + 1);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function humanElementName(payload) {
|
|
838
|
+
const tag = String(payload.tagName || '').toLowerCase();
|
|
839
|
+
const classes = payload.classList || [];
|
|
840
|
+
if (/^h[1-6]$/.test(tag)) return 'Heading';
|
|
841
|
+
if (tag === 'p') return 'Text block';
|
|
842
|
+
if (classes.some((name) => /card/i.test(name))) return 'Card';
|
|
843
|
+
if (classes.some((name) => /stat|metric|value|kpi/i.test(name))) return 'Metric';
|
|
844
|
+
if (tag === 'img' || tag === 'svg' || classes.some((name) => /chart|visual/i.test(name))) return 'Visual';
|
|
845
|
+
return 'Element';
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function buildSelector(el, slide) {
|
|
849
|
+
if (el.id) return '#' + cssEscape(el.id);
|
|
850
|
+
const parts = [];
|
|
851
|
+
let node = el;
|
|
852
|
+
while (node && node.nodeType === 1 && node !== slide) {
|
|
853
|
+
let part = node.tagName.toLowerCase();
|
|
854
|
+
const stable = Array.from(node.attributes || []).find((attr) => attr.name.startsWith('data-'));
|
|
855
|
+
if (stable) {
|
|
856
|
+
part += '[' + stable.name + '="' + stable.value.replace(/"/g, '\\"') + '"]';
|
|
857
|
+
parts.unshift(part);
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
const classes = Array.from(node.classList || []).slice(0, 2).map(cssEscape);
|
|
861
|
+
if (classes.length) part += '.' + classes.join('.');
|
|
862
|
+
const siblings = Array.from(node.parentElement ? node.parentElement.children : []).filter((child) => child.tagName === node.tagName);
|
|
863
|
+
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
|
|
864
|
+
parts.unshift(part);
|
|
865
|
+
node = node.parentElement;
|
|
866
|
+
}
|
|
867
|
+
return [slideSelector(slide)].concat(parts).filter(Boolean).join(' > ');
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function slideSelector(slide) {
|
|
871
|
+
if (!slide) return '.slide';
|
|
872
|
+
if (slide.id) return '#' + cssEscape(slide.id);
|
|
873
|
+
const slides = slideRoots();
|
|
874
|
+
const index = slides.indexOf(slide) + 1;
|
|
875
|
+
if (slide.classList && slide.classList.contains('slide')) return '.slide:nth-of-type(' + index + ')';
|
|
876
|
+
if (slide.hasAttribute && slide.hasAttribute('slide-qa')) return '[slide-qa]:nth-of-type(' + index + ')';
|
|
877
|
+
if (slide.classList && slide.classList.contains('slide-canvas')) return '.slide-canvas:nth-of-type(' + index + ')';
|
|
878
|
+
return '.page:nth-of-type(' + index + ')';
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function buildDomPath(el, stop) {
|
|
882
|
+
const parts = [];
|
|
883
|
+
let node = el;
|
|
884
|
+
while (node && node.nodeType === 1 && node !== stop) {
|
|
885
|
+
const siblings = Array.from(node.parentElement ? node.parentElement.children : []);
|
|
886
|
+
parts.unshift(node.tagName.toLowerCase() + '[' + (siblings.indexOf(node) + 1) + ']');
|
|
887
|
+
node = node.parentElement;
|
|
888
|
+
}
|
|
889
|
+
return parts.join(' > ');
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function canonicalSlideRoot(el) {
|
|
893
|
+
if (!el) return undefined;
|
|
894
|
+
const explicit = el.closest('[data-slide-index]');
|
|
895
|
+
if (explicit && !explicit.closest('.slide, [slide-qa]')) return explicit;
|
|
896
|
+
const slide = el.closest('.slide');
|
|
897
|
+
if (slide) return slide;
|
|
898
|
+
const qaSlide = el.closest('[slide-qa]');
|
|
899
|
+
if (qaSlide) return qaSlide;
|
|
900
|
+
const fallback = el.closest('.page, .slide-canvas');
|
|
901
|
+
if (!fallback) return undefined;
|
|
902
|
+
const parentSlide = fallback.parentElement?.closest('.slide, [slide-qa], [data-slide-index]');
|
|
903
|
+
return parentSlide || fallback;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function slideRoots() {
|
|
907
|
+
const doc = iframe.contentDocument;
|
|
908
|
+
const roots = [];
|
|
909
|
+
const candidates = Array.from(doc.querySelectorAll('.slide, [data-slide-index], [slide-qa], .page, .slide-canvas'));
|
|
910
|
+
for (const candidate of candidates) {
|
|
911
|
+
const root = canonicalSlideRoot(candidate);
|
|
912
|
+
if (root && !roots.includes(root)) roots.push(root);
|
|
913
|
+
}
|
|
914
|
+
return roots;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function slideIndex(slide) {
|
|
918
|
+
if (!slide) return undefined;
|
|
919
|
+
const explicit = slide.getAttribute('data-slide-index');
|
|
920
|
+
if (explicit && !Number.isNaN(Number(explicit))) return Number(explicit);
|
|
921
|
+
// Legacy decks may still carry 0-based data-index. It is intentionally
|
|
922
|
+
// ignored here; DECKS.json slide indexes are 1-based, so DOM order is the
|
|
923
|
+
// safer fallback for matching inspector snapshots to slide specs.
|
|
924
|
+
const slides = slideRoots();
|
|
925
|
+
const index = slides.indexOf(slide);
|
|
926
|
+
return index >= 0 ? index + 1 : undefined;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function cleanText(value) { return String(value || '').replace(/\\s+/g, ' ').trim(); }
|
|
930
|
+
|
|
931
|
+
async function inspectSnapshot(snapshot) {
|
|
932
|
+
setLocked(true);
|
|
933
|
+
const summary = snapshot.scope === 'selection'
|
|
934
|
+
? 'Inspecting ' + (snapshot.elements?.length || 0) + ' selected elements' + (snapshot.slideIndex ? ' on Slide ' + snapshot.slideIndex : '')
|
|
935
|
+
: (snapshot.text || '(slide area)');
|
|
936
|
+
selection.innerHTML = '<strong>Selection</strong><p>' + escapeHtml(summary) + '</p>';
|
|
937
|
+
staleBox.innerHTML = '';
|
|
938
|
+
cards.innerHTML = '<div class="loading"><b>Preparing inspection...</b>Deterministic Source/Purpose will appear first; generated cards will update lazily.</div>';
|
|
939
|
+
try {
|
|
940
|
+
const res = await fetch('/api/inspect?token=' + encodeURIComponent(token), {
|
|
941
|
+
method: 'POST',
|
|
942
|
+
headers: { 'content-type': 'application/json' },
|
|
943
|
+
body: JSON.stringify({ snapshot, deckVersion }),
|
|
944
|
+
});
|
|
945
|
+
const data = await res.json();
|
|
946
|
+
if (!data.ok) throw new Error(data.error || 'Inspection failed');
|
|
947
|
+
deckVersion = data.deckVersion || deckVersion;
|
|
948
|
+
activeRequestId = data.requestId;
|
|
949
|
+
if (data.preprocess) renderResult(data.preprocess, 'Preprocessed');
|
|
950
|
+
cards.insertAdjacentHTML('beforeend', '<div class="loading"><b>Generating lazy inspection...</b>The deck is locked until the LLM submits the structured result.</div>');
|
|
951
|
+
await pollInspectionResult(activeRequestId);
|
|
952
|
+
} catch (error) {
|
|
953
|
+
cards.innerHTML = '<div class="empty">' + escapeHtml(error.message || String(error)) + '</div>';
|
|
954
|
+
setLocked(false);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function pollInspectionResult(requestId) {
|
|
959
|
+
for (;;) {
|
|
960
|
+
await delay(900);
|
|
961
|
+
const res = await fetch('/api/inspect-result?token=' + encodeURIComponent(token) + '&requestId=' + encodeURIComponent(requestId));
|
|
962
|
+
const data = await res.json();
|
|
963
|
+
if (!data.ok) throw new Error(data.error || 'Inspection result failed');
|
|
964
|
+
if (data.status === 'completed') {
|
|
965
|
+
deckVersion = data.deckVersion || deckVersion;
|
|
966
|
+
renderResult(data.result, 'Generated');
|
|
967
|
+
setLocked(false);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (data.status === 'failed' || data.status === 'expired') {
|
|
971
|
+
throw new Error(data.error || 'Inspection failed');
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function setLocked(value) {
|
|
977
|
+
locked = value;
|
|
978
|
+
const doc = iframe.contentDocument;
|
|
979
|
+
if (doc?.body) doc.body.style.cursor = value ? 'wait' : 'default';
|
|
980
|
+
const button = document.getElementById('inspectButton');
|
|
981
|
+
if (button) button.disabled = value || !references.length;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
985
|
+
|
|
986
|
+
function renderResult(result, phase) {
|
|
987
|
+
if (result.stale?.stale) staleBox.innerHTML = '<div class="stale">' + escapeHtml(result.stale.reason || 'Inspection may be stale.') + '</div>';
|
|
988
|
+
else staleBox.innerHTML = '';
|
|
989
|
+
cards.innerHTML = [
|
|
990
|
+
'<div class="bind-status ready">' + escapeHtml(phase || 'Inspection') + '</div>',
|
|
991
|
+
renderCard('Purpose', result.cards.purpose.status, result.cards.purpose.rationale, renderPurpose(result.cards.purpose)),
|
|
992
|
+
renderCard('Source', result.cards.source.status, result.cards.source.rationale, renderSource(result.cards.source)),
|
|
993
|
+
].join('');
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function renderCard(title, status, rationale, body) {
|
|
997
|
+
return '<section class="card"><div class="card-head"><h2>' + escapeHtml(title) + '</h2><span class="badge ' + escapeHtml(status) + '">' + escapeHtml(status) + '</span></div><p>' + escapeHtml(rationale || '') + '</p>' + (body || '') + '</section>';
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function renderSources(items) {
|
|
1001
|
+
if (!items || !items.length) return '';
|
|
1002
|
+
return '<div class="items">' + items.map((item) => '<div class="item"><b>' + escapeHtml(item.source || 'Source') + '</b>' + field('Path', item.sourcePath || item.findingsFile) + field('Location', item.location || item.page) + field('Quote', item.quote) + field('URL', item.url) + field('Caveat', item.caveat) + '</div>').join('') + '</div>';
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function renderPurpose(card) {
|
|
1006
|
+
return '<div class="items"><div class="item">' + field('Role', card.role) + field('Why it matters', card.whyItMatters) + '</div></div>';
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function renderSource(card) {
|
|
1010
|
+
return renderSources(card.sources) + renderWarnings(card.warnings) + renderSectionList('Gaps', card.gaps) + renderSectionList('Caveats', card.caveats);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function renderWarnings(items) { return items && items.length ? items.map((item) => '<div class="warning">' + escapeHtml(item) + '</div>').join('') : ''; }
|
|
1014
|
+
function renderList(items) { return items && items.length ? '<div class="items">' + items.map((item) => '<div class="item">' + escapeHtml(item) + '</div>').join('') + '</div>' : ''; }
|
|
1015
|
+
function renderSectionList(title, items) { return items && items.length ? '<h3>' + escapeHtml(title) + '</h3>' + renderList(items) : ''; }
|
|
1016
|
+
function field(label, value) { return value ? '<br><b>' + escapeHtml(label) + ':</b> ' + escapeHtml(value) : ''; }
|
|
1017
|
+
function escapeHtml(value) { return String(value || '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); }
|
|
1018
|
+
function cssEscape(value) { return window.CSS && CSS.escape ? CSS.escape(value) : String(value || '').replace(/[^a-zA-Z0-9_-]/g, '\\$&'); }
|
|
1019
|
+
|
|
1020
|
+
function initializeInspectShell() {
|
|
1021
|
+
try {
|
|
1022
|
+
window.__revelaInspectDebug.ready = true;
|
|
1023
|
+
window.__revelaInspectDebug.attachDeckHandlers = attachDeckHandlers;
|
|
1024
|
+
window.__revelaInspectDebug.startBindingLoop = startBindingLoop;
|
|
1025
|
+
window.__revelaInspectDebug.retryAttachDeckHandlers = retryAttachDeckHandlers;
|
|
1026
|
+
window.__revelaInspectDebug.bindingState = bindingState;
|
|
1027
|
+
refreshDeckVersion();
|
|
1028
|
+
iframe.addEventListener('load', startBindingLoop);
|
|
1029
|
+
startBindingLoop();
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
window.__revelaInspectDebug.error = error?.message || String(error);
|
|
1032
|
+
console.error('Revela inspect initialization failed', error);
|
|
1033
|
+
setBindingStatus('error', 'Inspect initialization failed: ' + (error?.message || String(error)));
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function bindingState() {
|
|
1038
|
+
const doc = iframe.contentDocument;
|
|
1039
|
+
return {
|
|
1040
|
+
ready: !!window.__revelaInspectDebug?.ready,
|
|
1041
|
+
hasIframe: !!iframe,
|
|
1042
|
+
hasDoc: !!doc,
|
|
1043
|
+
readyState: doc?.readyState,
|
|
1044
|
+
hasBody: !!doc?.body,
|
|
1045
|
+
bound: doc?.body?.getAttribute('data-revela-inspect-bound') || null,
|
|
1046
|
+
attempts: bindAttempts,
|
|
1047
|
+
status: bindingStatus?.textContent || '',
|
|
1048
|
+
error: window.__revelaInspectDebug?.error || null,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
window.__revelaInspectDebug = Object.assign(window.__revelaInspectDebug || {}, {
|
|
1053
|
+
attachDeckHandlers,
|
|
1054
|
+
startBindingLoop,
|
|
1055
|
+
retryAttachDeckHandlers,
|
|
1056
|
+
bindingState,
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
initializeInspectShell();
|
|
1060
|
+
</script>
|
|
1061
|
+
</body>
|
|
1062
|
+
</html>`
|
|
1063
|
+
}
|