diff --git a/apps/cli/ai/inspector/inspector-inject.ts b/apps/cli/ai/inspector/inspector-inject.ts
index 2b724c482b..27944bea18 100644
--- a/apps/cli/ai/inspector/inspector-inject.ts
+++ b/apps/cli/ai/inspector/inspector-inject.ts
@@ -5,7 +5,7 @@
* The inspector is a self-contained vanilla-DOM widget defined in
* `./page-script.ts` — no React, no esm.sh, no external dependencies, no
* page-CSS conflicts. The user clicks elements, types comments, and clicks
- * "Done" to send everything back to the CLI via `window.__studioAnnotateDone`.
+ * "Send to agent" to return everything via `window.__studioAnnotateDone`.
*/
import { launchChromiumWithInstall } from 'cli/ai/browser-utils';
@@ -72,7 +72,7 @@ export interface AnnotationDoneResult {
}
/**
- * Block until the user clicks "Done" in the inspector toolbar, then return
+ * Block until the user clicks "Send to agent" in the inspector toolbar, then return
* the annotations. Reads from `window.__studioAnnotateDone` which the page
* script populates from its in-memory state and localStorage.
*/
@@ -103,7 +103,7 @@ export async function waitForAnnotationsDone(
// Auto-close the browser once we've captured the annotations. This
// makes the lifecycle unambiguous from the user's point of view —
- // clicking "Done" closes the window — and removes the failure mode
+ // clicking "Send to agent" closes the window — and removes the failure mode
// where a user re-annotates after the agent has already moved on, with
// the new payload sitting on `window.__studioAnnotateDone` but nobody
// polling for it. To start another round, the user just re-runs the
@@ -132,7 +132,7 @@ export async function openAnnotationBrowser( siteUrl: string ): Promise< string
try {
await inspectorPage.bringToFront();
await injectInspector( inspectorPage );
- return 'Inspector reattached to the open browser. Click "Annotate" to pick an element, then "Done" when finished.';
+ return 'Inspector reattached to the open browser. Click "Annotate" to pick an element, then "Send to agent" when finished.';
} catch {
await shutdownBrowser();
}
@@ -194,5 +194,5 @@ export async function openAnnotationBrowser( siteUrl: string ): Promise< string
installProcessExitHook();
- return `Annotation browser opened at ${ siteUrl }. Click "Annotate" in the bottom-right toolbar, click an element, type your feedback, then click "Done" when you're finished.`;
+ return `Annotation browser opened at ${ siteUrl }. Click "Annotate" in the bottom-right toolbar, click an element, type your feedback, then click "Send to agent" when you're finished.`;
}
diff --git a/apps/cli/ai/inspector/page-script.ts b/apps/cli/ai/inspector/page-script.ts
index e6c709f1ee..04e696bd32 100644
--- a/apps/cli/ai/inspector/page-script.ts
+++ b/apps/cli/ai/inspector/page-script.ts
@@ -1,612 +1,3 @@
-/**
- * Self-contained annotation inspector injected into the page.
- *
- * Lets the user click elements to attach comments, then ship the whole batch
- * back to the CLI by clicking "Done". Vanilla DOM, no React, no esm.sh, no
- * external imports — written this way so injection is reliable on any
- * WordPress page regardless of CSP, network access, or theme CSS.
- *
- * Storage: a single localStorage key `studio-inspector-annotations-v1`,
- * scoped per pathname inside the JSON, so refreshing the page or navigating
- * between site URLs doesn't lose work.
- *
- * Hand-off to the agent: clicking "Done" sets `window.__studioAnnotateDone`,
- * which `waitForAnnotationsDone()` in inspector-inject.ts polls for.
- */
+import { createCliInspectorPageScript } from '@studio/common/ai/inspector-page-script';
-export const INSPECTOR_PAGE_SCRIPT =
- String.raw`
-( () => {
- if ( window.__studioInspectorMounted ) {
- return;
- }
- window.__studioInspectorMounted = true;
-
- const STORAGE_KEY = 'studio-inspector-annotations-v1';
- const HOST_ID = '__studio-inspector-host';
-
- /* --------------------------------------------------------------------
- * Storage
- * ------------------------------------------------------------------ */
- function loadAnnotations() {
- try {
- const raw = localStorage.getItem( STORAGE_KEY );
- const parsed = raw ? JSON.parse( raw ) : [];
- return Array.isArray( parsed ) ? parsed : [];
- } catch {
- return [];
- }
- }
- function saveAnnotations( list ) {
- try {
- localStorage.setItem( STORAGE_KEY, JSON.stringify( list ) );
- } catch {}
- }
-
- /* --------------------------------------------------------------------
- * Element identification — build a CSS selector that's specific enough
- * for the agent to find the element again via wp_cli/theme files.
- * ------------------------------------------------------------------ */
- function buildSelector( el ) {
- if ( ! el || el.nodeType !== 1 ) return '';
- if ( el.id ) return '#' + CSS.escape( el.id );
- const parts = [];
- let node = el;
- while ( node && node.nodeType === 1 && node !== document.documentElement ) {
- let part = node.tagName.toLowerCase();
- if ( node.classList && node.classList.length ) {
- const classes = Array.from( node.classList )
- .filter( ( c ) => ! c.startsWith( '__studio-' ) )
- .slice( 0, 3 )
- .map( ( c ) => '.' + CSS.escape( c ) )
- .join( '' );
- part += classes;
- }
- const parent = node.parentElement;
- if ( parent ) {
- /* Exclude our own host element when counting siblings — it
- * lives directly under
, so without this filter every
- * top-level child of picks up an off-by-one
- * nth-of-type index and the agent looks up the wrong
- * element. */
- const sameTagSiblings = Array.from( parent.children ).filter(
- ( c ) => c.tagName === node.tagName && c.id !== HOST_ID
- );
- if ( sameTagSiblings.length > 1 ) {
- part += ':nth-of-type(' + ( sameTagSiblings.indexOf( node ) + 1 ) + ')';
- }
- }
- parts.unshift( part );
- node = parent;
- if ( parts.length >= 6 ) break;
- }
- return parts.join( ' > ' );
- }
-
- function nearbyText( el ) {
- const text = ( el.innerText || el.textContent || '' )
- .replace( /\s+/g, ' ' )
- .trim();
- return text.length > 200 ? text.slice( 0, 200 ) + '…' : text;
- }
-
- function pickComputedStyles( el ) {
- const cs = window.getComputedStyle( el );
- const keys = [
- 'color',
- 'background-color',
- 'font-size',
- 'font-weight',
- 'font-family',
- 'line-height',
- 'padding',
- 'margin',
- 'border',
- 'display',
- 'width',
- 'height',
- ];
- const out = {};
- for ( const k of keys ) {
- out[ k ] = cs.getPropertyValue( k );
- }
- return out;
- }
-
- function uid() {
- return 'a_' + Math.random().toString( 36 ).slice( 2, 10 );
- }
-
- /* --------------------------------------------------------------------
- * Shadow-DOM host so page CSS can't leak in and our CSS doesn't leak out.
- * ------------------------------------------------------------------ */
- const oldHost = document.getElementById( HOST_ID );
- if ( oldHost ) oldHost.remove();
- const host = document.createElement( 'div' );
- host.id = HOST_ID;
- host.style.cssText =
- 'all: initial; position: fixed; inset: 0; pointer-events: none; z-index: 2147483647;';
- document.body.appendChild( host );
- const root = host.attachShadow( { mode: 'open' } );
-
- const style = document.createElement( 'style' );
- style.textContent = ` +
- '`' +
- String.raw`
- :host { all: initial; }
- * { box-sizing: border-box; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
- .toolbar {
- position: fixed; bottom: 1.25rem; right: 1.25rem;
- display: flex; align-items: center; gap: 6px;
- background: #1a1a1a; color: #fff; border-radius: 22px;
- padding: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.2), 0 4px 16px rgba(0,0,0,0.1);
- pointer-events: auto;
- }
- .toolbar button {
- height: 32px; padding: 0 12px;
- background: transparent; color: #fff;
- border: none; border-radius: 16px;
- font: 600 12px/1 inherit; cursor: pointer;
- white-space: nowrap;
- }
- .toolbar button:hover { background: rgba(255,255,255,0.1); }
- .toolbar button.primary { background: #fff; color: #1a1a1a; }
- .toolbar button.primary:hover { background: #f0f0f0; }
- .toolbar button.primary[disabled] { opacity: 0.5; cursor: default; }
- .toolbar button.active { background: #2563eb; color: #fff; }
- .toolbar .count {
- min-width: 22px; height: 22px; padding: 0 6px;
- display: inline-flex; align-items: center; justify-content: center;
- background: rgba(255,255,255,0.15); color: #fff;
- border-radius: 11px; font: 600 11px/1 inherit;
- }
- .highlight {
- position: fixed; pointer-events: none;
- border: 2px solid #2563eb;
- background: rgba(37,99,235,0.1);
- border-radius: 2px;
- transition: all 80ms ease-out;
- z-index: 1;
- }
- .marker {
- position: fixed; pointer-events: auto; cursor: pointer;
- width: 22px; height: 22px;
- background: #2563eb; color: #fff;
- border: 2px solid #fff;
- border-radius: 50%;
- box-shadow: 0 2px 6px rgba(0,0,0,0.3);
- font: 700 11px/1 inherit;
- display: inline-flex; align-items: center; justify-content: center;
- z-index: 2;
- }
- .popup {
- position: fixed; width: 320px;
- background: #1a1a1a; color: #fff;
- border-radius: 12px;
- box-shadow: 0 4px 24px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.08);
- padding: 12px; z-index: 3;
- pointer-events: auto;
- display: flex; flex-direction: column; gap: 8px;
- }
- .popup .target {
- font-size: 11px; color: rgba(255,255,255,0.5);
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
- }
- .popup textarea {
- width: 100%; min-height: 72px; resize: vertical;
- background: rgba(255,255,255,0.05); color: #fff;
- border: 1px solid rgba(255,255,255,0.15); border-radius: 8px;
- padding: 8px; font: 13px/1.4 inherit; outline: none;
- }
- .popup textarea:focus { border-color: #2563eb; }
- .popup .actions { display: flex; justify-content: flex-end; gap: 6px; }
- .popup button {
- padding: 6px 12px; border-radius: 16px; border: none;
- font: 600 12px/1 inherit; cursor: pointer;
- }
- .popup .delete {
- background: transparent; color: rgba(255,255,255,0.5);
- margin-right: auto;
- }
- .popup .delete:hover { color: #ef4444; }
- .popup .cancel { background: transparent; color: rgba(255,255,255,0.7); }
- .popup .cancel:hover { background: rgba(255,255,255,0.08); }
- .popup .save { background: #fff; color: #1a1a1a; }
- .popup .save[disabled] { opacity: 0.4; cursor: default; }
- .toast {
- position: fixed; bottom: calc(1.25rem + 56px); right: 1.25rem;
- background: rgba(26,26,26,0.95); color: #fff;
- padding: 10px 14px; border-radius: 10px;
- font: 500 12px/1.4 inherit;
- pointer-events: auto;
- }
- ` +
- '`' +
- String.raw`;
- root.appendChild( style );
-
- /* --------------------------------------------------------------------
- * State
- * ------------------------------------------------------------------ */
- let isPicking = false;
- let hoveredEl = null;
- let activePopup = null; /* { id?, target, comment } */
- let annotations = loadAnnotations();
-
- /* --------------------------------------------------------------------
- * Render
- * ------------------------------------------------------------------ */
- const layer = document.createElement( 'div' );
- root.appendChild( layer );
-
- function render() {
- layer.innerHTML = '';
-
- /* Markers for existing annotations on the current path */
- const path = window.location.pathname;
- const pageAnnotations = annotations.filter( ( a ) => a.pathname === path );
- pageAnnotations.forEach( ( ann, idx ) => {
- let el = null;
- try {
- el = ann.selector ? document.querySelector( ann.selector ) : null;
- } catch {}
- if ( ! el ) return;
- const r = el.getBoundingClientRect();
- const marker = document.createElement( 'div' );
- marker.className = 'marker';
- marker.textContent = String( idx + 1 );
- marker.style.left = r.left + r.width - 11 + 'px';
- marker.style.top = r.top - 11 + 'px';
- marker.title = ann.comment;
- marker.addEventListener( 'click', ( e ) => {
- e.stopPropagation();
- openPopupForAnnotation( ann );
- } );
- layer.appendChild( marker );
- } );
-
- /* Hover highlight while picking */
- if ( isPicking && hoveredEl ) {
- const r = hoveredEl.getBoundingClientRect();
- const hl = document.createElement( 'div' );
- hl.className = 'highlight';
- hl.style.left = r.left + 'px';
- hl.style.top = r.top + 'px';
- hl.style.width = r.width + 'px';
- hl.style.height = r.height + 'px';
- layer.appendChild( hl );
- }
-
- /* Active popup */
- if ( activePopup ) {
- const popup = buildPopup( activePopup );
- layer.appendChild( popup );
- }
-
- /* Toolbar */
- const toolbar = document.createElement( 'div' );
- toolbar.className = 'toolbar';
-
- const pickBtn = document.createElement( 'button' );
- pickBtn.textContent = isPicking ? 'Picking… click element' : 'Annotate';
- pickBtn.className = isPicking ? 'active' : '';
- pickBtn.addEventListener( 'click', () => {
- isPicking = ! isPicking;
- hoveredEl = null;
- activePopup = null;
- render();
- } );
- toolbar.appendChild( pickBtn );
-
- if ( annotations.length > 0 ) {
- const count = document.createElement( 'span' );
- count.className = 'count';
- count.textContent = String( annotations.length );
- count.title = annotations.length + ' annotation(s) total';
- toolbar.appendChild( count );
- }
-
- const doneBtn = document.createElement( 'button' );
- doneBtn.className = 'primary';
- doneBtn.textContent = 'Done';
- doneBtn.disabled = annotations.length === 0;
- doneBtn.title =
- annotations.length === 0
- ? 'Add at least one annotation first'
- : 'Send annotations and return';
- doneBtn.addEventListener( 'click', () => {
- if ( annotations.length === 0 ) return;
- const sent = annotations.slice();
- /* Hand off to the CLI, then reset the inspector to its default
- * idle state — annotations are no longer pending in the UI,
- * picking mode is off, and nothing is left in localStorage to
- * confuse the next session. */
- window.__studioAnnotateDone = {
- capturedAt: Date.now(),
- url: window.location.href,
- annotations: sent,
- };
- annotations = [];
- saveAnnotations( annotations );
- isPicking = false;
- hoveredEl = null;
- activePopup = null;
- startCountdownToast( sent.length, 10 );
- render();
- } );
- toolbar.appendChild( doneBtn );
-
- layer.appendChild( toolbar );
- }
-
- function showToast( text ) {
- const existing = root.querySelector( '.toast' );
- if ( existing ) existing.remove();
- const toast = document.createElement( 'div' );
- toast.className = 'toast';
- toast.textContent = text;
- root.appendChild( toast );
- setTimeout( () => toast.remove(), 4000 );
- }
-
- /* Live countdown until the inspector-inject helper closes the browser.
- * The actual close timer lives in the CLI process — we just mirror it
- * visually so the user sees the window isn't disappearing on them. */
- function startCountdownToast( sentCount, totalSeconds ) {
- const existing = root.querySelector( '.toast' );
- if ( existing ) existing.remove();
- const toast = document.createElement( 'div' );
- toast.className = 'toast';
- root.appendChild( toast );
-
- let secondsLeft = totalSeconds;
- const paint = () => {
- toast.textContent =
- 'Sent ' +
- sentCount +
- ' annotation(s) — closing in ' +
- secondsLeft +
- 's';
- };
- paint();
- const interval = setInterval( () => {
- secondsLeft -= 1;
- if ( secondsLeft <= 0 ) {
- clearInterval( interval );
- toast.textContent =
- 'Sent ' + sentCount + ' annotation(s) — closing now…';
- } else {
- paint();
- }
- }, 1000 );
- }
-
- function buildPopup( state ) {
- const popup = document.createElement( 'div' );
- popup.className = 'popup';
-
- /* Position relative to the target element if possible */
- let el = null;
- try {
- el = state.target.selector ? document.querySelector( state.target.selector ) : null;
- } catch {}
- if ( el ) {
- const r = el.getBoundingClientRect();
- const popupWidth = 320;
- const gap = 12;
- const left = Math.min(
- Math.max( 8, r.left + r.width / 2 - popupWidth / 2 ),
- window.innerWidth - popupWidth - 8
- );
- let top = r.bottom + gap;
- if ( top + 200 > window.innerHeight ) {
- top = Math.max( 8, r.top - 200 - gap );
- }
- popup.style.left = left + 'px';
- popup.style.top = top + 'px';
- } else {
- popup.style.left = '50%';
- popup.style.top = '50%';
- popup.style.transform = 'translate(-50%, -50%)';
- }
-
- const target = document.createElement( 'div' );
- target.className = 'target';
- target.textContent =
- state.target.tag +
- ( state.target.nearbyText ? ' — ' + state.target.nearbyText : '' );
- popup.appendChild( target );
-
- const ta = document.createElement( 'textarea' );
- ta.placeholder = 'What should change about this element?';
- ta.value = state.comment || '';
- ta.addEventListener( 'input', () => {
- state.comment = ta.value;
- save.disabled = ! state.comment.trim();
- } );
- popup.appendChild( ta );
- setTimeout( () => ta.focus(), 0 );
-
- const actions = document.createElement( 'div' );
- actions.className = 'actions';
-
- /* state.fromPicker is set only when the popup was opened by clicking
- * a new element — used below by closePopup() to drop the user back
- * into picking mode after Save/Cancel so they can chain
- * annotations without re-clicking the Annotate button. */
- if ( state.id ) {
- const del = document.createElement( 'button' );
- del.className = 'delete';
- del.textContent = 'Delete';
- del.addEventListener( 'click', () => {
- annotations = annotations.filter( ( a ) => a.id !== state.id );
- saveAnnotations( annotations );
- activePopup = null;
- render();
- } );
- actions.appendChild( del );
- }
-
- /* When the popup came from the picker we want to drop the user
- * straight back into picking mode after Save/Cancel, so they can
- * chain annotations without re-clicking "Annotate" each time. When
- * the popup came from clicking an existing marker, leave picking
- * mode off — they were editing one specific item. */
- const closePopup = () => {
- activePopup = null;
- if ( state.fromPicker ) {
- isPicking = true;
- }
- render();
- };
-
- const cancel = document.createElement( 'button' );
- cancel.className = 'cancel';
- cancel.textContent = 'Cancel';
- cancel.addEventListener( 'click', closePopup );
- actions.appendChild( cancel );
-
- const save = document.createElement( 'button' );
- save.className = 'save';
- save.textContent = state.id ? 'Update' : 'Save';
- save.disabled = ! ( state.comment && state.comment.trim() );
- save.addEventListener( 'click', () => {
- const trimmed = ( state.comment || '' ).trim();
- if ( ! trimmed ) return;
- if ( state.id ) {
- annotations = annotations.map( ( a ) =>
- a.id === state.id ? { ...a, comment: trimmed, updatedAt: Date.now() } : a
- );
- } else {
- annotations = annotations.concat( [
- {
- id: uid(),
- comment: trimmed,
- selector: state.target.selector,
- tag: state.target.tag,
- nearbyText: state.target.nearbyText,
- boundingBox: state.target.boundingBox,
- computedStyles: state.target.computedStyles,
- pathname: window.location.pathname,
- url: window.location.href,
- timestamp: Date.now(),
- },
- ] );
- }
- saveAnnotations( annotations );
- closePopup();
- } );
- actions.appendChild( save );
-
- popup.appendChild( actions );
-
- /* Stop propagation so popup interactions don't pick the element underneath */
- popup.addEventListener( 'click', ( e ) => e.stopPropagation() );
- popup.addEventListener( 'mousemove', ( e ) => e.stopPropagation() );
-
- return popup;
- }
-
- function openPopupForAnnotation( ann ) {
- isPicking = false;
- activePopup = {
- id: ann.id,
- comment: ann.comment,
- target: {
- selector: ann.selector,
- tag: ann.tag,
- nearbyText: ann.nearbyText,
- boundingBox: ann.boundingBox,
- computedStyles: ann.computedStyles,
- },
- };
- render();
- }
-
- function openPopupForElement( el ) {
- const r = el.getBoundingClientRect();
- isPicking = false;
- activePopup = {
- /* Tag this popup so closePopup() restores picking mode after
- * Save/Cancel, letting the user chain annotations. */
- fromPicker: true,
- comment: '',
- target: {
- selector: buildSelector( el ),
- tag: el.tagName.toLowerCase(),
- nearbyText: nearbyText( el ),
- boundingBox: { x: r.x, y: r.y, width: r.width, height: r.height },
- computedStyles: pickComputedStyles( el ),
- },
- };
- render();
- }
-
- /* --------------------------------------------------------------------
- * Picking interaction
- *
- * We listen at document level in the capture phase so we can intercept
- * clicks before the page's own handlers fire — no accidental link
- * navigation while annotating.
- * ------------------------------------------------------------------ */
- function isOurElement( el ) {
- return !! ( el && el.closest && el.closest( '#' + HOST_ID ) );
- }
-
- document.addEventListener(
- 'mousemove',
- ( e ) => {
- if ( ! isPicking ) return;
- if ( isOurElement( e.target ) ) {
- hoveredEl = null;
- render();
- return;
- }
- if ( hoveredEl !== e.target ) {
- hoveredEl = e.target;
- render();
- }
- },
- true
- );
-
- document.addEventListener(
- 'click',
- ( e ) => {
- if ( ! isPicking ) return;
- if ( isOurElement( e.target ) ) return;
- e.preventDefault();
- e.stopPropagation();
- openPopupForElement( e.target );
- },
- true
- );
-
- document.addEventListener( 'keydown', ( e ) => {
- if ( e.key === 'Escape' ) {
- if ( activePopup ) {
- activePopup = null;
- render();
- } else if ( isPicking ) {
- isPicking = false;
- hoveredEl = null;
- render();
- }
- }
- } );
-
- /* Re-render on scroll/resize so markers and highlights track the page */
- let scrollRaf = 0;
- const onScrollOrResize = () => {
- if ( scrollRaf ) return;
- scrollRaf = requestAnimationFrame( () => {
- scrollRaf = 0;
- render();
- } );
- };
- window.addEventListener( 'scroll', onScrollOrResize, true );
- window.addEventListener( 'resize', onScrollOrResize );
-
- render();
-} )();
-`;
+export const INSPECTOR_PAGE_SCRIPT = createCliInspectorPageScript();
diff --git a/apps/ui/src/components/site-preview/index.tsx b/apps/ui/src/components/site-preview/index.tsx
index ef5dce1740..caa50e2f89 100644
--- a/apps/ui/src/components/site-preview/index.tsx
+++ b/apps/ui/src/components/site-preview/index.tsx
@@ -1,14 +1,7 @@
import { getSiteOperationLabel } from '@studio/common/lib/site-operation-labels';
import { useQuery } from '@tanstack/react-query';
import { __, sprintf } from '@wordpress/i18n';
-import {
- chevronDown,
- chevronLeft,
- chevronRight,
- Icon,
- moreVertical,
- pencil,
-} from '@wordpress/icons';
+import { chevronDown, chevronLeft, chevronRight, Icon, moreVertical } from '@wordpress/icons';
import { ariaKeyShortcut, displayShortcut, isAppleOS, isKeyboardEvent } from '@wordpress/keycodes';
import { Button, IconButton, Tooltip } from '@wordpress/ui';
import { clsx } from 'clsx';
@@ -28,7 +21,7 @@ import {
import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space';
import { useWindowControlsOverlay } from '@/hooks/use-window-controls-overlay';
import { getSiteUrl } from '@/lib/get-site-url';
-import { playIcon, refreshIcon } from '@/lib/icons';
+import { annotationIcon, playIcon, refreshIcon } from '@/lib/icons';
import {
DATABASE_HOME_PATH,
getPathFromPreviewUrl,
@@ -43,7 +36,7 @@ import {
import {
INSPECTOR_BRIDGE_PREFIX,
INSPECTOR_COMMAND_EVENT,
- INSPECTOR_PAGE_SCRIPT,
+ createInspectorPageScript,
} from './inspector-script';
import styles from './style.module.css';
import type { Annotation } from './types';
@@ -79,12 +72,15 @@ interface SitePreviewProps {
interface InspectorEvent {
type: 'annotations-updated' | 'browser-command' | 'done' | 'state';
+ bridgeToken?: string;
annotations?: Annotation[];
isPicking?: boolean;
annotationCount?: number;
command?: PreviewShortcutCommandType;
}
+const MAX_INSPECTOR_BRIDGE_MESSAGE_LENGTH = 1_100_000;
+
interface InspectorState {
ready: boolean;
isPicking: boolean;
@@ -565,7 +561,7 @@ function PreviewAnnotationControls( {
variant="minimal"
tone="neutral"
size="small"
- icon={ pencil }
+ icon={ annotationIcon }
label={ toggleLabel }
disabled={ disabled }
aria-pressed={ isPicking }
@@ -587,7 +583,7 @@ function PreviewAnnotationControls( {
{ hasPending ? (
{ /* Two commands to offer, so it becomes a split button matching the
- "Open in…" control beside it: the pencil still toggles directly,
+ "Open in…" control beside it: the annotation icon still toggles directly,
the chevron opens the pair. Modal for the same reason as the
overflow menu — the webview swallows outside clicks, so the
backdrop is what dismisses it. */ }
@@ -608,7 +604,7 @@ function PreviewAnnotationControls( {
/>
}
>
-
+
}>
{ toggleLabel }
@@ -1298,6 +1294,7 @@ function WebviewSurface( {
const domReadyRef = useRef( false );
const currentUrlRef = useRef( url );
const storedAnnotationsRef = useRef< Annotation[] >( [] );
+ const inspectorBridgeTokenRef = useRef( globalThis.crypto.randomUUID() );
const lastReloadNonceRef = useRef( reloadNonce );
const progressTimerRef = useRef< ReturnType< typeof setInterval > | null >( null );
const progressResetTimerRef = useRef< ReturnType< typeof setTimeout > | null >( null );
@@ -1419,7 +1416,10 @@ function WebviewSurface( {
const preload =
stored.length > 0 ? `window.__studioInspectorState=${ JSON.stringify( stored ) };` : '';
webview
- .executeJavaScript( preload + INSPECTOR_PAGE_SCRIPT, false )
+ .executeJavaScript(
+ preload + createInspectorPageScript( inspectorBridgeTokenRef.current ),
+ false
+ )
.then( () => {
onInspectorStateRef.current?.( {
ready: true,
@@ -1437,6 +1437,7 @@ function WebviewSurface( {
const consoleEvent = event as WebviewConsoleEvent;
if ( typeof consoleEvent.message !== 'string' ) return;
if ( ! consoleEvent.message.startsWith( INSPECTOR_BRIDGE_PREFIX ) ) return;
+ if ( consoleEvent.message.length > MAX_INSPECTOR_BRIDGE_MESSAGE_LENGTH ) return;
let parsed: InspectorEvent | null = null;
try {
parsed = JSON.parse( consoleEvent.message.slice( INSPECTOR_BRIDGE_PREFIX.length ) );
@@ -1444,6 +1445,7 @@ function WebviewSurface( {
return;
}
if ( ! parsed ) return;
+ if ( parsed.bridgeToken !== inspectorBridgeTokenRef.current ) return;
if ( parsed.type === 'browser-command' ) {
if ( isPreviewShortcutCommand( parsed.command ) ) {
onBrowserCommandRef.current?.( parsed.command );
@@ -1555,7 +1557,10 @@ function WebviewSurface( {
if ( ! ready || ! inspectorCommand ) return;
const webview = ref.current as WebviewTag | null;
if ( ! webview ) return;
- const detail = JSON.stringify( { type: inspectorCommand.type } );
+ const detail = JSON.stringify( {
+ type: inspectorCommand.type,
+ bridgeToken: inspectorBridgeTokenRef.current,
+ } );
webview
.executeJavaScript(
`window.dispatchEvent(new CustomEvent(${ JSON.stringify(
diff --git a/apps/ui/src/components/site-preview/inspector-script.test.ts b/apps/ui/src/components/site-preview/inspector-script.test.ts
index 42fe1060b5..978aade6bc 100644
--- a/apps/ui/src/components/site-preview/inspector-script.test.ts
+++ b/apps/ui/src/components/site-preview/inspector-script.test.ts
@@ -1,132 +1,296 @@
+import { createCliInspectorPageScript } from '@studio/common/ai/inspector-page-script';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
INSPECTOR_BRIDGE_PREFIX,
INSPECTOR_COMMAND_EVENT,
- INSPECTOR_PAGE_SCRIPT,
+ createInspectorPageScript,
} from './inspector-script';
-describe( 'site preview inspector sessions', () => {
+const BRIDGE_TOKEN = 'test-inspector-bridge-token';
+
+describe( 'site preview inspector', () => {
afterEach( () => {
- // Without this the previous inspector's document listeners stay live and
- // answer the next test's commands alongside the instance under test.
( window as Window & { __studioInspectorDispose?: () => void } ).__studioInspectorDispose?.();
vi.restoreAllMocks();
document.body.replaceChildren();
delete ( window as Window & { __studioInspectorState?: unknown[] } ).__studioInspectorState;
+ delete ( window as Window & { __studioAnnotateDone?: unknown } ).__studioAnnotateDone;
+ localStorage.clear();
} );
- it( 'saves several notes without leaving annotation mode', () => {
+ it( 'keeps saved notes when picking is switched off', () => {
const log = vi.spyOn( console, 'log' ).mockImplementation( () => undefined );
- document.body.innerHTML = '
First Second
';
+ ( window as Window & { __studioInspectorState?: unknown[] } ).__studioInspectorState = [
+ {
+ id: 'saved',
+ comment: 'Saved note',
+ tag: 'h1',
+ pathname: window.location.pathname,
+ documentRect: { left: 10, top: 10, width: 100, height: 40 },
+ },
+ ];
+
+ new Function( createInspectorPageScript( BRIDGE_TOKEN ) )();
+ dispatchInspectorCommand( 'toggle-picking' );
+ dispatchInspectorCommand( 'toggle-picking' );
+
+ expect( latestBridgeMessage( log, 'state' ) ).toMatchObject( {
+ isPicking: false,
+ annotationCount: 1,
+ } );
+ expect(
+ ( window as Window & { __studioInspectorState?: unknown[] } ).__studioInspectorState
+ ).toHaveLength( 1 );
+ } );
+
+ it( 'keeps picking active while composing and after saving annotations', () => {
+ const log = vi.spyOn( console, 'log' ).mockImplementation( () => undefined );
+ document.body.innerHTML =
+ '
First target Second target
';
const first = document.querySelector( '#first' ) as HTMLElement;
const second = document.querySelector( '#second' ) as HTMLElement;
- vi.spyOn( first, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 10 ) );
- vi.spyOn( second, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 80 ) );
+ vi.spyOn( first, 'getBoundingClientRect' ).mockReturnValue( {
+ x: 10,
+ y: 0,
+ top: 0,
+ right: 210,
+ bottom: 50,
+ left: 10,
+ width: 200,
+ height: 50,
+ toJSON: () => ( {} ),
+ } );
+ vi.spyOn( second, 'getBoundingClientRect' ).mockReturnValue( {
+ x: 10,
+ y: 100,
+ top: 100,
+ right: 210,
+ bottom: 140,
+ left: 10,
+ width: 200,
+ height: 40,
+ toJSON: () => ( {} ),
+ } );
+
+ new Function( createInspectorPageScript( BRIDGE_TOKEN ) )();
+ const host = document.querySelector( '#__studio-inspector-host' ) as HTMLElement;
+ const root = host.shadowRoot as ShadowRoot;
+ const command = ( type: string ) =>
+ window.dispatchEvent(
+ new CustomEvent( INSPECTOR_COMMAND_EVENT, {
+ detail: { type, bridgeToken: BRIDGE_TOKEN },
+ } )
+ );
+ const stateMessages = () =>
+ log.mock.calls
+ .map( ( [ message ] ) => message )
+ .filter(
+ ( message ): message is string =>
+ typeof message === 'string' && message.startsWith( INSPECTOR_BRIDGE_PREFIX )
+ )
+ .map( ( message ) => JSON.parse( message.slice( INSPECTOR_BRIDGE_PREFIX.length ) ) )
+ .filter( ( message ) => message.type === 'state' );
+
+ window.dispatchEvent(
+ new CustomEvent( INSPECTOR_COMMAND_EVENT, {
+ detail: { type: 'toggle-picking', bridgeToken: 'untrusted-page-token' },
+ } )
+ );
+ first.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true } ) );
+ expect( root.querySelector( '.popup' ) ).toBeNull();
- new Function( INSPECTOR_PAGE_SCRIPT )();
- const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement )
- .shadowRoot as ShadowRoot;
command( 'toggle-picking' );
+ first.dispatchEvent( new MouseEvent( 'mousemove', { bubbles: true } ) );
first.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true } ) );
+ expect( root.querySelector( '.popup' ) ).not.toBeNull();
+ expect( root.querySelector( '.highlight' ) ).not.toBeNull();
+ expect( root.querySelectorAll( '.scrim' ) ).toHaveLength( 4 );
+ expect( document.documentElement ).toHaveStyle( { overflow: 'hidden' } );
+ const style = root.querySelector( 'style' )?.textContent ?? '';
+ expect( style ).toContain( 'border-radius: 8px 8px 20px 8px' );
+ expect( style ).toContain( 'backdrop-filter: blur(20px)' );
+ expect( style ).toContain( 'min-height: 24px' );
+ expect( style ).toContain( '0 0 0 1px rgba(0,0,0,0.9)' );
+ expect( style ).toContain( 'background: rgba(0,0,0,0.52)' );
+
const firstTextarea = root.querySelector( 'textarea' ) as HTMLTextAreaElement;
- firstTextarea.value = 'First note';
+ firstTextarea.value = 'First line';
+ firstTextarea.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) );
+ firstTextarea.setSelectionRange( firstTextarea.value.length, firstTextarea.value.length );
+ firstTextarea.dispatchEvent(
+ new KeyboardEvent( 'keydown', { key: 'Enter', metaKey: true, bubbles: true } )
+ );
+ expect( firstTextarea.value ).toBe( 'First line\n' );
+ firstTextarea.value += 'Second line';
firstTextarea.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) );
firstTextarea.dispatchEvent(
new KeyboardEvent( 'keydown', { key: 'Enter', bubbles: true, cancelable: true } )
);
expect( root.querySelector( '.popup' ) ).toBeNull();
+ expect( root.querySelectorAll( '.scrim' ) ).toHaveLength( 0 );
+ expect( document.documentElement ).toHaveStyle( { overflow: '' } );
expect( root.querySelectorAll( '.marker' ) ).toHaveLength( 1 );
- expect( latestState( log ) ).toMatchObject( { isPicking: true, annotationCount: 1 } );
+ expect( root.querySelector( '.marker' ) ).toHaveStyle( { top: '12px' } );
+ expect( root.querySelectorAll( '.annotation-highlight' ) ).toHaveLength( 1 );
+ expect( stateMessages().at( -1 ) ).toMatchObject( {
+ isPicking: true,
+ annotationCount: 1,
+ } );
second.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true } ) );
+ expect( root.querySelector( '.popup' ) ).not.toBeNull();
+ expect( root.querySelector( '.highlight' ) ).not.toBeNull();
const secondTextarea = root.querySelector( 'textarea' ) as HTMLTextAreaElement;
- secondTextarea.value = 'First line';
- secondTextarea.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) );
- secondTextarea.setSelectionRange( secondTextarea.value.length, secondTextarea.value.length );
- secondTextarea.dispatchEvent(
- new KeyboardEvent( 'keydown', {
- key: 'Enter',
- metaKey: true,
- bubbles: true,
- cancelable: true,
- } )
- );
- expect( secondTextarea.value ).toBe( 'First line\n' );
- secondTextarea.value += 'Second line';
+ secondTextarea.value = 'Second note';
secondTextarea.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) );
+ expect( root.querySelector( '.send-to-chat' ) ).not.toBeNull();
command( 'submit' );
- const done = bridgeMessages( log ).find( ( message ) => message.type === 'done' );
- expect( done?.annotations ).toEqual(
+ expect( root.querySelector( '.popup' ) ).toBeNull();
+ expect( root.querySelectorAll( '.marker' ) ).toHaveLength( 0 );
+ expect( root.querySelectorAll( '.annotation-highlight' ) ).toHaveLength( 0 );
+ expect( stateMessages().at( -1 ) ).toMatchObject( {
+ isPicking: false,
+ annotationCount: 0,
+ } );
+ const doneMessage = log.mock.calls
+ .map( ( [ message ] ) => message )
+ .filter(
+ ( message ): message is string =>
+ typeof message === 'string' && message.startsWith( INSPECTOR_BRIDGE_PREFIX )
+ )
+ .map( ( message ) => JSON.parse( message.slice( INSPECTOR_BRIDGE_PREFIX.length ) ) )
+ .find( ( message ) => message.type === 'done' );
+ expect( doneMessage.annotations ).toEqual(
expect.arrayContaining( [
- expect.objectContaining( { comment: 'First note' } ),
expect.objectContaining( { comment: 'First line\nSecond line' } ),
+ expect.objectContaining( { comment: 'Second note' } ),
] )
);
- expect( latestState( log ) ).toMatchObject( { isPicking: false, annotationCount: 0 } );
} );
- it( 'keeps saved notes when annotation mode is switched off', () => {
- const log = vi.spyOn( console, 'log' ).mockImplementation( () => undefined );
- seedSavedNote();
+ it( 'cycles through overlapping elements at the selected point', () => {
+ vi.spyOn( console, 'log' ).mockImplementation( () => undefined );
+ document.body.innerHTML =
+ '
Front container
';
+ const front = document.querySelector( '#front' ) as HTMLElement;
+ const behind = document.querySelector( '#behind' ) as HTMLElement;
+ const frontRect = {
+ x: 10,
+ y: 10,
+ top: 10,
+ right: 210,
+ bottom: 210,
+ left: 10,
+ width: 200,
+ height: 200,
+ toJSON: () => ( {} ),
+ };
+ const behindRect = {
+ ...frontRect,
+ x: 40,
+ y: 40,
+ top: 40,
+ right: 140,
+ bottom: 140,
+ left: 40,
+ width: 100,
+ height: 100,
+ };
+ vi.spyOn( front, 'getBoundingClientRect' ).mockReturnValue( frontRect );
+ vi.spyOn( behind, 'getBoundingClientRect' ).mockReturnValue( behindRect );
- new Function( INSPECTOR_PAGE_SCRIPT )();
- command( 'toggle-picking' );
- document.dispatchEvent(
- new KeyboardEvent( 'keydown', { key: 'Escape', bubbles: true, cancelable: true } )
+ new Function( createInspectorPageScript( BRIDGE_TOKEN ) )();
+ const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement )
+ .shadowRoot as ShadowRoot;
+ window.dispatchEvent(
+ new CustomEvent( INSPECTOR_COMMAND_EVENT, {
+ detail: { type: 'toggle-picking', bridgeToken: BRIDGE_TOKEN },
+ } )
+ );
+ front.dispatchEvent(
+ new MouseEvent( 'click', { bubbles: true, cancelable: true, clientX: 80, clientY: 80 } )
);
- expect( latestState( log ) ).toMatchObject( { isPicking: false, annotationCount: 1 } );
- expect(
- ( window as Window & { __studioInspectorState?: unknown[] } ).__studioInspectorState
- ).toHaveLength( 1 );
- } );
-
- it( 'reopens an existing note without turning picking back on', () => {
- const log = vi.spyOn( console, 'log' ).mockImplementation( () => undefined );
- seedSavedNote();
+ expect( root.querySelector( '.layer-count' )?.textContent ).toBe( '1/2' );
+ const initialPopup = root.querySelector( '.popup' ) as HTMLElement;
+ const initialPosition = {
+ left: initialPopup.style.left,
+ top: initialPopup.style.top,
+ };
+ (
+ root.querySelector( '[aria-label="Select next element at this point"]' ) as HTMLElement
+ ).click();
+ expect( root.querySelector( '.target' )?.textContent ).toBe( 'img#behind' );
+ expect( root.querySelector( '.layer-count' )?.textContent ).toBe( '2/2' );
+ expect( root.querySelector( '.popup' ) ).toHaveStyle( initialPosition );
- new Function( INSPECTOR_PAGE_SCRIPT )();
- const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement )
- .shadowRoot as ShadowRoot;
- const marker = root.querySelector( '.marker' ) as HTMLElement;
- marker.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true } ) );
+ const handle = root.querySelector( '.target-row' ) as HTMLElement;
+ const popup = root.querySelector( '.popup' ) as HTMLElement;
+ const startingLeft = Number.parseFloat( popup.style.left );
+ handle.dispatchEvent(
+ new MouseEvent( 'mousedown', { bubbles: true, button: 0, clientX: 100, clientY: 100 } )
+ );
+ window.dispatchEvent(
+ new MouseEvent( 'mousemove', { bubbles: true, clientX: 130, clientY: 120 } )
+ );
+ window.dispatchEvent( new MouseEvent( 'mouseup', { bubbles: true } ) );
+ expect( Number.parseFloat( popup.style.left ) ).toBe( startingLeft + 30 );
- expect( root.querySelector( '.popup' ) ).toBeInTheDocument();
- expect( latestState( log ) ).toMatchObject( { isPicking: false, annotationCount: 1 } );
+ dispatchInspectorCommand( 'toggle-picking' );
} );
- it( 'submits saved notes when an empty draft popup is open', () => {
+ it( 'submits saved notes when an empty draft is open', () => {
const log = vi.spyOn( console, 'log' ).mockImplementation( () => undefined );
document.body.innerHTML = '
Draft ';
- const draft = document.querySelector( '#draft' ) as HTMLElement;
- vi.spyOn( draft, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 10 ) );
- seedSavedNote();
+ const target = document.querySelector( '#draft' ) as HTMLElement;
+ vi.spyOn( target, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 10 ) );
+ ( window as Window & { __studioInspectorState?: unknown[] } ).__studioInspectorState = [
+ {
+ id: 'saved',
+ comment: 'Saved note',
+ pathname: window.location.pathname,
+ documentRect: { left: 10, top: 10, width: 100, height: 40 },
+ },
+ ];
- new Function( INSPECTOR_PAGE_SCRIPT )();
- command( 'toggle-picking' );
- draft.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true } ) );
- command( 'submit' );
+ new Function( createInspectorPageScript( BRIDGE_TOKEN ) )();
+ dispatchInspectorCommand( 'toggle-picking' );
+ target.dispatchEvent(
+ new MouseEvent( 'click', {
+ bubbles: true,
+ cancelable: true,
+ clientX: 20,
+ clientY: 20,
+ } )
+ );
+ dispatchInspectorCommand( 'submit' );
- const done = bridgeMessages( log ).find( ( message ) => message.type === 'done' );
- expect( done?.annotations ).toEqual( [ expect.objectContaining( { comment: 'Saved note' } ) ] );
- expect( latestState( log ) ).toMatchObject( { isPicking: false, annotationCount: 0 } );
+ expect( latestBridgeMessage( log, 'done' )?.annotations ).toEqual( [
+ expect.objectContaining( { comment: 'Saved note' } ),
+ ] );
} );
- it( 'does not save while text input is being composed', () => {
+ it( 'does not save a note while text input is being composed', () => {
vi.spyOn( console, 'log' ).mockImplementation( () => undefined );
document.body.innerHTML = '
Composing ';
const target = document.querySelector( '#composing' ) as HTMLElement;
vi.spyOn( target, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 10 ) );
- new Function( INSPECTOR_PAGE_SCRIPT )();
+ new Function( createInspectorPageScript( BRIDGE_TOKEN ) )();
+ dispatchInspectorCommand( 'toggle-picking' );
+ target.dispatchEvent(
+ new MouseEvent( 'click', {
+ bubbles: true,
+ cancelable: true,
+ clientX: 20,
+ clientY: 20,
+ } )
+ );
const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement )
.shadowRoot as ShadowRoot;
- command( 'toggle-picking' );
- target.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true } ) );
-
const textarea = root.querySelector( 'textarea' ) as HTMLTextAreaElement;
textarea.value = '入力中';
textarea.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) );
@@ -142,22 +306,135 @@ describe( 'site preview inspector sessions', () => {
expect( root.querySelector( '.popup' ) ).toBeInTheDocument();
expect( root.querySelectorAll( '.marker' ) ).toHaveLength( 0 );
} );
+
+ it( 'uses the shared inspector UI and persistent batch in the CLI browser', () => {
+ vi.spyOn( console, 'log' ).mockImplementation( () => undefined );
+ localStorage.setItem(
+ 'studio-inspector-annotations-v1',
+ JSON.stringify( [
+ {
+ id: 'persisted-note',
+ comment: 'Persisted note',
+ pathname: window.location.pathname,
+ documentRect: { left: 10, top: 10, width: 100, height: 40 },
+ },
+ ] )
+ );
+
+ new Function( createCliInspectorPageScript() )();
+ const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement )
+ .shadowRoot as ShadowRoot;
+
+ expect( root.querySelector( '.toolbar' ) ).not.toBeNull();
+ expect( root.querySelector( '.popup' ) ).toBeNull();
+ expect( root.querySelectorAll( '.marker' ) ).toHaveLength( 1 );
+ expect( root.querySelectorAll( '.annotation-highlight' ) ).toHaveLength( 1 );
+ expect( root.querySelector( '.submit' ) ).toHaveTextContent( 'Send to agent' );
+ } );
+
+ it( 'keeps CLI notes when annotation mode is switched off', () => {
+ vi.spyOn( console, 'log' ).mockImplementation( () => undefined );
+ localStorage.setItem(
+ 'studio-inspector-annotations-v1',
+ JSON.stringify( [
+ {
+ id: 'saved-cli-note',
+ comment: 'Saved CLI note',
+ pathname: window.location.pathname,
+ documentRect: { left: 10, top: 10, width: 100, height: 40 },
+ },
+ ] )
+ );
+
+ new Function( createCliInspectorPageScript() )();
+ const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement )
+ .shadowRoot as ShadowRoot;
+ ( root.querySelector( '.toolbar button:not(.submit)' ) as HTMLButtonElement ).click();
+ expect( root.querySelector( '.toolbar button:not(.submit)' ) ).toHaveTextContent(
+ 'Stop annotating'
+ );
+ ( root.querySelector( '.toolbar button:not(.submit)' ) as HTMLButtonElement ).click();
+ expect( root.querySelector( '.toolbar button:not(.submit)' ) ).toHaveTextContent( 'Annotate' );
+ expect(
+ JSON.parse( localStorage.getItem( 'studio-inspector-annotations-v1' ) ?? '[]' )
+ ).toHaveLength( 1 );
+ } );
+
+ it( 'saves and submits several notes from the CLI browser', () => {
+ vi.spyOn( console, 'log' ).mockImplementation( () => undefined );
+ document.body.innerHTML = '
First Second
';
+ const first = document.querySelector( '#first-cli' ) as HTMLElement;
+ const second = document.querySelector( '#second-cli' ) as HTMLElement;
+ vi.spyOn( first, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 10 ) );
+ vi.spyOn( second, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 80 ) );
+
+ new Function( createCliInspectorPageScript() )();
+ const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement )
+ .shadowRoot as ShadowRoot;
+ ( root.querySelector( '.toolbar button:not(.submit)' ) as HTMLButtonElement ).click();
+
+ for ( const [ target, comment ] of [
+ [ first, 'First CLI note' ],
+ [ second, 'Second CLI note' ],
+ ] as const ) {
+ target.dispatchEvent(
+ new MouseEvent( 'click', {
+ bubbles: true,
+ cancelable: true,
+ clientX: 20,
+ clientY: target === first ? 20 : 90,
+ } )
+ );
+ const textarea = root.querySelector( 'textarea' ) as HTMLTextAreaElement;
+ textarea.value = comment;
+ textarea.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) );
+ textarea.dispatchEvent(
+ new KeyboardEvent( 'keydown', {
+ key: 'Enter',
+ bubbles: true,
+ cancelable: true,
+ } )
+ );
+ }
+
+ ( root.querySelector( '.submit' ) as HTMLButtonElement ).click();
+
+ const result = (
+ window as Window & {
+ __studioAnnotateDone?: { annotations: Array< { comment: string } > };
+ }
+ ).__studioAnnotateDone;
+ expect( result?.annotations ).toEqual( [
+ expect.objectContaining( { comment: 'First CLI note' } ),
+ expect.objectContaining( { comment: 'Second CLI note' } ),
+ ] );
+ expect(
+ JSON.parse( localStorage.getItem( 'studio-inspector-annotations-v1' ) ?? '[]' )
+ ).toEqual( [] );
+ } );
} );
-function seedSavedNote() {
- ( window as Window & { __studioInspectorState?: unknown[] } ).__studioInspectorState = [
- {
- id: 'saved',
- comment: 'Saved note',
- tag: 'h1',
- path: window.location.pathname + window.location.search,
- documentRect: { left: 10, top: 10, width: 100, height: 40 },
- },
- ];
+function dispatchInspectorCommand( type: string ) {
+ window.dispatchEvent(
+ new CustomEvent( INSPECTOR_COMMAND_EVENT, {
+ detail: { type, bridgeToken: BRIDGE_TOKEN },
+ } )
+ );
}
-function command( type: string ) {
- window.dispatchEvent( new CustomEvent( INSPECTOR_COMMAND_EVENT, { detail: { type } } ) );
+function latestBridgeMessage(
+ log: { mock: { calls: unknown[][] } },
+ type: string
+): { type: string; annotations?: unknown[]; isPicking?: boolean } | undefined {
+ return log.mock.calls
+ .map( ( call ) => call[ 0 ] )
+ .filter(
+ ( message ): message is string =>
+ typeof message === 'string' && message.startsWith( INSPECTOR_BRIDGE_PREFIX )
+ )
+ .map( ( message ) => JSON.parse( message.slice( INSPECTOR_BRIDGE_PREFIX.length ) ) )
+ .filter( ( message ) => message.type === type )
+ .at( -1 );
}
function rect( left: number, top: number ): DOMRect {
@@ -173,23 +450,3 @@ function rect( left: number, top: number ): DOMRect {
toJSON: () => ( {} ),
} as DOMRect;
}
-
-interface ConsoleLogSpy {
- mock: { calls: unknown[][] };
-}
-
-function bridgeMessages( log: ConsoleLogSpy ): Array< Record< string, unknown > > {
- return log.mock.calls
- .map( ( call ) => call[ 0 ] )
- .filter(
- ( message ): message is string =>
- typeof message === 'string' && message.startsWith( INSPECTOR_BRIDGE_PREFIX )
- )
- .map( ( message ) => JSON.parse( message.slice( INSPECTOR_BRIDGE_PREFIX.length ) ) );
-}
-
-function latestState( log: ConsoleLogSpy ) {
- return bridgeMessages( log )
- .filter( ( message ) => message.type === 'state' )
- .at( -1 );
-}
diff --git a/apps/ui/src/components/site-preview/inspector-script.ts b/apps/ui/src/components/site-preview/inspector-script.ts
index c607537b81..2abdb2de05 100644
--- a/apps/ui/src/components/site-preview/inspector-script.ts
+++ b/apps/ui/src/components/site-preview/inspector-script.ts
@@ -1,681 +1,5 @@
-/**
- * Annotation inspector injected into the site-preview `
` via
- * `webview.executeJavaScript()`.
- *
- * Runs in the cross-origin guest page so it uses vanilla DOM in a Shadow DOM
- * root — React isn't loaded there. Communicates with the host renderer via a
- * structured `console.log` line that the host receives through the webview's
- * `console-message` event:
- * guest -> host: `__studio-inspector__:{ "type": "done", ... }`
- *
- * The same bridge also reports picking/annotation-count state changes and
- * forwards browser keyboard shortcuts (reload, back, forward) pressed while
- * focus is inside the guest page, so the host toolbar can handle them:
- * guest -> host: `__studio-inspector__:{ "type": "state", ... }`
- * guest -> host: `__studio-inspector__:{ "type": "browser-command", ... }`
- *
- * The annotation controls live in the host toolbar (not in the page), and
- * drive the inspector by dispatching `INSPECTOR_COMMAND_EVENT` custom events
- * on the guest `window` via `webview.executeJavaScript()`:
- * host -> guest: `{ "type": "toggle-picking" | "submit" | "report-state" }`
- *
- * Layout strategy: markers and the picking highlight use `position: absolute`
- * anchored at *document* coordinates (viewport rect + scroll offset). They
- * scroll with the page automatically — no scroll listener, no rAF loop. The
- * popup uses `position: fixed` so it stays in the viewport.
- */
-
-export const INSPECTOR_BRIDGE_PREFIX = '__studio-inspector__:';
-export const INSPECTOR_COMMAND_EVENT = '__studio-inspector-command';
-
-export const INSPECTOR_PAGE_SCRIPT =
- String.raw`
-( () => {
- if ( window.__studioInspectorMounted ) {
- window.dispatchEvent(
- new CustomEvent( '` +
- INSPECTOR_COMMAND_EVENT +
- String.raw`', { detail: { type: 'report-state' } } )
- );
- return;
- }
- /* A stale instance can outlive its host element (the mount flag is per
- * document, its listeners are not), so retire it before taking over. */
- if ( typeof window.__studioInspectorDispose === 'function' ) {
- window.__studioInspectorDispose();
- }
- window.__studioInspectorMounted = true;
- const teardown = new AbortController();
-
- const BRIDGE_PREFIX = '` +
- INSPECTOR_BRIDGE_PREFIX +
- String.raw`';
- const COMMAND_EVENT = '` +
- INSPECTOR_COMMAND_EVENT +
- String.raw`';
- const HOST_ID = '__studio-inspector-host';
-
- function send( payload ) {
- try {
- console.log( BRIDGE_PREFIX + JSON.stringify( payload ) );
- } catch ( err ) {
- /* JSON.stringify can fail on cycles; the host treats missing
- * messages as no-ops, so we swallow rather than crash the page. */
- }
- }
-
- function isApplePlatform() {
- return /mac|iphone|ipad|ipod/i.test( navigator.platform || navigator.userAgent || '' );
- }
-
- function isTextEntryTarget( el ) {
- if ( ! el || el.nodeType !== 1 ) return false;
- if ( el.isContentEditable ) return true;
- const tag = el.tagName.toLowerCase();
- return tag === 'input' || tag === 'textarea' || tag === 'select';
- }
-
- function getBrowserShortcutCommand( event ) {
- if ( event.defaultPrevented || event.repeat ) return null;
- const apple = isApplePlatform();
- if ( event.key === 'ArrowLeft' || event.key === 'ArrowRight' ) {
- /* Layout-independent back/forward aliases: the bracket chords need
- * Option/AltGr on many European layouts. Skipped while editing text
- * to keep native caret movement. */
- const hasNavModifier = apple
- ? event.metaKey && ! event.ctrlKey && ! event.altKey
- : event.altKey && ! event.ctrlKey && ! event.metaKey;
- if ( ! hasNavModifier || event.shiftKey || isTextEntryTarget( event.target ) ) return null;
- return event.key === 'ArrowLeft' ? 'back' : 'forward';
- }
- if ( event.altKey ) return null;
- const hasPrimaryModifier = apple ? event.metaKey : event.ctrlKey;
- if ( ! hasPrimaryModifier ) return null;
- const key = event.key.toLowerCase();
- /* The host owns full preview, but in that mode this page covers most of
- * the window — so the chord is caught here and forwarded back. */
- if ( event.shiftKey ) {
- if ( key === 'f' ) return 'full-preview';
- return key === 'r' ? 'reload' : null;
- }
- if ( key === 'r' ) return 'reload';
- if ( key === '[' ) return 'back';
- if ( key === ']' ) return 'forward';
- return null;
- }
-
- function buildSelector( el ) {
- if ( ! el || el.nodeType !== 1 ) return '';
- if ( el.id ) return '#' + CSS.escape( el.id );
- const parts = [];
- let node = el;
- while ( node && node.nodeType === 1 && node !== document.documentElement ) {
- let part = node.tagName.toLowerCase();
- if ( node.classList && node.classList.length ) {
- const classes = Array.from( node.classList )
- .filter( ( c ) => ! c.startsWith( '__studio-' ) )
- .slice( 0, 3 )
- .map( ( c ) => '.' + CSS.escape( c ) )
- .join( '' );
- part += classes;
- }
- const parent = node.parentElement;
- if ( parent ) {
- const sameTagSiblings = Array.from( parent.children ).filter(
- ( c ) => c.tagName === node.tagName
- );
- if ( sameTagSiblings.length > 1 ) {
- part += ':nth-of-type(' + ( sameTagSiblings.indexOf( node ) + 1 ) + ')';
- }
- }
- parts.unshift( part );
- node = parent;
- if ( parts.length >= 6 ) break;
- }
- return parts.join( ' > ' );
- }
-
- function nearbyText( el ) {
- const text = ( el.innerText || el.textContent || '' )
- .replace( /\s+/g, ' ' )
- .trim();
- return text.length > 200 ? text.slice( 0, 200 ) + '…' : text;
- }
-
- function pickComputedStyles( el ) {
- const cs = window.getComputedStyle( el );
- const keys = [
- 'color', 'background-color', 'font-size', 'font-weight',
- 'font-family', 'line-height', 'padding', 'margin',
- 'border', 'display', 'width', 'height',
- ];
- const out = {};
- for ( const k of keys ) {
- out[ k ] = cs.getPropertyValue( k );
- }
- return out;
- }
-
- function uid() {
- return 'a_' + Math.random().toString( 36 ).slice( 2, 10 );
- }
-
- function documentRect( el ) {
- const r = el.getBoundingClientRect();
- return {
- left: r.left + window.scrollX,
- top: r.top + window.scrollY,
- width: r.width,
- height: r.height,
- };
- }
-
- /* ------------------------------------------------------------------
- * Shadow DOM host. The host is \`position: absolute; top: 0; left: 0\`
- * with zero size — this anchors all absolutely-positioned descendants
- * at the document origin so their coordinates are document-relative
- * (and therefore scroll naturally with the page).
- * ---------------------------------------------------------------- */
- const oldHost = document.getElementById( HOST_ID );
- if ( oldHost ) oldHost.remove();
- const host = document.createElement( 'div' );
- host.id = HOST_ID;
- host.style.cssText =
- 'all: initial; position: absolute; top: 0; left: 0; width: 0; height: 0; pointer-events: none; z-index: 2147483647;';
- document.body.appendChild( host );
- const root = host.attachShadow( { mode: 'open' } );
-
- window.__studioInspectorDispose = () => {
- teardown.abort();
- host.remove();
- delete window.__studioInspectorMounted;
- delete window.__studioInspectorDispose;
- };
-
- const style = document.createElement( 'style' );
- style.textContent = ` +
- '`' +
- String.raw`
- :host { all: initial; }
- * { box-sizing: border-box; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
- .highlight {
- position: absolute; pointer-events: none;
- border: 2px solid #2563eb;
- background: rgba(37,99,235,0.1);
- border-radius: 2px;
- }
- .marker {
- position: absolute; pointer-events: auto; cursor: pointer;
- width: 22px; height: 22px;
- background: #2563eb; color: #fff;
- border: 2px solid #fff;
- border-radius: 50%;
- box-shadow: 0 2px 6px rgba(0,0,0,0.3);
- font: 700 11px/1 inherit;
- display: inline-flex; align-items: center; justify-content: center;
- transform: translate(-50%, -50%);
- }
- .popup {
- position: fixed; width: 320px;
- background: #1a1a1a; color: #fff;
- border-radius: 12px;
- box-shadow: 0 4px 24px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.08);
- padding: 12px;
- pointer-events: auto;
- display: flex; flex-direction: column; gap: 8px;
- }
- .popup .target {
- font-size: 11px; color: rgba(255,255,255,0.5);
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
- }
- .popup textarea {
- width: 100%; min-height: 72px; resize: vertical;
- background: rgba(255,255,255,0.05); color: #fff;
- border: 1px solid rgba(255,255,255,0.15); border-radius: 8px;
- padding: 8px; font: 13px/1.4 inherit; outline: none;
- }
- .popup textarea:focus { border-color: #2563eb; }
- .popup .actions { display: flex; justify-content: flex-end; gap: 4px; }
- /* Sized so Delete/Cancel/Update/Send to chat all fit one row of the
- 320px popup; nowrap keeps a tight fit from wrapping a label onto a
- second line instead of the row overflowing visibly. */
- .popup button {
- padding: 6px 8px; border-radius: 16px; border: none;
- font: 600 11px/1 inherit; white-space: nowrap; cursor: pointer;
- }
- .popup .delete { background: transparent; color: rgba(255,255,255,0.5); margin-right: auto; }
- .popup .delete:hover { color: #ef4444; }
- .popup .cancel { background: transparent; color: rgba(255,255,255,0.7); }
- .popup .cancel:hover { background: rgba(255,255,255,0.08); }
- .popup .save { background: #fff; color: #1a1a1a; }
- .popup .save[disabled] { opacity: 0.4; cursor: default; }
- .popup .submit { background: rgba(255,255,255,0.12); color: #fff; }
- .popup .submit[disabled] { opacity: 0.4; cursor: default; }
- ` +
- '`' +
- String.raw`;
- root.appendChild( style );
-
- /* ------------------------------------------------------------------
- * State + DOM
- * ---------------------------------------------------------------- */
- let isPicking = false;
- let hoveredEl = null;
- let activePopup = null; /* { id?, target, comment, fromPicker? } */
- let annotations = Array.isArray( window.__studioInspectorState )
- ? window.__studioInspectorState.slice()
- : [];
-
- const markerNodes = new Map(); /* id -> marker element */
- let highlightNode = null;
- let popupNode = null;
-
- function persistAnnotations() {
- window.__studioInspectorState = annotations;
- send( { type: 'annotations-updated', annotations: annotations.slice() } );
- }
-
- function sendState() {
- send( {
- type: 'state',
- isPicking,
- annotationCount: annotations.length,
- } );
- }
-
- function syncMarkers() {
- const currentPath = window.location.pathname + window.location.search;
- const ids = new Set( annotations.map( ( a ) => a.id ) );
- for ( const [ id, marker ] of markerNodes ) {
- if ( ! ids.has( id ) ) {
- marker.remove();
- markerNodes.delete( id );
- }
- }
- annotations.forEach( ( ann, idx ) => {
- /* Only render markers for annotations made on the current page.
- * Annotations from other pages are preserved for submission but
- * their document-coordinate positions would be meaningless here. */
- const onCurrentPage = ! ann.path || ann.path === currentPath;
- let marker = markerNodes.get( ann.id );
- if ( ! onCurrentPage ) {
- if ( marker ) {
- marker.remove();
- markerNodes.delete( ann.id );
- }
- return;
- }
- if ( ! marker ) {
- marker = document.createElement( 'div' );
- marker.className = 'marker';
- marker.addEventListener( 'click', ( e ) => {
- e.stopPropagation();
- const current = annotations.find( ( a ) => a.id === ann.id );
- if ( current ) openPopupForAnnotation( current );
- } );
- /* Use the document-coord rect captured at save time so the
- * marker's position is fixed in document space and scrolls
- * with the page. No per-scroll repositioning needed. */
- const box = ann.documentRect || ann.boundingBox || { left: 0, top: 0, width: 0, height: 0 };
- marker.style.left = ( box.left + box.width ) + 'px';
- marker.style.top = box.top + 'px';
- root.appendChild( marker );
- markerNodes.set( ann.id, marker );
- }
- marker.textContent = String( idx + 1 );
- marker.title = ann.comment;
- } );
- }
-
- function showHighlight( el ) {
- if ( highlightNode ) {
- highlightNode.remove();
- highlightNode = null;
- }
- if ( ! el || ! isPicking ) return;
- const r = documentRect( el );
- highlightNode = document.createElement( 'div' );
- highlightNode.className = 'highlight';
- highlightNode.style.left = r.left + 'px';
- highlightNode.style.top = r.top + 'px';
- highlightNode.style.width = r.width + 'px';
- highlightNode.style.height = r.height + 'px';
- root.appendChild( highlightNode );
- }
-
- function showPopup() {
- if ( popupNode ) {
- popupNode.remove();
- popupNode = null;
- }
- if ( activePopup ) {
- popupNode = buildPopup( activePopup );
- root.appendChild( popupNode );
- }
- }
-
- function render() {
- syncMarkers();
- showHighlight( hoveredEl );
- showPopup();
- sendState();
- }
-
- function togglePicking() {
- isPicking = ! isPicking;
- if ( ! isPicking ) hoveredEl = null;
- activePopup = null;
- persistAnnotations();
- render();
- }
-
- function commitActivePopup() {
- if ( ! activePopup ) return true;
- const state = activePopup;
- const trimmed = ( state.comment || '' ).trim();
- if ( ! trimmed ) return false;
- if ( state.id ) {
- annotations = annotations.map( ( annotation ) =>
- annotation.id === state.id
- ? Object.assign( {}, annotation, { comment: trimmed, updatedAt: Date.now() } )
- : annotation
- );
- } else {
- annotations = annotations.concat( [
- {
- id: uid(),
- comment: trimmed,
- selector: state.target.selector,
- tag: state.target.tag,
- nearbyText: state.target.nearbyText,
- boundingBox: state.target.boundingBox,
- documentRect: state.target.documentRect,
- computedStyles: state.target.computedStyles,
- path: window.location.pathname + window.location.search,
- url: window.location.href,
- timestamp: Date.now(),
- },
- ] );
- }
- persistAnnotations();
- return true;
- }
-
- function hasDraft() {
- return !! ( activePopup && ( activePopup.comment || '' ).trim() );
- }
-
- function submitAnnotations() {
- if ( annotations.length === 0 && ! hasDraft() ) {
- sendState();
- return;
- }
- /* An untouched popup is a draft the user never filled in — drop it
- * rather than blocking the notes they did save. */
- if ( hasDraft() ) {
- commitActivePopup();
- } else {
- activePopup = null;
- }
- send( { type: 'done', annotations: annotations.slice() } );
- annotations = [];
- activePopup = null;
- isPicking = false;
- hoveredEl = null;
- persistAnnotations();
- render();
- }
-
- window.addEventListener(
- COMMAND_EVENT,
- ( event ) => {
- const command = event.detail || {};
- if ( command.type === 'toggle-picking' ) {
- togglePicking();
- return;
- }
- if ( command.type === 'submit' ) {
- submitAnnotations();
- return;
- }
- if ( command.type === 'report-state' ) {
- sendState();
- }
- },
- { signal: teardown.signal }
- );
-
- function buildPopup( state ) {
- const popup = document.createElement( 'div' );
- popup.className = 'popup';
-
- /* Position the popup near the element using viewport coords (it's
- * \`position: fixed\` so it stays in the viewport). Falls back to
- * centre if the element can't be located. */
- let el = null;
- try {
- el = state.target.selector ? document.querySelector( state.target.selector ) : null;
- } catch {}
- if ( el ) {
- const r = el.getBoundingClientRect();
- const popupWidth = 320;
- const gap = 12;
- const left = Math.min(
- Math.max( 8, r.left + r.width / 2 - popupWidth / 2 ),
- window.innerWidth - popupWidth - 8
- );
- let top = r.bottom + gap;
- if ( top + 200 > window.innerHeight ) {
- top = Math.max( 8, r.top - 200 - gap );
- }
- popup.style.left = left + 'px';
- popup.style.top = top + 'px';
- } else {
- popup.style.left = '50%';
- popup.style.top = '50%';
- popup.style.transform = 'translate(-50%, -50%)';
- }
-
- const target = document.createElement( 'div' );
- target.className = 'target';
- target.textContent =
- state.target.tag +
- ( state.target.nearbyText ? ' — ' + state.target.nearbyText : '' );
- popup.appendChild( target );
-
- state.comment = state.comment || '';
- const ta = document.createElement( 'textarea' );
- ta.placeholder = 'What should change about this element?';
- ta.value = state.comment;
- popup.appendChild( ta );
- setTimeout( () => ta.focus(), 0 );
-
- const actions = document.createElement( 'div' );
- actions.className = 'actions';
-
- if ( state.id ) {
- const del = document.createElement( 'button' );
- del.className = 'delete';
- del.textContent = 'Delete';
- del.addEventListener( 'click', () => {
- annotations = annotations.filter( ( a ) => a.id !== state.id );
- activePopup = null;
- persistAnnotations();
- render();
- } );
- actions.appendChild( del );
- }
-
- const closePopup = () => {
- activePopup = null;
- hoveredEl = null;
- persistAnnotations();
- render();
- };
-
- const cancel = document.createElement( 'button' );
- cancel.className = 'cancel';
- cancel.textContent = 'Cancel';
- cancel.addEventListener( 'click', closePopup );
- actions.appendChild( cancel );
-
- const save = document.createElement( 'button' );
- save.className = 'save';
- save.textContent = state.id ? 'Update' : 'Save';
- save.addEventListener( 'click', () => {
- if ( ! commitActivePopup() ) return;
- closePopup();
- } );
- actions.appendChild( save );
-
- const submit = document.createElement( 'button' );
- submit.className = 'submit';
- submit.textContent = 'Send to chat';
- submit.addEventListener( 'click', submitAnnotations );
- actions.appendChild( submit );
-
- function syncActions() {
- save.disabled = ! state.comment.trim();
- /* Sending stays available while notes are already saved, even if
- * this popup is an untouched draft — submit discards it. */
- submit.disabled = save.disabled && annotations.length === 0;
- }
- syncActions();
-
- ta.addEventListener( 'input', () => {
- state.comment = ta.value;
- syncActions();
- } );
- ta.addEventListener( 'keydown', ( event ) => {
- if ( event.key !== 'Enter' || event.isComposing || event.keyCode === 229 ) return;
- if ( event.metaKey || event.ctrlKey ) {
- event.preventDefault();
- const start = ta.selectionStart;
- const end = ta.selectionEnd;
- ta.value = ta.value.slice( 0, start ) + '\n' + ta.value.slice( end );
- state.comment = ta.value;
- ta.setSelectionRange( start + 1, start + 1 );
- syncActions();
- return;
- }
- if ( event.shiftKey ) return;
- event.preventDefault();
- save.click();
- } );
-
- popup.appendChild( actions );
-
- popup.addEventListener( 'click', ( e ) => e.stopPropagation() );
- popup.addEventListener( 'mousemove', ( e ) => e.stopPropagation() );
-
- return popup;
- }
-
- /* Editing an existing note leaves picking mode alone: markers stay
- * clickable when picking is off, and silently switching it on would
- * swallow every subsequent link click in the page. */
- function openPopupForAnnotation( ann ) {
- hoveredEl = null;
- activePopup = {
- id: ann.id,
- comment: ann.comment,
- target: {
- selector: ann.selector,
- tag: ann.tag,
- nearbyText: ann.nearbyText,
- boundingBox: ann.boundingBox,
- documentRect: ann.documentRect,
- computedStyles: ann.computedStyles,
- },
- };
- persistAnnotations();
- render();
- }
-
- function openPopupForElement( el ) {
- const viewport = el.getBoundingClientRect();
- activePopup = {
- fromPicker: true,
- comment: '',
- target: {
- selector: buildSelector( el ),
- tag: el.tagName.toLowerCase(),
- nearbyText: nearbyText( el ),
- boundingBox: { x: viewport.x, y: viewport.y, width: viewport.width, height: viewport.height },
- documentRect: documentRect( el ),
- computedStyles: pickComputedStyles( el ),
- },
- };
- persistAnnotations();
- render();
- }
-
- function isOurElement( el ) {
- return !! ( el && el.closest && el.closest( '#' + HOST_ID ) );
- }
-
- /* ------------------------------------------------------------------
- * Picking interactions. Only the highlight is updated on mousemove —
- * markers are document-anchored and don't move with mouse position.
- * No scroll/resize listeners: markers and highlight live in document
- * coordinates and follow the page naturally.
- * ---------------------------------------------------------------- */
- document.addEventListener(
- 'mousemove',
- ( e ) => {
- if ( ! isPicking || activePopup ) return;
- if ( isOurElement( e.target ) ) {
- if ( hoveredEl !== null ) {
- hoveredEl = null;
- showHighlight( null );
- }
- return;
- }
- if ( hoveredEl !== e.target ) {
- hoveredEl = e.target;
- showHighlight( hoveredEl );
- }
- },
- { capture: true, signal: teardown.signal }
- );
-
- document.addEventListener(
- 'click',
- ( e ) => {
- if ( ! isPicking || activePopup ) return;
- if ( isOurElement( e.target ) ) return;
- e.preventDefault();
- e.stopPropagation();
- openPopupForElement( e.target );
- },
- { capture: true, signal: teardown.signal }
- );
-
- document.addEventListener(
- 'keydown',
- ( e ) => {
- const browserCommand = getBrowserShortcutCommand( e );
- if ( browserCommand ) {
- e.preventDefault();
- e.stopPropagation();
- send( { type: 'browser-command', command: browserCommand } );
- return;
- }
- if ( e.key !== 'Escape' ) return;
- if ( activePopup ) {
- activePopup = null;
- persistAnnotations();
- render();
- } else if ( isPicking ) {
- isPicking = false;
- hoveredEl = null;
- persistAnnotations();
- render();
- }
- },
- { capture: true, signal: teardown.signal }
- );
-
- render();
-} )();
-`;
+export {
+ INSPECTOR_BRIDGE_PREFIX,
+ INSPECTOR_COMMAND_EVENT,
+ createStudioInspectorPageScript as createInspectorPageScript,
+} from '@studio/common/ai/inspector-page-script';
diff --git a/apps/ui/src/lib/icons.tsx b/apps/ui/src/lib/icons.tsx
index 4460fc5345..bfa4072922 100644
--- a/apps/ui/src/lib/icons.tsx
+++ b/apps/ui/src/lib/icons.tsx
@@ -49,3 +49,10 @@ export const databaseIcon = (
/>
);
+
+export const annotationIcon = (
+
+
+
+
+);
diff --git a/packages/common/ai/inspector-page-script.ts b/packages/common/ai/inspector-page-script.ts
new file mode 100644
index 0000000000..36e789af99
--- /dev/null
+++ b/packages/common/ai/inspector-page-script.ts
@@ -0,0 +1,1244 @@
+/**
+ * Shared annotation inspector injected into Studio's preview webview and the
+ * standalone CLI annotation browser.
+ *
+ * Runs in the cross-origin guest page so it uses vanilla DOM in a Shadow DOM
+ * root — React isn't loaded there. Communicates with the host renderer via a
+ * structured `console.log` line that the host receives through the webview's
+ * `console-message` event:
+ * guest -> host: `__studio-inspector__:{ "type": "done", ... }`
+ *
+ * The bridge also reports inspector state and forwards browser shortcuts.
+ * Toolbar commands travel in the other direction through a custom event.
+ */
+
+export const INSPECTOR_BRIDGE_PREFIX = '__studio-inspector__:';
+export const INSPECTOR_COMMAND_EVENT = '__studio-inspector-command';
+const INSPECTOR_BRIDGE_TOKEN_PLACEHOLDER = '__STUDIO_INSPECTOR_BRIDGE_TOKEN__';
+const INSPECTOR_EMBEDDED_TOOLBAR_PLACEHOLDER = '__STUDIO_INSPECTOR_EMBEDDED_TOOLBAR__';
+
+export function createStudioInspectorPageScript( bridgeToken: string ): string {
+ if ( ! /^[a-zA-Z0-9_-]{16,128}$/.test( bridgeToken ) ) {
+ throw new Error( 'Invalid inspector bridge token.' );
+ }
+ return INSPECTOR_PAGE_SCRIPT.replace( INSPECTOR_BRIDGE_TOKEN_PLACEHOLDER, bridgeToken ).replace(
+ INSPECTOR_EMBEDDED_TOOLBAR_PLACEHOLDER,
+ 'false'
+ );
+}
+
+export function createCliInspectorPageScript(): string {
+ return INSPECTOR_PAGE_SCRIPT.replace( INSPECTOR_BRIDGE_TOKEN_PLACEHOLDER, '' ).replace(
+ INSPECTOR_EMBEDDED_TOOLBAR_PLACEHOLDER,
+ 'true'
+ );
+}
+
+export const INSPECTOR_PAGE_SCRIPT =
+ String.raw`
+( () => {
+ const BRIDGE_TOKEN = '` +
+ INSPECTOR_BRIDGE_TOKEN_PLACEHOLDER +
+ String.raw`';
+ const EMBEDDED_TOOLBAR = ` +
+ INSPECTOR_EMBEDDED_TOOLBAR_PLACEHOLDER +
+ String.raw`;
+ const HOST_ID = '__studio-inspector-host';
+ if ( window.__studioInspectorMounted && document.getElementById( HOST_ID ) ) {
+ if ( ! EMBEDDED_TOOLBAR ) window.dispatchEvent(
+ new CustomEvent( '` +
+ INSPECTOR_COMMAND_EVENT +
+ String.raw`', { detail: { type: 'report-state', bridgeToken: BRIDGE_TOKEN } } )
+ );
+ return;
+ }
+ if ( typeof window.__studioInspectorDispose === 'function' ) {
+ window.__studioInspectorDispose();
+ }
+ window.__studioInspectorMounted = true;
+ const teardown = new AbortController();
+
+ const BRIDGE_PREFIX = '` +
+ INSPECTOR_BRIDGE_PREFIX +
+ String.raw`';
+ const COMMAND_EVENT = '` +
+ INSPECTOR_COMMAND_EVENT +
+ String.raw`';
+ const STORAGE_KEY = 'studio-inspector-annotations-v1';
+ const MAX_ANNOTATIONS = 100;
+
+ function send( payload ) {
+ if ( EMBEDDED_TOOLBAR ) return;
+ try {
+ console.log(
+ BRIDGE_PREFIX + JSON.stringify( Object.assign( { bridgeToken: BRIDGE_TOKEN }, payload ) )
+ );
+ } catch ( err ) {
+ /* JSON.stringify can fail on cycles; the host treats missing
+ * messages as no-ops, so we swallow rather than crash the page. */
+ }
+ }
+
+ function isApplePlatform() {
+ return /mac|iphone|ipad|ipod/i.test( navigator.platform || navigator.userAgent || '' );
+ }
+
+ function isTextEntryTarget( el ) {
+ if ( ! el || el.nodeType !== 1 ) return false;
+ if ( el.isContentEditable ) return true;
+ const tag = el.tagName.toLowerCase();
+ return tag === 'input' || tag === 'textarea' || tag === 'select';
+ }
+
+ function getBrowserShortcutCommand( event ) {
+ if ( event.defaultPrevented || event.repeat ) return null;
+ const apple = isApplePlatform();
+ if ( event.key === 'ArrowLeft' || event.key === 'ArrowRight' ) {
+ /* Layout-independent back/forward aliases: the bracket chords need
+ * Option/AltGr on many European layouts. Skipped while editing text
+ * to keep native caret movement. */
+ const hasNavModifier = apple
+ ? event.metaKey && ! event.ctrlKey && ! event.altKey
+ : event.altKey && ! event.ctrlKey && ! event.metaKey;
+ if ( ! hasNavModifier || event.shiftKey || isTextEntryTarget( event.target ) ) return null;
+ return event.key === 'ArrowLeft' ? 'back' : 'forward';
+ }
+ if ( event.altKey ) return null;
+ const hasPrimaryModifier = apple ? event.metaKey : event.ctrlKey;
+ if ( ! hasPrimaryModifier ) return null;
+ const key = event.key.toLowerCase();
+ /* The host owns full preview, but in that mode this page covers most of
+ * the window — so the chord is caught here and forwarded back. */
+ if ( event.shiftKey ) return key === 'f' ? 'full-preview' : null;
+ if ( key === 'r' ) return 'reload';
+ if ( key === '[' ) return 'back';
+ if ( key === ']' ) return 'forward';
+ return null;
+ }
+
+ function buildSelector( el ) {
+ if ( ! el || el.nodeType !== 1 ) return '';
+ if ( el.id ) return '#' + CSS.escape( el.id );
+ const parts = [];
+ let node = el;
+ while ( node && node.nodeType === 1 && node !== document.documentElement ) {
+ let part = node.tagName.toLowerCase();
+ if ( node.classList && node.classList.length ) {
+ const classes = Array.from( node.classList )
+ .filter( ( c ) => ! c.startsWith( '__studio-' ) )
+ .slice( 0, 3 )
+ .map( ( c ) => '.' + CSS.escape( c ) )
+ .join( '' );
+ part += classes;
+ }
+ const parent = node.parentElement;
+ if ( parent ) {
+ const sameTagSiblings = Array.from( parent.children ).filter(
+ ( c ) => c.tagName === node.tagName && c.id !== HOST_ID
+ );
+ if ( sameTagSiblings.length > 1 ) {
+ part += ':nth-of-type(' + ( sameTagSiblings.indexOf( node ) + 1 ) + ')';
+ }
+ }
+ parts.unshift( part );
+ node = parent;
+ if ( parts.length >= 6 ) break;
+ }
+ return parts.join( ' > ' );
+ }
+
+ function nearbyText( el ) {
+ const text = ( el.innerText || el.textContent || '' )
+ .replace( /\s+/g, ' ' )
+ .trim();
+ return text.length > 200 ? text.slice( 0, 200 ) + '…' : text;
+ }
+
+ function pickComputedStyles( el ) {
+ const cs = window.getComputedStyle( el );
+ const keys = [
+ 'color', 'background-color', 'font-size', 'font-weight',
+ 'font-family', 'line-height', 'padding', 'margin',
+ 'border', 'display', 'width', 'height',
+ ];
+ const out = {};
+ for ( const k of keys ) {
+ out[ k ] = cs.getPropertyValue( k );
+ }
+ return out;
+ }
+
+ function uid() {
+ return 'a_' + Math.random().toString( 36 ).slice( 2, 10 );
+ }
+
+ function documentRect( el ) {
+ const r = el.getBoundingClientRect();
+ return {
+ left: r.left + window.scrollX,
+ top: r.top + window.scrollY,
+ width: r.width,
+ height: r.height,
+ };
+ }
+
+ const oldHost = document.getElementById( HOST_ID );
+ if ( oldHost ) oldHost.remove();
+ const host = document.createElement( 'div' );
+ host.id = HOST_ID;
+ host.style.cssText =
+ 'all: initial; position: absolute; top: 0; left: 0; width: 0; height: 0; pointer-events: none; z-index: 2147483647;';
+ document.body.appendChild( host );
+ const root = host.attachShadow( { mode: 'open' } );
+
+ const style = document.createElement( 'style' );
+ style.textContent = ` +
+ '`' +
+ String.raw`
+ :host { all: initial; }
+ * { box-sizing: border-box; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
+ .highlight, .annotation-highlight {
+ position: absolute; pointer-events: none;
+ border: 2px solid #2563eb;
+ background: rgba(37,99,235,0.1);
+ border-radius: 4px;
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.9);
+ }
+ .scrim {
+ position: fixed; pointer-events: none;
+ background: rgba(0,0,0,0.52);
+ z-index: 10;
+ }
+ .highlight { z-index: 11; }
+ .annotation-highlight {
+ background: rgba(37,99,235,0.07);
+ }
+ .marker {
+ position: absolute; pointer-events: auto; cursor: pointer;
+ z-index: 2;
+ width: 22px; height: 22px;
+ padding: 0; appearance: none;
+ background: #2563eb; color: #fff;
+ border: 2px solid #fff;
+ border-radius: 50%;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.3);
+ font: 700 11px/1 inherit;
+ display: inline-flex; align-items: center; justify-content: center;
+ transform: translate(-50%, -50%);
+ }
+ .toolbar {
+ position: fixed; right: 16px; bottom: 16px; z-index: 13;
+ display: flex; align-items: center; gap: 6px;
+ padding: 6px;
+ background: rgba(250,250,250,0.94); color: #1e1e1e;
+ border: 1px solid rgba(30,30,30,0.18); border-radius: 10px 10px 18px 10px;
+ box-shadow: 0 8px 28px rgba(0,0,0,0.22);
+ backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
+ pointer-events: auto;
+ }
+ .toolbar button {
+ height: 30px; padding: 0 10px;
+ border: 0; border-radius: 6px;
+ background: transparent; color: inherit;
+ font: 600 12px/1 inherit; cursor: pointer;
+ }
+ .toolbar button:hover { background: rgba(30,30,30,0.08); }
+ .toolbar .submit { background: #2563eb; color: #fff; }
+ .toolbar .submit:hover:not([disabled]) { filter: brightness(0.92); }
+ .toolbar .submit[disabled] { opacity: 0.4; cursor: default; }
+ .toolbar .count {
+ min-width: 22px; height: 22px; padding: 0 6px;
+ display: inline-flex; align-items: center; justify-content: center;
+ border-radius: 11px; background: rgba(30,30,30,0.08);
+ font: 600 11px/1 inherit;
+ }
+ .toast {
+ position: fixed; right: 16px; bottom: 72px; z-index: 13;
+ padding: 8px 12px; border-radius: 8px;
+ background: #1e1e1e; color: #fff;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.25);
+ font: 500 12px/1.3 inherit; pointer-events: none;
+ }
+ .popup {
+ --popup-fill: rgba(250,250,250,0.92);
+ --popup-tint: rgba(255,255,255,0.04);
+ --popup-text: #1e1e1e;
+ --popup-text-weak: rgba(30,30,30,0.58);
+ position: fixed; width: min(360px, calc(100vw - 16px)); z-index: 12;
+ background-color: var(--popup-fill);
+ background-image: linear-gradient(var(--popup-tint), var(--popup-tint));
+ backdrop-filter: blur(20px) saturate(115%);
+ -webkit-backdrop-filter: blur(20px) saturate(115%);
+ color: var(--popup-text);
+ border: 2px solid #2563eb;
+ border-radius: 8px 8px 20px 8px;
+ box-shadow: 0 8px 32px rgba(0,0,0,0.2), 0 0 0 1px rgba(0,0,0,0.9);
+ padding: 8px 8px 6px;
+ pointer-events: auto;
+ display: flex; flex-direction: column; gap: 2px;
+ will-change: transform;
+ }
+ .popup.dragging {
+ backdrop-filter: none;
+ -webkit-backdrop-filter: none;
+ will-change: transform;
+ }
+ .popup .target-row {
+ display: flex; align-items: center; justify-content: space-between;
+ gap: 6px; min-width: 0;
+ cursor: grab; user-select: none;
+ }
+ .popup .target-row.dragging { cursor: grabbing; }
+ .popup .target {
+ min-width: 0; max-width: 70%;
+ padding: 3px 7px;
+ border: 1px solid rgba(30,30,30,0.18);
+ border-radius: 6px;
+ background: rgba(255,255,255,0.38);
+ font: 500 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
+ color: var(--popup-text);
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ }
+ .popup .layer-controls {
+ display: inline-flex; align-items: center; flex: none;
+ padding: 1px;
+ border: 1px solid rgba(30,30,30,0.14);
+ border-radius: 7px;
+ background: rgba(255,255,255,0.28);
+ }
+ .popup .layer-button {
+ width: 18px; height: 18px; padding: 0;
+ border-radius: 5px;
+ background: transparent; color: var(--popup-text-weak);
+ font-size: 15px; line-height: 1;
+ }
+ .popup .layer-button:hover { background: rgba(30,30,30,0.08); color: var(--popup-text); }
+ .popup .layer-count {
+ min-width: 24px; color: var(--popup-text-weak);
+ font-size: 10px; text-align: center;
+ }
+ .popup textarea {
+ width: 100%; height: 24px; min-height: 24px; max-height: 100px; resize: none;
+ background: transparent; color: var(--popup-text);
+ border: 0; border-radius: 0;
+ margin-top: 4px; padding: 3px 4px 0; font: 14px/1.3 inherit; outline: none;
+ }
+ .popup textarea::placeholder { color: var(--popup-text-weak); }
+ .popup .actions {
+ display: flex; align-items: center; justify-content: flex-end; gap: 4px;
+ margin-top: -2px;
+ }
+ .popup .left-actions { display: inline-flex; align-items: center; margin-right: auto; }
+ .popup button {
+ padding: 6px 10px; border-radius: 8px; border: none;
+ font: 600 12px/1 inherit; cursor: pointer;
+ }
+ .popup .delete { background: transparent; color: var(--popup-text-weak); }
+ .popup .delete:hover { color: #ef4444; }
+ .popup .cancel {
+ display: inline-flex; align-items: center; height: 28px;
+ padding: 0 4px; border-radius: 4px;
+ background: transparent; color: var(--popup-text);
+ font-size: 11px; font-weight: 500; line-height: 1; opacity: 0.72;
+ }
+ .popup .cancel:hover { background: rgba(30,30,30,0.08); color: var(--popup-text); }
+ .popup .send-to-chat {
+ background: rgba(30,30,30,0.08); color: var(--popup-text);
+ border-radius: 4px;
+ font-size: 11px; font-weight: 600;
+ }
+ .popup .send-to-chat:hover:not([disabled]) { background: rgba(30,30,30,0.13); }
+ .popup .send-to-chat[disabled] { opacity: 0.4; cursor: default; }
+ .popup .save {
+ display: inline-flex; align-items: center; justify-content: center;
+ position: relative;
+ width: 28px; height: 28px; padding: 0;
+ border-radius: 50%; background: #2563eb; color: #fff;
+ font-size: 0;
+ }
+ .popup .save::before, .popup .save::after {
+ content: ''; position: absolute; left: 50%; top: 50%;
+ width: 12px; height: 2px; border-radius: 1px;
+ background: currentColor; transform: translate(-50%, -50%);
+ }
+ .popup .save::after { width: 2px; height: 12px; }
+ .popup .save:hover:not([disabled]) { filter: brightness(0.92); }
+ .popup .save[disabled] { opacity: 0.4; cursor: default; }
+ @media (prefers-color-scheme: dark) {
+ .highlight, .annotation-highlight {
+ box-shadow: 0 0 0 1px rgba(255,255,255,0.9);
+ }
+ .popup {
+ --popup-fill: rgba(20,20,20,0.92);
+ --popup-tint: rgba(0,0,0,0.04);
+ --popup-text: #f4f4f4;
+ --popup-text-weak: rgba(244,244,244,0.58);
+ box-shadow: 0 8px 32px rgba(0,0,0,0.42), 0 0 0 1px rgba(255,255,255,0.9);
+ }
+ .toolbar {
+ background: rgba(20,20,20,0.94); color: #f4f4f4;
+ border-color: rgba(255,255,255,0.2);
+ }
+ .toolbar button:hover, .toolbar .count { background: rgba(255,255,255,0.1); }
+ .popup .target {
+ border-color: rgba(255,255,255,0.2);
+ background: rgba(0,0,0,0.2);
+ }
+ .popup .layer-controls {
+ border-color: rgba(255,255,255,0.16);
+ background: rgba(0,0,0,0.16);
+ }
+ .popup .cancel:hover { background: rgba(255,255,255,0.08); }
+ .popup .send-to-chat { background: rgba(255,255,255,0.1); }
+ .popup .send-to-chat:hover:not([disabled]) { background: rgba(255,255,255,0.16); }
+ .popup .layer-button:hover { background: rgba(255,255,255,0.08); }
+ }
+ ` +
+ '`' +
+ String.raw`;
+ root.appendChild( style );
+
+ /* ------------------------------------------------------------------
+ * State + DOM
+ * ---------------------------------------------------------------- */
+ let isPicking = false;
+ let hoveredEl = null;
+ let activePopup = null; /* { id?, target, comment } */
+ function loadAnnotations() {
+ let stored = window.__studioInspectorState;
+ if ( EMBEDDED_TOOLBAR ) {
+ try {
+ stored = JSON.parse( localStorage.getItem( STORAGE_KEY ) || '[]' );
+ } catch {
+ stored = [];
+ }
+ }
+ return Array.isArray( stored )
+ ? stored
+ .filter(
+ ( annotation ) =>
+ typeof annotation === 'object' &&
+ annotation !== null &&
+ typeof annotation.id === 'string' &&
+ typeof annotation.comment === 'string' &&
+ annotation.comment.trim()
+ )
+ .slice( 0, MAX_ANNOTATIONS )
+ : [];
+ }
+
+ let annotations = loadAnnotations();
+
+ const markerNodes = new Map(); /* id -> marker element */
+ const annotationHighlightNodes = new Map(); /* id -> highlight element */
+ const scrimNodes = [];
+ let highlightNode = null;
+ let popupNode = null;
+ let scrollLock = null;
+
+ window.__studioInspectorDispose = () => {
+ teardown.abort();
+ if ( scrollLock ) {
+ document.documentElement.style.overflow = scrollLock.documentOverflow;
+ document.body.style.overflow = scrollLock.bodyOverflow;
+ scrollLock = null;
+ }
+ host.remove();
+ delete window.__studioInspectorMounted;
+ delete window.__studioInspectorDispose;
+ };
+
+ function syncScrollLock() {
+ if ( activePopup && ! scrollLock ) {
+ scrollLock = {
+ documentOverflow: document.documentElement.style.overflow,
+ bodyOverflow: document.body.style.overflow,
+ };
+ document.documentElement.style.overflow = 'hidden';
+ document.body.style.overflow = 'hidden';
+ } else if ( ! activePopup && scrollLock ) {
+ document.documentElement.style.overflow = scrollLock.documentOverflow;
+ document.body.style.overflow = scrollLock.bodyOverflow;
+ scrollLock = null;
+ }
+ }
+
+ function persistAnnotations() {
+ let persistedAnnotations = [];
+ try {
+ persistedAnnotations = JSON.parse( JSON.stringify( annotations ) );
+ } catch {
+ persistedAnnotations = [];
+ }
+ window.__studioInspectorState = persistedAnnotations;
+ if ( EMBEDDED_TOOLBAR ) {
+ try {
+ localStorage.setItem( STORAGE_KEY, JSON.stringify( persistedAnnotations ) );
+ } catch {}
+ }
+ send( { type: 'annotations-updated', annotations: persistedAnnotations } );
+ }
+
+ function sendState() {
+ send( {
+ type: 'state',
+ isPicking,
+ annotationCount: annotations.length,
+ } );
+ }
+
+ function syncMarkers() {
+ const visibleAnnotations = annotations.filter( isAnnotationOnCurrentPage );
+ const ids = new Set( visibleAnnotations.map( ( annotation ) => annotation.id ) );
+ for ( const [ id, marker ] of markerNodes ) {
+ if ( ! ids.has( id ) ) {
+ marker.remove();
+ markerNodes.delete( id );
+ }
+ }
+ visibleAnnotations.forEach( ( ann ) => {
+ let marker = markerNodes.get( ann.id );
+ if ( ! marker ) {
+ marker = document.createElement( 'button' );
+ marker.type = 'button';
+ marker.className = 'marker';
+ marker.addEventListener( 'click', ( e ) => {
+ e.stopPropagation();
+ const current = annotations.find( ( a ) => a.id === ann.id );
+ if ( current ) openPopupForAnnotation( current );
+ } );
+ root.appendChild( marker );
+ markerNodes.set( ann.id, marker );
+ }
+ const box = resolveTargetRect( ann );
+ if ( box ) {
+ const inset = 12;
+ const viewport = {
+ left: window.scrollX,
+ top: window.scrollY,
+ right: window.scrollX + window.innerWidth,
+ bottom: window.scrollY + window.innerHeight,
+ };
+ const boxRight = box.left + box.width;
+ const boxBottom = box.top + box.height;
+ const isVisible =
+ boxRight >= viewport.left &&
+ box.left <= viewport.right &&
+ boxBottom >= viewport.top &&
+ box.top <= viewport.bottom;
+ marker.style.display = isVisible ? '' : 'none';
+ if ( isVisible ) {
+ marker.style.left =
+ Math.min(
+ Math.max( boxRight, viewport.left + inset ),
+ viewport.right - inset
+ ) + 'px';
+ marker.style.top =
+ Math.min(
+ Math.max( box.top, viewport.top + inset ),
+ viewport.bottom - inset
+ ) + 'px';
+ }
+ }
+ const annotationNumber = annotations.findIndex( ( item ) => item.id === ann.id ) + 1;
+ marker.textContent = String( annotationNumber );
+ marker.title = ann.comment;
+ marker.setAttribute( 'aria-label', 'Annotation ' + annotationNumber + ': ' + ann.comment );
+ } );
+ }
+
+ function isAnnotationOnCurrentPage( annotation ) {
+ return ! annotation.pathname || annotation.pathname === window.location.pathname;
+ }
+
+ function resolveTargetRect( target ) {
+ let el = null;
+ try {
+ el = target.selector ? document.querySelector( target.selector ) : null;
+ } catch {}
+ return el ? documentRect( el ) : target.documentRect || target.boundingBox || null;
+ }
+
+ function positionHighlight( node, rect ) {
+ node.style.left = rect.left + 'px';
+ node.style.top = rect.top + 'px';
+ node.style.width = rect.width + 'px';
+ node.style.height = rect.height + 'px';
+ }
+
+ function syncAnnotationHighlights() {
+ const visibleAnnotations = annotations.filter( isAnnotationOnCurrentPage );
+ const ids = new Set( visibleAnnotations.map( ( annotation ) => annotation.id ) );
+ for ( const [ id, node ] of annotationHighlightNodes ) {
+ if ( ! ids.has( id ) ) {
+ node.remove();
+ annotationHighlightNodes.delete( id );
+ }
+ }
+ for ( const annotation of visibleAnnotations ) {
+ const rect = resolveTargetRect( annotation );
+ if ( ! rect ) continue;
+ let node = annotationHighlightNodes.get( annotation.id );
+ if ( ! node ) {
+ node = document.createElement( 'div' );
+ node.className = 'annotation-highlight';
+ root.appendChild( node );
+ annotationHighlightNodes.set( annotation.id, node );
+ }
+ positionHighlight( node, rect );
+ }
+ }
+
+ function showHighlight( el ) {
+ if ( highlightNode ) {
+ highlightNode.remove();
+ highlightNode = null;
+ }
+ const rect = activePopup ? resolveTargetRect( activePopup.target ) : el ? documentRect( el ) : null;
+ if ( ! rect || ! isPicking ) return;
+ highlightNode = document.createElement( 'div' );
+ highlightNode.className = 'highlight';
+ positionHighlight( highlightNode, rect );
+ root.appendChild( highlightNode );
+ }
+
+ function syncScrim() {
+ if ( ! activePopup ) {
+ scrimNodes.splice( 0 ).forEach( ( node ) => node.remove() );
+ return;
+ }
+ const rect = resolveTargetRect( activePopup.target );
+ if ( ! rect ) {
+ scrimNodes.splice( 0 ).forEach( ( node ) => node.remove() );
+ return;
+ }
+ while ( scrimNodes.length < 4 ) {
+ const node = document.createElement( 'div' );
+ node.className = 'scrim';
+ root.appendChild( node );
+ scrimNodes.push( node );
+ }
+ const left = Math.min( window.innerWidth, Math.max( 0, rect.left - window.scrollX ) );
+ const top = Math.min( window.innerHeight, Math.max( 0, rect.top - window.scrollY ) );
+ const right = Math.min(
+ window.innerWidth,
+ Math.max( left, rect.left + rect.width - window.scrollX )
+ );
+ const bottom = Math.min(
+ window.innerHeight,
+ Math.max( top, rect.top + rect.height - window.scrollY )
+ );
+ const panels = [
+ { left: 0, top: 0, width: window.innerWidth, height: top },
+ { left: 0, top: bottom, width: window.innerWidth, height: window.innerHeight - bottom },
+ { left: 0, top, width: left, height: bottom - top },
+ { left: right, top, width: window.innerWidth - right, height: bottom - top },
+ ];
+ scrimNodes.forEach( ( node, index ) => {
+ const panel = panels[ index ];
+ node.style.left = panel.left + 'px';
+ node.style.top = panel.top + 'px';
+ node.style.width = panel.width + 'px';
+ node.style.height = panel.height + 'px';
+ } );
+ }
+
+ function showPopup() {
+ if ( popupNode ) {
+ popupNode.remove();
+ popupNode = null;
+ }
+ if ( activePopup ) {
+ popupNode = buildPopup( activePopup );
+ root.appendChild( popupNode );
+ }
+ }
+
+ function showEmbeddedToolbar() {
+ const existing = root.querySelector( '.toolbar' );
+ if ( existing ) existing.remove();
+ if ( ! EMBEDDED_TOOLBAR ) return;
+ const toolbar = document.createElement( 'div' );
+ toolbar.className = 'toolbar';
+ const modeButton = document.createElement( 'button' );
+ modeButton.type = 'button';
+ modeButton.textContent = isPicking ? 'Stop annotating' : 'Annotate';
+ modeButton.addEventListener( 'click', togglePicking );
+ toolbar.appendChild( modeButton );
+ if ( annotations.length > 0 ) {
+ const count = document.createElement( 'span' );
+ count.className = 'count';
+ count.textContent = String( annotations.length );
+ count.title = annotations.length + ' annotation(s)';
+ toolbar.appendChild( count );
+ }
+ const submit = document.createElement( 'button' );
+ submit.type = 'button';
+ submit.className = 'submit';
+ submit.textContent = 'Send to agent';
+ submit.disabled = annotations.length === 0 && ! ( activePopup && activePopup.comment.trim() );
+ submit.addEventListener( 'click', submitAnnotations );
+ toolbar.appendChild( submit );
+ root.appendChild( toolbar );
+ }
+
+ function render() {
+ syncScrollLock();
+ syncMarkers();
+ syncAnnotationHighlights();
+ syncScrim();
+ showHighlight( hoveredEl );
+ showPopup();
+ showEmbeddedToolbar();
+ sendState();
+ }
+
+ function togglePicking() {
+ isPicking = ! isPicking;
+ if ( ! isPicking ) hoveredEl = null;
+ activePopup = null;
+ persistAnnotations();
+ render();
+ }
+
+ function commitActivePopup() {
+ if ( ! activePopup ) return true;
+ const state = activePopup;
+ const trimmed = ( state.comment || '' ).trim();
+ if ( ! trimmed ) return false;
+ if ( state.id ) {
+ annotations = annotations.map( ( annotation ) =>
+ annotation.id === state.id
+ ? Object.assign( {}, annotation, { comment: trimmed, updatedAt: Date.now() } )
+ : annotation
+ );
+ } else {
+ if ( annotations.length >= MAX_ANNOTATIONS ) return false;
+ state.id = uid();
+ annotations = annotations.concat( [
+ {
+ id: state.id,
+ comment: trimmed,
+ selector: state.target.selector,
+ tag: state.target.tag,
+ elementLabel: state.target.elementLabel,
+ nearbyText: state.target.nearbyText,
+ boundingBox: state.target.boundingBox,
+ documentRect: state.target.documentRect,
+ computedStyles: state.target.computedStyles,
+ pathname: window.location.pathname,
+ url: window.location.href,
+ timestamp: Date.now(),
+ },
+ ] );
+ }
+ persistAnnotations();
+ return true;
+ }
+
+ function submitAnnotations() {
+ if ( activePopup && ! ( activePopup.comment || '' ).trim() ) {
+ if ( annotations.length === 0 ) {
+ sendState();
+ return;
+ }
+ activePopup = null;
+ } else if ( ! commitActivePopup() ) {
+ return;
+ }
+ if ( annotations.length === 0 ) {
+ sendState();
+ return;
+ }
+ const sent = annotations.slice();
+ if ( EMBEDDED_TOOLBAR ) {
+ window.__studioAnnotateDone = {
+ capturedAt: Date.now(),
+ url: window.location.href,
+ annotations: sent,
+ };
+ } else {
+ send( { type: 'done', annotations: sent } );
+ }
+ annotations = [];
+ activePopup = null;
+ isPicking = false;
+ hoveredEl = null;
+ persistAnnotations();
+ render();
+ if ( EMBEDDED_TOOLBAR ) showSubmissionToast( sent.length );
+ }
+
+ function showSubmissionToast( count ) {
+ const toast = document.createElement( 'div' );
+ toast.className = 'toast';
+ root.appendChild( toast );
+ let seconds = 10;
+ const paint = () => {
+ toast.textContent = 'Sent ' + count + ' annotation(s) — closing in ' + seconds + 's';
+ };
+ paint();
+ const interval = window.setInterval( () => {
+ seconds -= 1;
+ if ( seconds <= 0 ) {
+ window.clearInterval( interval );
+ toast.textContent = 'Sent ' + count + ' annotation(s) — closing now…';
+ } else paint();
+ }, 1000 );
+ }
+
+ if ( ! EMBEDDED_TOOLBAR ) window.addEventListener( COMMAND_EVENT, ( event ) => {
+ const command = event.detail || {};
+ if ( command.bridgeToken !== BRIDGE_TOKEN ) return;
+ if ( command.type === 'toggle-picking' ) {
+ togglePicking();
+ return;
+ }
+ if ( command.type === 'submit' ) {
+ submitAnnotations();
+ return;
+ }
+ if ( command.type === 'report-state' ) {
+ sendState();
+ }
+ }, { signal: teardown.signal } );
+
+ function buildPopup( state ) {
+ const popup = document.createElement( 'div' );
+ popup.className = 'popup';
+ popup.setAttribute( 'role', 'dialog' );
+ popup.setAttribute( 'aria-label', 'Annotate selected element' );
+
+ /* Position the popup near the element using viewport coords (it's
+ * \`position: fixed\` so it stays in the viewport). Falls back to
+ * centre if the element can't be located. */
+ let el = null;
+ try {
+ el = state.target.selector ? document.querySelector( state.target.selector ) : null;
+ } catch {}
+ if ( state.popupPosition ) {
+ popup.style.left = state.popupPosition.left + 'px';
+ popup.style.top = state.popupPosition.top + 'px';
+ } else if ( el ) {
+ const r = el.getBoundingClientRect();
+ const popupWidth = Math.min( 360, window.innerWidth - 16 );
+ const gap = 12;
+ const left = Math.min(
+ Math.max( 8, r.left + r.width / 2 - popupWidth / 2 ),
+ window.innerWidth - popupWidth - 8
+ );
+ let top = r.bottom + gap;
+ if ( top + 150 > window.innerHeight ) {
+ top = Math.max( 8, r.top - 150 - gap );
+ }
+ state.popupPosition = { left, top };
+ popup.style.left = state.popupPosition.left + 'px';
+ popup.style.top = state.popupPosition.top + 'px';
+ } else {
+ state.popupPosition = {
+ left: Math.max( 8, ( window.innerWidth - Math.min( 360, window.innerWidth - 16 ) ) / 2 ),
+ top: Math.max( 8, ( window.innerHeight - 150 ) / 2 ),
+ };
+ popup.style.left = state.popupPosition.left + 'px';
+ popup.style.top = state.popupPosition.top + 'px';
+ }
+
+ const targetRow = document.createElement( 'div' );
+ targetRow.className = 'target-row';
+ const target = document.createElement( 'div' );
+ target.className = 'target';
+ target.textContent = state.target.elementLabel || state.target.tag;
+ target.title = [ state.target.selector, state.target.nearbyText ].filter( Boolean ).join( '\n' );
+ targetRow.appendChild( target );
+ if ( state.targets && state.targets.length > 1 ) {
+ const controls = document.createElement( 'div' );
+ controls.className = 'layer-controls';
+ const changeTarget = ( offset ) => {
+ state.targetIndex =
+ ( state.targetIndex + offset + state.targets.length ) % state.targets.length;
+ state.target = state.targets[ state.targetIndex ];
+ render();
+ };
+ const previous = document.createElement( 'button' );
+ previous.type = 'button';
+ previous.className = 'layer-button';
+ previous.textContent = '‹';
+ previous.title = 'Select previous element at this point';
+ previous.setAttribute( 'aria-label', previous.title );
+ previous.addEventListener( 'click', () => changeTarget( -1 ) );
+ const count = document.createElement( 'span' );
+ count.className = 'layer-count';
+ count.textContent = ( state.targetIndex + 1 ) + '/' + state.targets.length;
+ const next = document.createElement( 'button' );
+ next.type = 'button';
+ next.className = 'layer-button';
+ next.textContent = '›';
+ next.title = 'Select next element at this point';
+ next.setAttribute( 'aria-label', next.title );
+ next.addEventListener( 'click', () => changeTarget( 1 ) );
+ controls.append( previous, count, next );
+ targetRow.appendChild( controls );
+ }
+ targetRow.addEventListener( 'mousedown', ( event ) => {
+ if ( event.button !== 0 || event.target.closest( 'button' ) ) return;
+ event.preventDefault();
+ const startX = event.clientX;
+ const startY = event.clientY;
+ const startLeft = state.popupPosition.left;
+ const startTop = state.popupPosition.top;
+ let nextPosition = { left: startLeft, top: startTop };
+ let animationFrame = null;
+ let didDrag = false;
+ popup.classList.add( 'dragging' );
+ targetRow.classList.add( 'dragging' );
+ const move = ( moveEvent ) => {
+ moveEvent.preventDefault();
+ moveEvent.stopPropagation();
+ if (
+ Math.abs( moveEvent.clientX - startX ) > 2 ||
+ Math.abs( moveEvent.clientY - startY ) > 2
+ ) {
+ didDrag = true;
+ }
+ const width = popup.offsetWidth || Math.min( 360, window.innerWidth - 16 );
+ const height = popup.offsetHeight || 150;
+ nextPosition = {
+ left: Math.min(
+ Math.max( 8, startLeft + moveEvent.clientX - startX ),
+ Math.max( 8, window.innerWidth - width - 8 )
+ ),
+ top: Math.min(
+ Math.max( 8, startTop + moveEvent.clientY - startY ),
+ Math.max( 8, window.innerHeight - height - 8 )
+ ),
+ };
+ if ( animationFrame !== null ) return;
+ animationFrame = window.requestAnimationFrame( () => {
+ animationFrame = null;
+ popup.style.transform =
+ 'translate3d(' +
+ ( nextPosition.left - startLeft ) +
+ 'px,' +
+ ( nextPosition.top - startTop ) +
+ 'px,0)';
+ } );
+ };
+ const stop = () => {
+ if ( animationFrame !== null ) window.cancelAnimationFrame( animationFrame );
+ animationFrame = null;
+ state.popupPosition = nextPosition;
+ popup.style.left = state.popupPosition.left + 'px';
+ popup.style.top = state.popupPosition.top + 'px';
+ popup.style.transform = '';
+ popup.classList.remove( 'dragging' );
+ targetRow.classList.remove( 'dragging' );
+ window.removeEventListener( 'mousemove', move, true );
+ window.removeEventListener( 'mouseup', stop, true );
+ window.removeEventListener( 'blur', stop, true );
+ if ( didDrag ) {
+ const suppressClick = ( clickEvent ) => {
+ clickEvent.preventDefault();
+ clickEvent.stopPropagation();
+ };
+ window.addEventListener( 'click', suppressClick, { capture: true, once: true } );
+ setTimeout( () => window.removeEventListener( 'click', suppressClick, true ), 0 );
+ }
+ };
+ window.addEventListener( 'mousemove', move, true );
+ window.addEventListener( 'mouseup', stop, true );
+ window.addEventListener( 'blur', stop, true );
+ } );
+ popup.appendChild( targetRow );
+
+ const ta = document.createElement( 'textarea' );
+ ta.placeholder = 'What should change about this element?';
+ ta.maxLength = 10000;
+ ta.value = state.comment || '';
+ const resizeTextarea = () => {
+ ta.style.height = '24px';
+ ta.style.height = Math.min( ta.scrollHeight, 100 ) + 'px';
+ };
+ ta.addEventListener( 'input', () => {
+ state.comment = ta.value;
+ syncActions();
+ resizeTextarea();
+ } );
+ popup.appendChild( ta );
+ setTimeout( () => {
+ resizeTextarea();
+ ta.focus();
+ }, 0 );
+
+ const actions = document.createElement( 'div' );
+ actions.className = 'actions';
+ const leftActions = document.createElement( 'div' );
+ leftActions.className = 'left-actions';
+
+ const closePopup = () => {
+ activePopup = null;
+ hoveredEl = null;
+ persistAnnotations();
+ render();
+ };
+
+ const cancel = document.createElement( 'button' );
+ cancel.type = 'button';
+ cancel.className = 'cancel';
+ cancel.textContent = 'Cancel';
+ cancel.addEventListener( 'click', closePopup );
+ leftActions.appendChild( cancel );
+
+ if ( state.id ) {
+ const del = document.createElement( 'button' );
+ del.type = 'button';
+ del.className = 'delete';
+ del.textContent = 'Delete';
+ del.addEventListener( 'click', () => {
+ annotations = annotations.filter( ( a ) => a.id !== state.id );
+ activePopup = null;
+ persistAnnotations();
+ render();
+ } );
+ leftActions.appendChild( del );
+ }
+ actions.appendChild( leftActions );
+
+ const sendToChat = document.createElement( 'button' );
+ sendToChat.type = 'button';
+ sendToChat.className = 'send-to-chat';
+ sendToChat.textContent = EMBEDDED_TOOLBAR ? 'Send to agent' : 'Send to chat';
+ sendToChat.addEventListener( 'click', submitAnnotations );
+ actions.appendChild( sendToChat );
+
+ const save = document.createElement( 'button' );
+ save.type = 'button';
+ save.className = 'save';
+ save.setAttribute( 'aria-label', state.id ? 'Update note' : 'Save note' );
+ save.title = state.id ? 'Update note' : 'Save note';
+ save.addEventListener( 'click', () => {
+ if ( ! commitActivePopup() ) return;
+ closePopup();
+ } );
+ actions.appendChild( save );
+
+ function syncActions() {
+ save.disabled = ! ( state.comment && state.comment.trim() );
+ sendToChat.disabled = save.disabled && annotations.length === 0;
+ }
+ syncActions();
+
+ ta.addEventListener( 'keydown', ( event ) => {
+ if ( event.key !== 'Enter' || event.isComposing || event.keyCode === 229 ) return;
+ if ( event.metaKey || event.ctrlKey ) {
+ event.preventDefault();
+ const start = ta.selectionStart;
+ const end = ta.selectionEnd;
+ ta.value = ta.value.slice( 0, start ) + '\n' + ta.value.slice( end );
+ state.comment = ta.value;
+ ta.setSelectionRange( start + 1, start + 1 );
+ syncActions();
+ resizeTextarea();
+ return;
+ }
+ if ( event.shiftKey ) return;
+ event.preventDefault();
+ save.click();
+ } );
+
+ popup.appendChild( actions );
+
+ popup.addEventListener( 'click', ( e ) => e.stopPropagation() );
+ popup.addEventListener( 'mousemove', ( e ) => e.stopPropagation() );
+
+ return popup;
+ }
+
+ function openPopupForAnnotation( ann ) {
+ hoveredEl = null;
+ activePopup = {
+ id: ann.id,
+ comment: ann.comment,
+ target: {
+ selector: ann.selector,
+ tag: ann.tag,
+ elementLabel: ann.elementLabel,
+ nearbyText: ann.nearbyText,
+ boundingBox: ann.boundingBox,
+ documentRect: ann.documentRect,
+ computedStyles: ann.computedStyles,
+ },
+ };
+ persistAnnotations();
+ render();
+ }
+
+ function targetForElement( el ) {
+ const viewport = el.getBoundingClientRect();
+ const classes = Array.from( el.classList || [] )
+ .filter( ( className ) => ! className.startsWith( '__studio-' ) )
+ .slice( 0, 2 );
+ const elementLabel = (
+ el.tagName.toLowerCase() +
+ ( el.id ? '#' + el.id : '' ) +
+ classes.map( ( className ) => '.' + className ).join( '' )
+ ).slice( 0, 240 );
+ const selector = buildSelector( el );
+ return {
+ selector: selector.length <= 1000 ? selector : undefined,
+ tag: el.tagName.toLowerCase(),
+ elementLabel,
+ nearbyText: nearbyText( el ),
+ boundingBox: { x: viewport.x, y: viewport.y, width: viewport.width, height: viewport.height },
+ documentRect: documentRect( el ),
+ computedStyles: pickComputedStyles( el ),
+ };
+ }
+
+ function elementsAtPoint( initial, clientX, clientY ) {
+ const MAX_CANDIDATES = 30;
+ const MAX_FALLBACK_ELEMENTS = 5000;
+ const MAX_FALLBACK_MS = 20;
+ const candidates = [];
+ const seen = new Set();
+ const add = ( el ) => {
+ if ( candidates.length >= MAX_CANDIDATES || ! el || seen.has( el ) || isOurElement( el ) )
+ return;
+ if ( el === document.documentElement || el === document.body ) return;
+ const rect = el.getBoundingClientRect();
+ if ( rect.width <= 0 || rect.height <= 0 ) return;
+ const style = window.getComputedStyle( el );
+ if ( style.display === 'none' || style.visibility === 'hidden' ) return;
+ seen.add( el );
+ candidates.push( el );
+ };
+
+ add( initial );
+ if ( typeof document.elementsFromPoint === 'function' ) {
+ document.elementsFromPoint( clientX, clientY ).forEach( add );
+ }
+ const behind = [];
+ const scanStartedAt = performance.now();
+ let scannedElements = 0;
+ for ( const el of document.querySelectorAll( 'body *' ) ) {
+ scannedElements += 1;
+ if (
+ scannedElements > MAX_FALLBACK_ELEMENTS ||
+ ( scannedElements % 50 === 0 && performance.now() - scanStartedAt > MAX_FALLBACK_MS )
+ ) {
+ break;
+ }
+ if ( seen.has( el ) || isOurElement( el ) ) continue;
+ const rect = el.getBoundingClientRect();
+ if (
+ rect.width > 0 &&
+ rect.height > 0 &&
+ clientX >= rect.left &&
+ clientX <= rect.right &&
+ clientY >= rect.top &&
+ clientY <= rect.bottom
+ ) {
+ behind.push( { el, area: rect.width * rect.height } );
+ }
+ }
+ behind.sort( ( a, b ) => a.area - b.area ).forEach( ( item ) => add( item.el ) );
+ return candidates;
+ }
+
+ function openPopupForElement( el, clientX, clientY ) {
+ const elements = elementsAtPoint( el, clientX, clientY );
+ const targets = elements.map( targetForElement );
+ isPicking = true;
+ activePopup = {
+ comment: '',
+ target: targets[ 0 ] || targetForElement( el ),
+ targets,
+ targetIndex: 0,
+ };
+ persistAnnotations();
+ render();
+ }
+
+ function isOurElement( el ) {
+ return !! ( el && el.closest && el.closest( '#' + HOST_ID ) );
+ }
+
+ document.addEventListener(
+ 'mousemove',
+ ( e ) => {
+ if ( ! isPicking || activePopup ) return;
+ if ( isOurElement( e.target ) ) {
+ if ( hoveredEl !== null ) {
+ hoveredEl = null;
+ showHighlight( null );
+ }
+ return;
+ }
+ if ( hoveredEl !== e.target ) {
+ hoveredEl = e.target;
+ showHighlight( hoveredEl );
+ }
+ },
+ { capture: true, signal: teardown.signal }
+ );
+
+ document.addEventListener(
+ 'click',
+ ( e ) => {
+ if ( ! isPicking || activePopup ) return;
+ if ( isOurElement( e.target ) ) return;
+ e.preventDefault();
+ e.stopPropagation();
+ openPopupForElement( e.target, e.clientX, e.clientY );
+ },
+ { capture: true, signal: teardown.signal }
+ );
+
+ document.addEventListener(
+ 'keydown',
+ ( e ) => {
+ const browserCommand = EMBEDDED_TOOLBAR ? null : getBrowserShortcutCommand( e );
+ if ( browserCommand ) {
+ e.preventDefault();
+ e.stopPropagation();
+ send( { type: 'browser-command', command: browserCommand } );
+ return;
+ }
+ if ( e.key !== 'Escape' ) return;
+ if ( activePopup ) {
+ activePopup = null;
+ hoveredEl = null;
+ persistAnnotations();
+ render();
+ } else if ( isPicking ) {
+ isPicking = false;
+ hoveredEl = null;
+ persistAnnotations();
+ render();
+ }
+ },
+ { capture: true, signal: teardown.signal }
+ );
+
+ function syncOverlayPositions() {
+ syncMarkers();
+ syncAnnotationHighlights();
+ if ( highlightNode ) {
+ const rect = activePopup
+ ? resolveTargetRect( activePopup.target )
+ : hoveredEl
+ ? documentRect( hoveredEl )
+ : null;
+ if ( rect ) positionHighlight( highlightNode, rect );
+ }
+ syncScrim();
+ }
+
+ window.addEventListener( 'scroll', syncOverlayPositions, {
+ capture: true,
+ signal: teardown.signal,
+ } );
+ window.addEventListener( 'resize', syncOverlayPositions, { signal: teardown.signal } );
+
+ render();
+} )();
+`;