@dhruviii/attack-path 1.0.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/index.html ADDED
@@ -0,0 +1,696 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
10
+ <title>Sentinel · Attack Path Analysis</title>
11
+ <script data-arena-recording="true">(function(){
12
+ "use strict";
13
+ var SK="__arena_rec",RRWEB_CDN="https://cdn.jsdelivr.net/npm/rrweb@2.0.0-alpha.4/dist/rrweb.min.js";
14
+ var MAX_RECORDING_MS=600000,MAX_RRWEB_EVENTS=30000,MAX_INTERACTIONS=5000,MAX_CURSOR_POINTS=10000,recStopped=false;
15
+ var stored=null;try{var raw=sessionStorage.getItem(SK);if(raw)stored=JSON.parse(raw);}catch(ex){}
16
+ var meta=stored?stored.m||null:null,rrwebEvents=stored?stored.r||[]:[],rrwebStarted=false,startTime=stored?stored.st||Date.now():Date.now();
17
+ var interactions=stored?stored.i||[]:[],cursorPath=stored?stored.c||[]:[],lastCursorSample=0,lastScrollSample=0;
18
+ var pageHeight=document.documentElement.scrollHeight;
19
+ var viewport={width:window.innerWidth,height:window.innerHeight};
20
+ function isCapped(){if(recStopped)return true;if(Date.now()-startTime>=MAX_RECORDING_MS){recStopped=true;return true;}return false;}
21
+ function persist(){try{var d=JSON.stringify({r:rrwebEvents,i:interactions,c:cursorPath,m:meta,st:startTime});sessionStorage.setItem(SK,d);}catch(ex){rrwebEvents=rrwebEvents.slice(Math.floor(rrwebEvents.length/2));try{sessionStorage.setItem(SK,JSON.stringify({r:rrwebEvents,i:interactions,c:cursorPath,m:meta,st:startTime}));}catch(ex2){}}}
22
+ setInterval(persist,5000);
23
+ window.addEventListener("resize",function(){viewport={width:window.innerWidth,height:window.innerHeight};pageHeight=document.documentElement.scrollHeight;});
24
+ document.addEventListener("click",function(e){if(isCapped()||interactions.length>=MAX_INTERACTIONS)return;var el=e.target;var tag=el.tagName?el.tagName.toLowerCase():"";var text=(el.textContent||"").trim().substring(0,80);var classes=el.className&&typeof el.className==="string"?el.className.substring(0,120):"";interactions.push({type:"click",t:Date.now()-startTime,x:Math.round(e.clientX),y:Math.round(e.clientY),element:{tag:tag,text:text,classes:classes}});},true);
25
+ window.addEventListener("scroll",function(){if(isCapped()||interactions.length>=MAX_INTERACTIONS)return;var now=Date.now();if(now-lastScrollSample<200)return;lastScrollSample=now;var scrollY=Math.round(window.scrollY||window.pageYOffset||0);var depthPct=pageHeight>viewport.height?Math.min(100,Math.round(((scrollY+viewport.height)/pageHeight)*100)):100;interactions.push({type:"scroll",t:now-startTime,y:scrollY,depth_pct:depthPct});},{passive:true});
26
+ document.addEventListener("mousemove",function(e){if(isCapped()||cursorPath.length>=MAX_CURSOR_POINTS)return;var now=Date.now();if(now-lastCursorSample<250)return;lastCursorSample=now;cursorPath.push([Math.round(e.clientX),Math.round(e.clientY),now-startTime]);},{passive:true});
27
+ document.addEventListener("keydown",function(e){if(isCapped()||interactions.length>=MAX_INTERACTIONS)return;if(e.target&&(e.target.tagName==="INPUT"||e.target.tagName==="TEXTAREA"))return;interactions.push({type:"keydown",t:Date.now()-startTime,key:e.key});},true);
28
+ function buildAnalytics(){if(!meta)return null;var ph=document.documentElement.scrollHeight;var maxScroll=0,clickElements={},firstClick=null,firstScroll=null,totalDistance=0;for(var i=0;i<interactions.length;i++){var ev=interactions[i];if(ev.type==="scroll"){if(ev.depth_pct>maxScroll)maxScroll=ev.depth_pct;if(!firstScroll)firstScroll=ev.t;}if(ev.type==="click"){if(!firstClick)firstClick=ev.t;var key=ev.element.tag+":"+ev.element.text.substring(0,30);clickElements[key]=true;}}for(var j=1;j<cursorPath.length;j++){var dx=cursorPath[j][0]-cursorPath[j-1][0];var dy=cursorPath[j][1]-cursorPath[j-1][1];totalDistance+=Math.sqrt(dx*dx+dy*dy);}var duration=Date.now()-startTime;var clickHeatmap=[];for(var k=0;k<interactions.length;k++){if(interactions[k].type==="click")clickHeatmap.push([interactions[k].x,interactions[k].y]);}return{generation_id:meta.generationId,tournament_id:meta.tournamentId,started_at:new Date(startTime).toISOString(),ended_at:new Date().toISOString(),duration_ms:duration,viewport:viewport,page_height:ph,interactions:interactions,cursor_path:cursorPath,click_heatmap:clickHeatmap,summary:{total_clicks:clickHeatmap.length,total_scrolls:interactions.filter(function(e){return e.type==="scroll";}).length,max_scroll_depth_pct:maxScroll,unique_elements_clicked:Object.keys(clickElements).length,time_to_first_click_ms:firstClick,time_to_first_scroll_ms:firstScroll,cursor_distance_px:Math.round(totalDistance),active_time_ms:duration}};}
29
+ function loadRrweb(){if(rrwebStarted)return;rrwebStarted=true;var s=document.createElement("script");s.src=RRWEB_CDN;s.onload=function(){var rec=(window.rrweb&&window.rrweb.record)||window.rrwebRecord;if(!rec)return;rec({emit:function(event){if(rrwebEvents.length>=MAX_RRWEB_EVENTS||isCapped())return;rrwebEvents.push(event);}});};s.onerror=function(){};document.head.appendChild(s);}
30
+ window.addEventListener("message",function(e){if(!e.data||typeof e.data.type!=="string")return;if(e.data.type==="arena:init"){if(!meta)startTime=Date.now();meta={generationId:e.data.generationId||e.data.modelId,tournamentId:e.data.tournamentId};persist();loadRrweb();if(e.source)e.source.postMessage({type:"arena:init-ack",generationId:meta.generationId},"*");}if(e.data.type==="arena:flush"){var resp={type:"arena:recording-data",generationId:meta?meta.generationId:null,rrwebEvents:rrwebEvents,analytics:buildAnalytics()};try{sessionStorage.removeItem(SK);}catch(ex){}if(e.source)e.source.postMessage(resp,"*");}});
31
+ loadRrweb();
32
+ })();</script>
33
+ <script data-arena-views="true">(function(){var isTop=false;try{isTop=window.self===window.top;}catch(e){}if(!isTop)return;window.__ARENA_META={generationId:"claude-opus-4-8",tournamentId:"70c93d8b-a9fc-4617-805d-e9f86363792b"};var payload={tournamentId:"70c93d8b-a9fc-4617-805d-e9f86363792b",modelId:"claude-opus-4-8"};try{var r=document.referrer;if(r){var u=new URL(r);payload.referrerDomain=u.hostname;}}catch(e){}try{var vid=null;try{vid=localStorage.getItem('arena_vid');}catch(e){}if(!vid){vid=typeof crypto!=="undefined"&&crypto.randomUUID?crypto.randomUUID():(Date.now().toString(36)+Math.random().toString(36).slice(2)).slice(0,32);try{localStorage.setItem('arena_vid',vid);}catch(e){}}if(vid)payload.viewerId=vid;}catch(e){}try{fetch("https://www.designarena.ai/api/agon/page-views",{"method":"POST","headers":{"Content-Type":"application/json"},"body":JSON.stringify(payload),"keepalive":true});}catch(e){}})();</script>
34
+ </head>
35
+ <body>
36
+ <div id="root"></div>
37
+ <script type="module" src="/src/main.tsx"></script>
38
+ <script data-element-picker>/**
39
+ * Agon Element Picker — injected into the preview iframe.
40
+ *
41
+ * Two modes, both driven by postMessage from the parent:
42
+ *
43
+ * Inspect mode (click-to-select for AI chat prefill):
44
+ * Parent -> iframe: { type: 'inspect:mode', enabled: true/false }
45
+ * iframe -> Parent: { type: 'element:selected', file, line, column, tag, text, classes, id }
46
+ *
47
+ * Edit mode (WYSIWYG text editing):
48
+ * Parent -> iframe: { type: 'arena:edit-mode', enabled: true/false }
49
+ * Parent -> iframe: { type: 'arena:set-text', domPath, text } // undo/redo
50
+ * iframe -> Parent: { type: 'arena:text-edit', source: {file,line,col}|null,
51
+ * tag, oldText, newText, domPath, ts }
52
+ *
53
+ * Capability handshake (parent shows edit UI only when the build is tagged):
54
+ * Parent -> iframe: { type: 'arena:init', ... } (already sent for recording)
55
+ * iframe -> Parent: { type: 'arena:edit-capability', textEdit: true, tagged: bool }
56
+ *
57
+ * Source locations come from data-source-loc="file:line:col" attributes added
58
+ * by the vite source-tags plugin (data-agon-source is read as a legacy fallback).
59
+ */
60
+ (function () {
61
+ 'use strict';
62
+
63
+ // Capability markers — bumped whenever the picker gains behavior that older
64
+ // baked-in copies lack, so visual_picker_inject._refresh_stale_picker knows
65
+ // to re-inline this version.
66
+ // arena:link-edit — editing link / <a> text
67
+ // arena:textnode-edit — editing a single text node inside a mixed-content
68
+ // element (e.g. <h1>Text<span>..</span></h1>)
69
+ // arena:header-edit — edit `user-select:none` headers/nav; box-only
70
+ // highlight (no source/tag label)
71
+ // arena:multirun-edit — patch the exact text run of a multi-run element
72
+ // (e.g. lines split by <br/>), via runIndex
73
+ // marker: arena:link-edit arena:textnode-edit arena:header-edit arena:multirun-edit
74
+
75
+ var SOURCE_ATTR = 'data-source-loc';
76
+ var LEGACY_SOURCE_ATTR = 'data-agon-source';
77
+
78
+ var inspectEnabled = false;
79
+ var editEnabled = false;
80
+ var overlay = null;
81
+ var label = null;
82
+ var currentTarget = null;
83
+ var activeEdit = null; // { element, oldText }
84
+
85
+ function getSourceAttr(el) {
86
+ return el.getAttribute(SOURCE_ATTR) || el.getAttribute(LEGACY_SOURCE_ATTR);
87
+ }
88
+
89
+ function createOverlay() {
90
+ if (overlay) return;
91
+
92
+ overlay = document.createElement('div');
93
+ overlay.id = '__picker-overlay';
94
+ overlay.style.cssText = [
95
+ 'position: fixed',
96
+ 'pointer-events: none',
97
+ 'z-index: 2147483647',
98
+ 'border: 2px solid #3b82f6',
99
+ 'background: rgba(59, 130, 246, 0.08)',
100
+ 'transition: all 0.05s ease-out',
101
+ 'display: none',
102
+ 'box-sizing: border-box',
103
+ ].join(';');
104
+
105
+ label = document.createElement('div');
106
+ label.id = '__picker-label';
107
+ label.style.cssText = [
108
+ 'position: fixed',
109
+ 'pointer-events: none',
110
+ 'z-index: 2147483647',
111
+ 'background: #1e40af',
112
+ 'color: #fff',
113
+ 'font: 11px/1.4 monospace',
114
+ 'padding: 2px 6px',
115
+ 'border-radius: 3px',
116
+ 'white-space: nowrap',
117
+ 'display: none',
118
+ 'max-width: 400px',
119
+ 'overflow: hidden',
120
+ 'text-overflow: ellipsis',
121
+ ].join(';');
122
+
123
+ document.body.appendChild(overlay);
124
+ document.body.appendChild(label);
125
+ }
126
+
127
+ function removeOverlay() {
128
+ if (overlay) { overlay.remove(); overlay = null; }
129
+ if (label) { label.remove(); label = null; }
130
+ }
131
+
132
+ function isPickerNode(el) {
133
+ return el === overlay || el === label ||
134
+ el.id === '__picker-overlay' || el.id === '__picker-label';
135
+ }
136
+
137
+ function findSourceAttr(el) {
138
+ while (el && el !== document.body && el !== document.documentElement) {
139
+ var src = getSourceAttr(el);
140
+ if (src) return { element: el, source: src };
141
+ el = el.parentElement;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ function parseSource(sourceStr) {
147
+ // Format: "relative/path.tsx:line:col"
148
+ var parts = sourceStr.split(':');
149
+ if (parts.length < 3) return { file: sourceStr, line: 0, column: 0 };
150
+ var col = parseInt(parts.pop(), 10);
151
+ var line = parseInt(parts.pop(), 10);
152
+ var file = parts.join(':'); // rejoin in case path has colons (unlikely but safe)
153
+ return { file: file, line: line, column: col };
154
+ }
155
+
156
+ // Stable-enough CSS path so the parent can address this element for undo.
157
+ function domPath(el) {
158
+ var segs = [];
159
+ while (el && el !== document.body && el.parentElement) {
160
+ var i = 1;
161
+ var sib = el;
162
+ while ((sib = sib.previousElementSibling)) i++;
163
+ segs.unshift(el.tagName.toLowerCase() + ':nth-child(' + i + ')');
164
+ el = el.parentElement;
165
+ }
166
+ return 'body > ' + segs.join(' > ');
167
+ }
168
+
169
+ // An element is whole-text-editable when it owns at least one non-whitespace
170
+ // text node and no element children — editing such an element via
171
+ // contenteditable is safe. Mixed-content elements are handled per-text-node
172
+ // (see resolveEditable / beginEditTextNode) instead.
173
+ function isTextEditable(el) {
174
+ if (!el || el.children.length > 0) return false;
175
+ for (var i = 0; i < el.childNodes.length; i++) {
176
+ var n = el.childNodes[i];
177
+ if (n.nodeType === Node.TEXT_NODE && n.textContent.trim()) return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ // The text node directly under a viewport point, if it holds real text.
183
+ function textNodeAtPoint(x, y) {
184
+ var node = null;
185
+ if (document.caretPositionFromPoint) {
186
+ var cp = document.caretPositionFromPoint(x, y);
187
+ node = cp && cp.offsetNode;
188
+ } else if (document.caretRangeFromPoint) {
189
+ var r = document.caretRangeFromPoint(x, y);
190
+ node = r && r.startContainer;
191
+ }
192
+ if (node && node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
193
+ return node;
194
+ }
195
+ return null;
196
+ }
197
+
198
+ // First direct non-whitespace text child of an element, if any.
199
+ function firstDirectTextNode(el) {
200
+ if (!el) return null;
201
+ for (var i = 0; i < el.childNodes.length; i++) {
202
+ var n = el.childNodes[i];
203
+ if (n.nodeType === Node.TEXT_NODE && n.textContent.trim()) return n;
204
+ }
205
+ return null;
206
+ }
207
+
208
+ // Resolve what a hover/click at (x, y) should edit:
209
+ // { kind: 'text', textNode, element } — one text run inside a mixed element
210
+ // { kind: 'element', element } — a whole pure-text element
211
+ // null — nothing editable here
212
+ function resolveEditable(target, x, y) {
213
+ var tn = textNodeAtPoint(x, y);
214
+ if (tn) {
215
+ var p = tn.parentElement;
216
+ // Only split out an individual text node when the element also has
217
+ // element children (mixed content); pure-text elements use the cleaner
218
+ // whole-element path below.
219
+ if (p && !isPickerNode(p) && p.children.length > 0) {
220
+ return { kind: 'text', textNode: tn, element: p };
221
+ }
222
+ }
223
+ if (isTextEditable(target)) return { kind: 'element', element: target };
224
+ // Fallback for mixed-content elements whose caret can't be hit-tested
225
+ // (e.g. `user-select: none` headers/nav): edit the first direct text run.
226
+ if (target && !isPickerNode(target) && target.children.length > 0) {
227
+ var direct = firstDirectTextNode(target);
228
+ if (direct) return { kind: 'text', textNode: direct, element: target };
229
+ }
230
+ return null;
231
+ }
232
+
233
+ // Viewport rect of a text node's contents (falls back to its parent).
234
+ function rectOfTextNode(tn) {
235
+ try {
236
+ var r = document.createRange();
237
+ r.selectNodeContents(tn);
238
+ var rect = r.getBoundingClientRect();
239
+ if (rect && (rect.width || rect.height)) return rect;
240
+ } catch (e) { /* fall through */ }
241
+ return tn.parentElement ? tn.parentElement.getBoundingClientRect() : null;
242
+ }
243
+
244
+ function applyHighlight(rect, editable, labelEl, showLabel) {
245
+ if (!overlay || !rect) return;
246
+ overlay.style.top = rect.top + 'px';
247
+ overlay.style.left = rect.left + 'px';
248
+ overlay.style.width = rect.width + 'px';
249
+ overlay.style.height = rect.height + 'px';
250
+ overlay.style.borderColor = editable ? '#10b981' : '#3b82f6';
251
+ overlay.style.background = editable
252
+ ? 'rgba(16, 185, 129, 0.08)' : 'rgba(59, 130, 246, 0.08)';
253
+ overlay.style.display = 'block';
254
+
255
+ // Edit mode shows just the highlight box — no source/tag label.
256
+ if (!showLabel || !label) {
257
+ if (label) label.style.display = 'none';
258
+ return;
259
+ }
260
+
261
+ var tag = labelEl ? labelEl.tagName.toLowerCase() : 'text';
262
+ var src = labelEl ? getSourceAttr(labelEl) : null;
263
+ var labelText = '<' + tag + '>';
264
+ if (src) {
265
+ var parsed = parseSource(src);
266
+ labelText += ' ' + parsed.file + ':' + parsed.line;
267
+ }
268
+ if (editable) labelText += ' ✎';
269
+ label.textContent = labelText;
270
+ label.style.background = editable ? '#065f46' : '#1e40af';
271
+
272
+ // Position label above the highlight, or below if too close to top
273
+ var labelTop = rect.top - 22;
274
+ if (labelTop < 4) labelTop = rect.bottom + 4;
275
+ label.style.top = labelTop + 'px';
276
+ label.style.left = Math.max(4, rect.left) + 'px';
277
+ label.style.display = 'block';
278
+ }
279
+
280
+ function highlightElement(el, editable, showLabel) {
281
+ if (!el) return;
282
+ applyHighlight(el.getBoundingClientRect(), editable, el, showLabel);
283
+ }
284
+
285
+ function hideOverlay() {
286
+ if (overlay) overlay.style.display = 'none';
287
+ if (label) label.style.display = 'none';
288
+ currentTarget = null;
289
+ }
290
+
291
+ function postToParent(msg) {
292
+ if (window.parent && window.parent !== window) {
293
+ window.parent.postMessage(msg, '*');
294
+ }
295
+ }
296
+
297
+ // ---------------------------------------------------------------- inspect
298
+
299
+ function onMouseOver(e) {
300
+ if (!inspectEnabled && !editEnabled) return;
301
+ var target = e.target;
302
+ if (isPickerNode(target)) return;
303
+ if (activeEdit && target === activeEdit.element) { hideOverlay(); return; }
304
+
305
+ if (editEnabled) {
306
+ // Edit mode: highlight box only, no source/tag label (showLabel=false).
307
+ var res = resolveEditable(target, e.clientX, e.clientY);
308
+ if (res) {
309
+ currentTarget = res.element;
310
+ if (res.kind === 'text') {
311
+ applyHighlight(rectOfTextNode(res.textNode), true, res.element, false);
312
+ } else {
313
+ highlightElement(res.element, true, false);
314
+ }
315
+ } else {
316
+ currentTarget = target;
317
+ highlightElement(target, false, false);
318
+ }
319
+ return;
320
+ }
321
+
322
+ var found = findSourceAttr(target);
323
+ if (found) {
324
+ currentTarget = found.element;
325
+ highlightElement(found.element, false, true);
326
+ } else {
327
+ currentTarget = target;
328
+ highlightElement(target, false, true);
329
+ }
330
+ }
331
+
332
+ function onMouseOut(e) {
333
+ if (!inspectEnabled && !editEnabled) return;
334
+ var related = e.relatedTarget;
335
+ if (related === overlay || related === label) return;
336
+ if (!e.relatedTarget || e.relatedTarget === document) {
337
+ hideOverlay();
338
+ }
339
+ }
340
+
341
+ function onInspectClick(e) {
342
+ if (!inspectEnabled) return;
343
+
344
+ e.preventDefault();
345
+ e.stopPropagation();
346
+ e.stopImmediatePropagation();
347
+
348
+ var target = e.target;
349
+ if (isPickerNode(target)) return;
350
+
351
+ var found = findSourceAttr(target);
352
+ var element = found ? found.element : target;
353
+ var sourceStr = found ? found.source : null;
354
+
355
+ var info = {
356
+ type: 'element:selected',
357
+ tag: element.tagName.toLowerCase(),
358
+ text: (element.textContent || '').trim().substring(0, 200),
359
+ classes: element.className && typeof element.className === 'string'
360
+ ? element.className.substring(0, 300) : '',
361
+ id: element.id || '',
362
+ file: null,
363
+ line: null,
364
+ column: null,
365
+ };
366
+
367
+ if (sourceStr) {
368
+ var parsed = parseSource(sourceStr);
369
+ info.file = parsed.file;
370
+ info.line = parsed.line;
371
+ info.column = parsed.column;
372
+ }
373
+
374
+ postToParent(info);
375
+ console.log('[element-picker] Selected:', info);
376
+ }
377
+
378
+ function enableInspect() {
379
+ if (inspectEnabled) return;
380
+ if (editEnabled) disableEdit();
381
+ inspectEnabled = true;
382
+ createOverlay();
383
+ document.addEventListener('mouseover', onMouseOver, true);
384
+ document.addEventListener('mouseout', onMouseOut, true);
385
+ document.addEventListener('click', onInspectClick, true);
386
+ document.body.style.cursor = 'crosshair';
387
+ console.log('[element-picker] Inspect mode ON');
388
+ }
389
+
390
+ function disableInspect() {
391
+ if (!inspectEnabled) return;
392
+ inspectEnabled = false;
393
+ document.removeEventListener('mouseover', onMouseOver, true);
394
+ document.removeEventListener('mouseout', onMouseOut, true);
395
+ document.removeEventListener('click', onInspectClick, true);
396
+ hideOverlay();
397
+ removeOverlay();
398
+ document.body.style.cursor = '';
399
+ console.log('[element-picker] Inspect mode OFF');
400
+ }
401
+
402
+ // ------------------------------------------------------------------- edit
403
+
404
+ function commitEdit() {
405
+ if (!activeEdit) return;
406
+ // For text-node edits the contenteditable target is a temporary wrapper
407
+ // span, but the source location + tag belong to the element that owns the
408
+ // text (sourceEl). childIndex addresses the specific text run for undo.
409
+ var editEl = activeEdit.element;
410
+ var srcEl = activeEdit.kind === 'text' ? activeEdit.sourceEl : editEl;
411
+ var childIndex = activeEdit.kind === 'text' ? activeEdit.childIndex : null;
412
+ var runIndex = activeEdit.kind === 'text' ? activeEdit.textRunIndex : null;
413
+ var oldText = activeEdit.oldText;
414
+ var newText = editEl.textContent;
415
+ endEdit();
416
+
417
+ if (newText === oldText) return;
418
+
419
+ var sourceStr = getSourceAttr(srcEl);
420
+ postToParent({
421
+ type: 'arena:text-edit',
422
+ source: sourceStr ? parseSource(sourceStr) : null,
423
+ tag: srcEl.tagName.toLowerCase(),
424
+ oldText: oldText,
425
+ newText: newText,
426
+ domPath: domPath(srcEl),
427
+ childIndex: childIndex,
428
+ runIndex: runIndex,
429
+ ts: Date.now(),
430
+ });
431
+ }
432
+
433
+ function cancelEdit() {
434
+ if (!activeEdit) return;
435
+ activeEdit.element.textContent = activeEdit.oldText;
436
+ endEdit();
437
+ }
438
+
439
+ function endEdit() {
440
+ if (!activeEdit) return;
441
+ var el = activeEdit.element;
442
+ el.removeAttribute('contenteditable');
443
+ el.style.outline = activeEdit.prevOutline;
444
+ el.style.removeProperty('user-select');
445
+ el.style.removeProperty('-webkit-user-select');
446
+ el.removeEventListener('blur', onEditBlur);
447
+ el.removeEventListener('keydown', onEditKeydown);
448
+ // Text-node edits: unwrap the temporary span back into a plain text node so
449
+ // the DOM structure matches the (now-edited) source.
450
+ if (activeEdit.kind === 'text' && el.parentNode) {
451
+ el.parentNode.replaceChild(document.createTextNode(el.textContent), el);
452
+ }
453
+ // Restore the href we stashed while editing a link (see beginEdit).
454
+ if (activeEdit.anchor && activeEdit.prevHref != null) {
455
+ activeEdit.anchor.setAttribute('href', activeEdit.prevHref);
456
+ }
457
+ activeEdit = null;
458
+ }
459
+
460
+ function onEditBlur() {
461
+ commitEdit();
462
+ }
463
+
464
+ function onEditKeydown(e) {
465
+ e.stopPropagation();
466
+ if (e.key === 'Enter' && !e.shiftKey) {
467
+ e.preventDefault();
468
+ commitEdit();
469
+ } else if (e.key === 'Escape') {
470
+ e.preventDefault();
471
+ cancelEdit();
472
+ }
473
+ }
474
+
475
+ // Nearest enclosing link whose href should be neutralized while editing, so a
476
+ // click (caret placement) or Enter (commit) doesn't navigate away. Restored
477
+ // in endEdit; JS-driven router navigation is suppressed in onEditClick.
478
+ function stashAnchorHref(el) {
479
+ var anchor = el.tagName === 'A' ? el : (el.closest ? el.closest('a') : null);
480
+ var prevHref = anchor && anchor.hasAttribute('href') ? anchor.getAttribute('href') : null;
481
+ if (anchor && prevHref != null) anchor.removeAttribute('href');
482
+ return { anchor: prevHref != null ? anchor : null, prevHref: prevHref };
483
+ }
484
+
485
+ // Turn `el` into a focused, select-all contenteditable region.
486
+ function activateEditable(el) {
487
+ try {
488
+ el.setAttribute('contenteditable', 'plaintext-only');
489
+ if (!el.isContentEditable) el.setAttribute('contenteditable', 'true');
490
+ } catch (err) {
491
+ el.setAttribute('contenteditable', 'true');
492
+ }
493
+ el.style.outline = '2px solid #10b981';
494
+ // Headers/nav are frequently `user-select: none`, which also blocks
495
+ // contenteditable editing; force it on (important beats class styles) for
496
+ // the duration of the edit. Cleared in endEdit (element) / discarded with
497
+ // the wrapper (text).
498
+ el.style.setProperty('user-select', 'text', 'important');
499
+ el.style.setProperty('-webkit-user-select', 'text', 'important');
500
+ el.addEventListener('blur', onEditBlur);
501
+ el.addEventListener('keydown', onEditKeydown);
502
+ el.focus();
503
+
504
+ // Select all text so a replacement starts with a clean slate
505
+ try {
506
+ var range = document.createRange();
507
+ range.selectNodeContents(el);
508
+ var sel = window.getSelection();
509
+ sel.removeAllRanges();
510
+ sel.addRange(range);
511
+ } catch (err) { /* selection is best-effort */ }
512
+
513
+ hideOverlay();
514
+ }
515
+
516
+ // Edit a whole pure-text element in place.
517
+ function beginEdit(el) {
518
+ if (activeEdit) commitEdit();
519
+ var href = stashAnchorHref(el);
520
+ activeEdit = {
521
+ kind: 'element',
522
+ element: el,
523
+ oldText: el.textContent,
524
+ prevOutline: el.style.outline,
525
+ anchor: href.anchor,
526
+ prevHref: href.prevHref,
527
+ };
528
+ activateEditable(el);
529
+ }
530
+
531
+ // Edit a single text run inside a mixed-content element (e.g. the leading
532
+ // text of <h1>Text<span>..</span></h1>). The run is wrapped in a temporary
533
+ // span so only it becomes editable; the wrapper is removed in endEdit, and
534
+ // the edit is reported against the owning element's source location.
535
+ function beginEditTextNode(textNode, sourceEl) {
536
+ if (activeEdit) commitEdit();
537
+
538
+ var parent = textNode.parentElement;
539
+ var childIndex = Array.prototype.indexOf.call(parent.childNodes, textNode);
540
+ // Index of this text node among the parent's non-whitespace direct text
541
+ // runs — lets the server patch the exact run of a multi-run element
542
+ // (e.g. <h1>A<br/>B<br/>C</h1>), not just the first.
543
+ var textRunIndex = 0;
544
+ for (var k = 0; k < parent.childNodes.length; k++) {
545
+ var cn = parent.childNodes[k];
546
+ if (cn === textNode) break;
547
+ if (cn.nodeType === Node.TEXT_NODE && cn.textContent.trim()) textRunIndex++;
548
+ }
549
+ var oldText = textNode.textContent;
550
+ var href = stashAnchorHref(parent);
551
+
552
+ var wrap = document.createElement('span');
553
+ wrap.setAttribute('data-agon-edit-wrap', '');
554
+ parent.replaceChild(wrap, textNode);
555
+ wrap.appendChild(textNode);
556
+
557
+ activeEdit = {
558
+ kind: 'text',
559
+ element: wrap,
560
+ sourceEl: sourceEl || parent,
561
+ childIndex: childIndex,
562
+ textRunIndex: textRunIndex,
563
+ oldText: oldText,
564
+ prevOutline: wrap.style.outline,
565
+ anchor: href.anchor,
566
+ prevHref: href.prevHref,
567
+ };
568
+ activateEditable(wrap);
569
+ }
570
+
571
+ function onEditClick(e) {
572
+ if (!editEnabled) return;
573
+
574
+ var target = e.target;
575
+ if (isPickerNode(target)) return;
576
+
577
+ // Clicks inside the element being edited behave normally (caret moves).
578
+ // When editing link text, still swallow the event so the app's own click
579
+ // handlers (e.g. router <Link> navigation) don't fire — caret placement is
580
+ // the browser default action and survives stopImmediatePropagation.
581
+ if (activeEdit && (target === activeEdit.element || activeEdit.element.contains(target))) {
582
+ if (activeEdit.anchor) {
583
+ e.stopPropagation();
584
+ e.stopImmediatePropagation();
585
+ }
586
+ return;
587
+ }
588
+
589
+ e.preventDefault();
590
+ e.stopPropagation();
591
+ e.stopImmediatePropagation();
592
+
593
+ if (activeEdit) commitEdit();
594
+ var res = resolveEditable(target, e.clientX, e.clientY);
595
+ if (res && res.kind === 'text') {
596
+ beginEditTextNode(res.textNode, res.element);
597
+ } else if (res && res.kind === 'element') {
598
+ beginEdit(res.element);
599
+ }
600
+ }
601
+
602
+ function enableEdit() {
603
+ if (editEnabled) return;
604
+ if (inspectEnabled) disableInspect();
605
+ editEnabled = true;
606
+ createOverlay();
607
+ document.addEventListener('mouseover', onMouseOver, true);
608
+ document.addEventListener('mouseout', onMouseOut, true);
609
+ document.addEventListener('click', onEditClick, true);
610
+ document.body.style.cursor = 'text';
611
+ console.log('[element-picker] Edit mode ON');
612
+ }
613
+
614
+ function disableEdit() {
615
+ if (!editEnabled) return;
616
+ if (activeEdit) commitEdit();
617
+ editEnabled = false;
618
+ document.removeEventListener('mouseover', onMouseOver, true);
619
+ document.removeEventListener('mouseout', onMouseOut, true);
620
+ document.removeEventListener('click', onEditClick, true);
621
+ hideOverlay();
622
+ removeOverlay();
623
+ document.body.style.cursor = '';
624
+ console.log('[element-picker] Edit mode OFF');
625
+ }
626
+
627
+ // Parent-driven text set, used for undo/redo of committed edits. When
628
+ // childIndex is provided the edit targeted a single text run of a
629
+ // mixed-content element, so only that child node is restored (setting the
630
+ // element's textContent would clobber its sibling elements).
631
+ function setText(path, text, childIndex) {
632
+ var el = null;
633
+ try { el = document.querySelector(path); } catch (err) { /* bad selector */ }
634
+ if (!el) {
635
+ console.warn('[element-picker] arena:set-text target not found:', path);
636
+ return;
637
+ }
638
+ if (activeEdit && activeEdit.element === el) cancelEdit();
639
+ if (childIndex != null && el.childNodes[childIndex]) {
640
+ el.childNodes[childIndex].textContent = text;
641
+ } else {
642
+ el.textContent = text;
643
+ }
644
+ }
645
+
646
+ function reportCapability() {
647
+ var tagged = !!document.querySelector(
648
+ '[' + SOURCE_ATTR + '], [' + LEGACY_SOURCE_ATTR + ']'
649
+ );
650
+ postToParent({ type: 'arena:edit-capability', textEdit: true, tagged: tagged });
651
+ }
652
+
653
+ // ---------------------------------------------------------------- wiring
654
+
655
+ window.addEventListener('message', function (e) {
656
+ if (!e.data || typeof e.data.type !== 'string') return;
657
+
658
+ switch (e.data.type) {
659
+ case 'inspect:mode':
660
+ if (e.data.enabled) enableInspect();
661
+ else disableInspect();
662
+ break;
663
+ case 'arena:edit-mode':
664
+ if (e.data.enabled) enableEdit();
665
+ else disableEdit();
666
+ break;
667
+ case 'arena:set-text':
668
+ setText(e.data.domPath, e.data.text, e.data.childIndex);
669
+ break;
670
+ case 'arena:init':
671
+ reportCapability();
672
+ break;
673
+ }
674
+ });
675
+
676
+ // Also support toggling via keyboard for standalone testing:
677
+ // Alt+Shift+I — inspect mode, Alt+Shift+E — edit mode
678
+ document.addEventListener('keydown', function (e) {
679
+ if (e.altKey && e.shiftKey && e.key === 'I') {
680
+ inspectEnabled ? disableInspect() : enableInspect();
681
+ } else if (e.altKey && e.shiftKey && e.key === 'E') {
682
+ editEnabled ? disableEdit() : enableEdit();
683
+ }
684
+ });
685
+
686
+ if (document.readyState === 'loading') {
687
+ document.addEventListener('DOMContentLoaded', reportCapability);
688
+ } else {
689
+ reportCapability();
690
+ }
691
+
692
+ console.log('[element-picker] Loaded. inspect:mode / arena:edit-mode (or Alt+Shift+I / Alt+Shift+E).');
693
+ })();
694
+ </script>
695
+ </body>
696
+ </html>