@ahmednawaz/crank 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ /* Auto-generated by scripts/embed-panel.js — do not edit */
2
+ export const CRANK_PANEL_JS = "/**\n * Crank bookmarklet — tool-ui CSS/helpers + text-source classification.\n * Preview (DOM) vs Save (source via SAVE_TEXT_CHANGE) uses Crank text dispatch.\n */\n(function () {\n // Allow re-drag / re-inject to pick up companion updates.\n if (typeof window.__crankTeardown === 'function') {\n try {\n window.__crankTeardown();\n } catch (_) {\n /* ignore */\n }\n }\n if (window.__crankActive) {\n window.__crankActive = false;\n }\n window.__crankActive = true;\n\n const COMPANION_HTTP =\n window.__CRANK_COMPANION__ ||\n detectCompanionOrigin() ||\n detectReplitCompanionOrigin() ||\n 'http://127.0.0.1:3344';\n const COMPANION_WS = COMPANION_HTTP.replace(/^http/i, 'ws') + '/ws';\n const TEAM_TOKEN_KEY = 'crank:teamToken';\n // Retune-style: UI assets from the npm package (same-origin), not companion.\n const ASSET_BASE = (window.__CRANK_ASSET_BASE__ || '').replace(/\\/?$/, '/');\n\n function assetUrl(file, companionPath) {\n if (window.__CRANK_ASSET_MAP__ && window.__CRANK_ASSET_MAP__[file]) {\n return window.__CRANK_ASSET_MAP__[file];\n }\n if (ASSET_BASE && ASSET_BASE.indexOf('blob:crank/') !== 0) {\n return ASSET_BASE + file;\n }\n return COMPANION_HTTP + (companionPath || '/ui/' + file);\n }\n\n function detectReplitCompanionOrigin() {\n try {\n const host = window.location.hostname || '';\n if (!/\\.replit\\.dev$|\\.repl\\.co$|\\.replit\\.app$/.test(host)) {\n return null;\n }\n return 'https://3344-' + host.replace(/^\\d+-/, '');\n } catch (_) {\n return null;\n }\n }\n\n function getTeamToken() {\n try {\n return localStorage.getItem(TEAM_TOKEN_KEY) || '';\n } catch (_) {\n return '';\n }\n }\n\n function setTeamToken(value) {\n try {\n localStorage.setItem(TEAM_TOKEN_KEY, String(value || '').trim());\n } catch (_) {\n /* ignore */\n }\n }\n\n async function ensureTeamToken() {\n const status = await fetch(COMPANION_HTTP + '/api/auth/status').then(function (r) {\n return r.json();\n }).catch(function () {\n return { teamAuthRequired: false };\n });\n if (!status.teamAuthRequired) {\n return true;\n }\n if (getTeamToken()) {\n return true;\n }\n const entered = window.prompt(\n status.hint ||\n 'Enter the Crank team token (ask your team lead):'\n );\n if (!entered) {\n return false;\n }\n const verify = await fetch(COMPANION_HTTP + '/api/auth/verify', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: entered.trim() })\n }).then(function (r) {\n return r.json();\n }).catch(function () {\n return { ok: false };\n });\n if (!verify.ok) {\n window.alert('Invalid Crank team token');\n return false;\n }\n setTeamToken(entered.trim());\n return true;\n }\n\n function authHeaders(extra) {\n const h = extra || {};\n const token = getTeamToken();\n if (token) {\n h['X-Crank-Team-Token'] = token;\n }\n return h;\n }\n\n function wsUrlWithToken() {\n const token = getTeamToken();\n if (!token) {\n return COMPANION_WS;\n }\n const sep = COMPANION_WS.indexOf('?') >= 0 ? '&' : '?';\n return COMPANION_WS + sep + 'teamToken=' + encodeURIComponent(token);\n }\n\n function detectCompanionOrigin() {\n try {\n const scripts = document.querySelectorAll(\n 'script[src*=\"bookmarklet.js\"],script[src*=\"dev-loader.js\"],script[data-crank-loader=\"1\"]'\n );\n for (const s of scripts) {\n const u = new URL(s.src, location.href);\n // Vite same-origin proxy: /@crank/dev-loader.js → companion base is origin+/@crank\n if (u.pathname.indexOf('/@crank/') === 0 || u.pathname === '/@crank') {\n return u.origin + '/@crank';\n }\n return u.origin;\n }\n } catch (_) {}\n return null;\n }\n\n function loadScript(src) {\n return new Promise(function (resolve, reject) {\n const existing = document.querySelector('script[src=\"' + src + '\"]');\n if (existing) {\n resolve();\n return;\n }\n const s = document.createElement('script');\n s.src = src;\n s.onload = function () {\n resolve();\n };\n s.onerror = reject;\n document.documentElement.appendChild(s);\n });\n }\n\n function loadCss(href) {\n let link = document.querySelector('link[data-crank-ui=\"1\"]');\n if (!link) {\n link = document.createElement('link');\n link.rel = 'stylesheet';\n link.setAttribute('data-crank-ui', '1');\n document.documentElement.appendChild(link);\n }\n link.href = href;\n }\n\n let selectedEl = null;\n let selectedNodes = [];\n let sessionId = null;\n let socket = null;\n let selecting = false;\n let originalSnapshot = [];\n let lastVariants = [];\n let activeVariantId = null;\n /** Bumps on each Ask Glean; stale WS progress after variants must not reopen thinking. */\n let gleanEpoch = 0;\n let gleanActiveEpoch = 0;\n const OPTION_COUNT_KEY = 'crank.gleanOptionCount';\n const DEFAULT_GLEAN_OPTION_COUNT = 3;\n\n function normalizeRole(role) {\n return String(role || 'text').toLowerCase();\n }\n\n function isTextLikeRole(role) {\n const r = normalizeRole(role);\n return r === 'text' || r === 'label' || r === 'body';\n }\n\n function hasSemanticStructure(nodes) {\n return (Array.isArray(nodes) ? nodes : []).some(function (n) {\n const r = normalizeRole(n.role);\n return r === 'heading' || r === 'action' || r === 'button';\n });\n }\n\n function textLikeNodes(nodes) {\n return (Array.isArray(nodes) ? nodes : []).filter(function (n) {\n return isTextLikeRole(n.role);\n });\n }\n\n function unitDisplayLabel(node, index, nodes) {\n const list = Array.isArray(nodes) ? nodes : [];\n const role = normalizeRole(node && node.role);\n if (role === 'heading') return 'Heading';\n if (role === 'action' || role === 'button') return 'CTA';\n if (role === 'label') return 'Label';\n const semantic = hasSemanticStructure(list);\n const texts = textLikeNodes(list);\n if (semantic) {\n const bodyIndex = texts.findIndex(function (n) {\n return n === node;\n });\n if (bodyIndex >= 0) {\n return texts.length > 1 ? 'Body ' + (bodyIndex + 1) : 'Body';\n }\n }\n if (list.length <= 1) return 'Copy 1';\n return 'Copy ' + (index + 1);\n }\n\n function assignUnitLabels(nodes) {\n const list = Array.isArray(nodes) ? nodes : [];\n return list.map(function (node, index) {\n return Object.assign({}, node, {\n label: node.label || unitDisplayLabel(node, index, list)\n });\n });\n }\n\n function getGleanOptionCount() {\n const sel = panel && panel.querySelector('#vc-option-count');\n const raw = sel ? sel.value : localStorage.getItem(OPTION_COUNT_KEY);\n const n = parseInt(String(raw || DEFAULT_GLEAN_OPTION_COUNT), 10);\n if (!Number.isFinite(n)) return DEFAULT_GLEAN_OPTION_COUNT;\n return Math.min(6, Math.max(2, n));\n }\n\n const highlight = document.createElement('div');\n highlight.id = 'crank-highlight';\n highlight.style.cssText =\n 'position:fixed;pointer-events:none;z-index:2147483646;border:2px solid oklch(0.45 0.08 180);background:oklch(0.7 0.06 180 / 0.12);border-radius:3px;transition:all 80ms ease;';\n\n const panel = document.createElement('div');\n panel.id = 'vp-shell';\n panel.setAttribute('data-crank-panel', '1');\n panel.style.cssText =\n 'position:fixed;top:16px;right:16px;width:380px;max-height:calc(100vh - 32px);overflow:auto;z-index:2147483647;';\n\n function UI() {\n return window.CrankUI || {};\n }\n function TS() {\n return window.CrankTextSource || {};\n }\n\n // Short phrase for the *current* Cursor chat — paste once; skill/MCP pulls pending.\n // Cursor has no public API to inject into an open Composer; deeplink opens a *new* chat.\n const AGENT_SHORT_PROMPT = 'Apply the pending Crank change';\n const CURSOR_DEEPLINK_MAX = 7800;\n\n function setStatus(text) {\n const el = panel.querySelector('#vc-status');\n if (el) el.textContent = text;\n }\n\n function showToast(text, tone) {\n let toast = panel.querySelector('#vc-toast');\n if (!toast) {\n toast = document.createElement('div');\n toast.id = 'vc-toast';\n const status = panel.querySelector('#vc-status');\n if (status && status.parentNode) {\n status.parentNode.insertBefore(toast, status.nextSibling);\n } else {\n panel.appendChild(toast);\n }\n }\n const bg =\n tone === 'error'\n ? 'oklch(0.93 0.04 25)'\n : tone === 'ok'\n ? 'oklch(0.93 0.04 160)'\n : 'oklch(0.95 0.03 180)';\n const border =\n tone === 'error'\n ? 'oklch(0.55 0.14 25)'\n : tone === 'ok'\n ? 'oklch(0.45 0.1 160)'\n : 'oklch(0.45 0.08 180)';\n toast.style.cssText =\n 'display:block;margin:0 0 10px;padding:8px 10px;border:1px solid ' +\n border +\n ';border-radius:8px;background:' +\n bg +\n ';color:var(--foreground,#111);font:12px/1.45 ui-sans-serif,system-ui,sans-serif;';\n toast.textContent = text;\n }\n\n function hideToast() {\n const toast = panel.querySelector('#vc-toast');\n if (toast) toast.style.display = 'none';\n }\n\n async function copyTextToClipboard(text) {\n const value = String(text || '');\n if (!value) return false;\n try {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n await navigator.clipboard.writeText(value);\n return true;\n }\n } catch (_) {\n /* fall through */\n }\n try {\n const ta = document.createElement('textarea');\n ta.value = value;\n ta.setAttribute('readonly', '');\n ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';\n document.body.appendChild(ta);\n ta.focus();\n ta.select();\n const ok = document.execCommand('copy');\n ta.remove();\n return !!ok;\n } catch (_) {\n return false;\n }\n }\n\n function buildCursorPromptDeeplink(promptText) {\n const text = String(promptText || '').trim();\n if (!text) return '';\n const base = 'cursor://anysphere.cursor-deeplink/prompt?text=';\n let encoded = encodeURIComponent(text);\n if (base.length + encoded.length <= CURSOR_DEEPLINK_MAX) {\n return base + encoded;\n }\n encoded = encodeURIComponent(AGENT_SHORT_PROMPT);\n if (base.length + encoded.length <= CURSOR_DEEPLINK_MAX) {\n return base + encoded;\n }\n return '';\n }\n\n /** Prefill Cursor chat via official prompt deeplink (user must confirm). */\n function openCursorPrompt(promptText) {\n const href = buildCursorPromptDeeplink(promptText);\n if (!href) return false;\n try {\n const a = document.createElement('a');\n a.href = href;\n a.rel = 'noopener';\n a.style.display = 'none';\n document.documentElement.appendChild(a);\n a.click();\n a.remove();\n return true;\n } catch (_) {\n try {\n window.open(href, '_self');\n return true;\n } catch (__) {\n return false;\n }\n }\n }\n\n function hideAgentHandoff() {\n const box = panel.querySelector('#vc-agent-handoff');\n if (box) {\n box.style.display = 'none';\n box.innerHTML = '';\n }\n stopAgentRunPoll();\n }\n\n let agentRunPollTimer = null;\n let agentStatusPollTimer = null;\n\n function stopAgentRunPoll() {\n if (agentRunPollTimer) {\n clearInterval(agentRunPollTimer);\n agentRunPollTimer = null;\n }\n }\n\n function stopAgentStatusPoll() {\n if (agentStatusPollTimer) {\n clearInterval(agentStatusPollTimer);\n agentStatusPollTimer = null;\n }\n }\n\n function statusLabelForJob(job) {\n const s = String((job && job.status) || '').toLowerCase();\n if (s === 'queued') return 'Queued';\n if (s === 'running') return 'Running';\n if (s === 'done') return 'Done';\n if (s === 'failed') return 'Failed';\n return s || '—';\n }\n\n function updateAgentHandoffJobLine(job) {\n const box = panel.querySelector('#vc-agent-handoff');\n if (!box || box.style.display === 'none') return;\n const el = box.querySelector('#vc-agent-job-status');\n if (!el) return;\n const ui = UI();\n const escape = ui.escapeHtml || function (v) {\n return String(v == null ? '' : v);\n };\n if (!job) {\n el.style.display = 'none';\n el.textContent = '';\n return;\n }\n el.style.display = 'block';\n el.innerHTML =\n 'Auto-run: <strong>' +\n escape(statusLabelForJob(job)) +\n '</strong>' +\n (job.error ? ' — ' + escape(job.error) : '');\n }\n\n function showAgentHandoffError(message, detail) {\n const box = panel.querySelector('#vc-agent-handoff');\n if (!box) return;\n const ui = UI();\n const escape = ui.escapeHtml || function (v) {\n return String(v == null ? '' : v);\n };\n box.style.display = 'block';\n box.innerHTML =\n '<div class=\"vp-type-label\" style=\"font-weight:620;margin-bottom:6px;color:oklch(0.45 0.14 25);\">Agent queue failed</div>' +\n '<div class=\"vp-type-support\" style=\"line-height:1.45;color:var(--foreground);\">' +\n escape(message || 'Unknown error') +\n (detail\n ? '<br/><span style=\"opacity:0.85;\">' + escape(detail) + '</span>'\n : '') +\n '</div>';\n try {\n box.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n } catch (_) {\n /* ignore */\n }\n }\n\n function showAgentHandoff(task, options) {\n const box = panel.querySelector('#vc-agent-handoff');\n if (!box) return;\n const opts = options || {};\n const ui = UI();\n const escape = ui.escapeHtml || function (v) {\n return String(v == null ? '' : v);\n };\n const btn = ui.vpButtonHtml || function (_v, label, attrs) {\n return (\n '<button type=\"button\" id=\"' +\n (attrs && attrs.id ? attrs.id : '') +\n '\">' +\n label +\n '</button>'\n );\n };\n const prompt = String((task && task.prompt) || '');\n const label = (task && task.variantLabel) || '';\n const changeCount =\n task && typeof task.changeCount === 'number'\n ? task.changeCount\n : Array.isArray(task && task.changes)\n ? task.changes.length\n : 0;\n const apiKeyConfigured = opts.apiKeyConfigured === true;\n const job = opts.job || null;\n box.style.display = 'block';\n box.innerHTML =\n '<div class=\"vp-type-label\" style=\"font-weight:620;margin-bottom:6px;\">Queued for agent' +\n (label ? ' (“' + escape(label) + '”)' : '') +\n (changeCount ? ' · ' + changeCount + ' change' + (changeCount === 1 ? '' : 's') : '') +\n '</div>' +\n '<div class=\"vp-type-support\" style=\"margin-bottom:8px;line-height:1.45;color:var(--foreground);\">' +\n (opts.waitingWatch\n ? 'Waiting for agent… (skill/MCP watch active)'\n : 'Paste in your <strong>current</strong> Cursor chat: <code>' +\n escape(AGENT_SHORT_PROMPT) +\n '</code> — the <code>crank-apply-changes</code> skill pulls pending changes via MCP. Cursor has no public API to inject into an open chat.') +\n (!apiKeyConfigured\n ? '<br/><span style=\"opacity:0.85;\">Optional: set <code>CURSOR_API_KEY</code> for one-click fully automatic apply (separate agent, not your open chat).</span>'\n : '') +\n '</div>' +\n '<div id=\"vc-agent-job-status\" class=\"vp-type-support\" style=\"display:' +\n (job ? 'block' : 'none') +\n ';margin-bottom:8px;line-height:1.45;color:var(--foreground);\">' +\n (job\n ? 'Auto-run: <strong>' +\n escape(statusLabelForJob(job)) +\n '</strong>' +\n (job.error ? ' — ' + escape(job.error) : '')\n : '') +\n '</div>' +\n '<div style=\"display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;\">' +\n (apiKeyConfigured\n ? btn('primary', 'Run fully automatic', { id: 'vc-agent-auto', type: 'button' }, {\n size: 'compact',\n flex: true\n })\n : '') +\n btn('secondary', 'Copy again', { id: 'vc-agent-copy', type: 'button' }, {\n size: 'compact',\n flex: true\n }) +\n btn('ghost', 'Open new chat instead', { id: 'vc-agent-open', type: 'button' }, {\n size: 'compact',\n flex: true\n }) +\n btn('ghost', 'Dismiss', { id: 'vc-agent-dismiss', type: 'button' }, {\n size: 'compact',\n flex: true\n }) +\n '</div>' +\n '<div class=\"vp-type-label\" style=\"margin-top:10px;font-size:12px;\">Full prompt</div>' +\n '<textarea id=\"vc-agent-prompt\" readonly rows=\"8\" style=\"width:100%;box-sizing:border-box;resize:vertical;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;padding:8px;border:1px solid var(--border);border-radius:6px;background:var(--background, #fff);color:var(--foreground);margin-top:6px;max-height:240px;overflow:auto;\">' +\n escape(prompt) +\n '</textarea>' +\n '<div style=\"margin-top:6px;\">' +\n btn('ghost', 'Copy full prompt', { id: 'vc-agent-copy-full', type: 'button' }, {\n size: 'compact',\n flex: true\n }) +\n '</div>';\n\n const autoBtn = box.querySelector('#vc-agent-auto');\n const openBtn = box.querySelector('#vc-agent-open');\n const copyBtn = box.querySelector('#vc-agent-copy');\n const copyFullBtn = box.querySelector('#vc-agent-copy-full');\n const dismissBtn = box.querySelector('#vc-agent-dismiss');\n\n if (autoBtn) {\n autoBtn.addEventListener('click', function (ev) {\n if (ev && ev.preventDefault) ev.preventDefault();\n startFullyAutomaticRun(task);\n });\n }\n if (openBtn) {\n openBtn.addEventListener('click', function (ev) {\n if (ev && ev.preventDefault) ev.preventDefault();\n const ok = openCursorPrompt(prompt || AGENT_SHORT_PROMPT);\n showToast(\n ok\n ? 'Opened a new Cursor chat (deeplink) — confirm if prompted'\n : 'Could not open deeplink — paste “' +\n AGENT_SHORT_PROMPT +\n '” in your current chat instead',\n ok ? 'ok' : 'error'\n );\n });\n }\n if (copyBtn) {\n copyBtn.addEventListener('click', function () {\n copyTextToClipboard(AGENT_SHORT_PROMPT).then(function (ok) {\n showToast(\n ok\n ? 'Copied — paste in your current Cursor chat'\n : 'Copy failed — type: ' + AGENT_SHORT_PROMPT,\n ok ? 'ok' : 'error'\n );\n });\n });\n }\n if (copyFullBtn) {\n copyFullBtn.addEventListener('click', function () {\n copyTextToClipboard(prompt || AGENT_SHORT_PROMPT).then(function (ok) {\n showToast(\n ok ? 'Full prompt copied' : 'Copy failed — select the textarea',\n ok ? 'ok' : 'error'\n );\n });\n });\n }\n if (dismissBtn) {\n dismissBtn.addEventListener('click', function () {\n hideAgentHandoff();\n hideToast();\n });\n }\n try {\n box.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n } catch (_) {\n /* ignore */\n }\n }\n\n async function fetchAgentStatus() {\n try {\n const data = await postJson('/api/agent-status', null, {\n method: 'GET',\n timeoutMs: 8000\n });\n return data || {};\n } catch (_) {\n try {\n const res = await fetch(COMPANION_HTTP + '/api/agent-status', {\n method: 'GET',\n headers: authHeaders()\n });\n return await res.json();\n } catch (__) {\n return {};\n }\n }\n }\n\n function pollAgentRun(jobId) {\n stopAgentRunPoll();\n if (!jobId) return;\n agentRunPollTimer = setInterval(async function () {\n try {\n const res = await fetch(\n COMPANION_HTTP + '/api/agent-run/' + encodeURIComponent(jobId),\n { method: 'GET', headers: authHeaders() }\n );\n const data = await res.json();\n const job = data && data.job;\n if (!job) return;\n updateAgentHandoffJobLine(job);\n setStatus('Agent auto-run: ' + statusLabelForJob(job));\n if (job.status === 'done' || job.status === 'failed') {\n stopAgentRunPoll();\n setStatus(\n job.status === 'done'\n ? 'Agent auto-run done'\n : 'Agent auto-run failed: ' + (job.error || 'unknown')\n );\n }\n } catch (_) {\n /* ignore transient poll errors */\n }\n }, 2000);\n }\n\n async function startFullyAutomaticRun(task) {\n showToast('Starting fully automatic agent…', 'info');\n setStatus('Starting fully automatic agent…');\n try {\n const body = {\n sessionId: sessionId,\n variantId: (task && task.variantId) || activeVariantId\n };\n const res = await fetch(COMPANION_HTTP + '/api/agent-run', {\n method: 'POST',\n headers: authHeaders({ 'Content-Type': 'application/json' }),\n body: JSON.stringify(body)\n });\n const data = await res.json().catch(function () {\n return {};\n });\n if (!res.ok || data.ok === false) {\n const msg =\n (data && data.error) ||\n 'Set CURSOR_API_KEY to enable one-click Agent apply';\n showToast(msg, 'error');\n setStatus(msg);\n return;\n }\n const job = data.job;\n showAgentHandoff(task || data.task, {\n apiKeyConfigured: true,\n job: job\n });\n if (job && job.id) pollAgentRun(job.id);\n setStatus('Agent auto-run: ' + statusLabelForJob(job));\n } catch (err) {\n const msg =\n err && err.message\n ? String(err.message)\n : 'Fully automatic run failed';\n showToast(msg, 'error');\n setStatus(msg);\n }\n }\n\n let lastFinishedAgentTaskId = null;\n\n async function finishAgentQueue(task, options) {\n const opts = options || {};\n if (\n task &&\n task.id &&\n task.id === lastFinishedAgentTaskId &&\n !opts.force\n ) {\n return;\n }\n if (task && task.id) lastFinishedAgentTaskId = task.id;\n let apiKeyConfigured = opts.apiKeyConfigured;\n if (apiKeyConfigured == null) {\n const st = await fetchAgentStatus();\n apiKeyConfigured = !!(st && st.apiKeyConfigured);\n }\n // Same-chat primary: queue for MCP pull + clipboard short phrase.\n // Do NOT auto-open cursor:// prompt deeplink (that opens a *new* chat).\n showAgentHandoff(task, {\n apiKeyConfigured: apiKeyConfigured,\n waitingWatch: !!opts.waitingWatch,\n job: opts.job || null\n });\n let copied = false;\n if (!opts.skipCopy) {\n copied = await copyTextToClipboard(AGENT_SHORT_PROMPT);\n }\n const msg =\n 'Queued — paste in your current Cursor chat (or ask: ' +\n AGENT_SHORT_PROMPT +\n ')' +\n (copied ? ' · copied' : '');\n showToast(msg, copied || opts.skipCopy ? 'ok' : 'info');\n setStatus(msg);\n }\n\n function cssPath(el) {\n if (!(el instanceof Element)) return '';\n const parts = [];\n let node = el;\n while (node && node.nodeType === 1 && parts.length < 8) {\n let part = node.tagName.toLowerCase();\n if (node.id) {\n part += '#' + CSS.escape(node.id);\n parts.unshift(part);\n break;\n }\n const parent = node.parentElement;\n if (parent) {\n const siblings = Array.from(parent.children).filter(\n (c) => c.tagName === node.tagName\n );\n if (siblings.length > 1) {\n part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';\n }\n }\n parts.unshift(part);\n node = parent;\n }\n return parts.join(' > ');\n }\n\n function isInlineTag(tag) {\n return /^(span|strong|b|em|i|u|small|mark|code|a|br|wbr|svg|path)$/i.test(tag);\n }\n\n function isLeafTextWrapper(el) {\n if (!(el instanceof Element)) return false;\n const tag = el.tagName.toLowerCase();\n if (!/^(div|span|section|article|header|footer|aside|blockquote)$/.test(tag)) {\n return false;\n }\n const kids = Array.from(el.childNodes);\n if (!kids.length) return false;\n return kids.every(function (child) {\n if (child.nodeType === 3) return true;\n if (child.nodeType !== 1) return false;\n return isInlineTag(child.tagName);\n });\n }\n\n function isCopyUnitRoot(el) {\n if (!(el instanceof Element)) return false;\n const tag = el.tagName.toLowerCase();\n if (/^(h1|h2|h3|h4|h5|h6|button|label|li|p|figcaption|caption|td|th)$/.test(tag)) {\n return true;\n }\n if (tag === 'a' && el.childNodes.length) return true;\n return isLeafTextWrapper(el);\n }\n\n function unitRole(el) {\n const tag = el.tagName.toLowerCase();\n if (/^(h1|h2|h3|h4|h5|h6)$/.test(tag)) return 'heading';\n if (/^(button|a)$/.test(tag)) return 'action';\n if (/^(label|figcaption|caption)$/.test(tag)) return 'label';\n const ariaRole = String(el.getAttribute('role') || '').toLowerCase();\n if (ariaRole === 'heading') return 'heading';\n if (ariaRole === 'button' || ariaRole === 'link') return 'action';\n const cls = String(el.className || '').toLowerCase();\n if (/\\b(heading|headline|title|hero-title|card-title)\\b/.test(cls)) {\n return 'heading';\n }\n if (/\\b(cta|btn|button|action-link)\\b/.test(cls)) return 'action';\n return 'text';\n }\n\n /**\n * Within one copy unit, keep the full sentence together but chip any\n * dynamic/bound fragments (DB/prop values) so they are visible, not separate fields.\n */\n function analyzeUnitParts(el, classify) {\n const parts = [];\n const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);\n let textNode;\n while ((textNode = walker.nextNode())) {\n const raw = String(textNode.nodeValue || '').replace(/\\s+/g, ' ');\n if (!raw.trim()) continue;\n const owner =\n textNode.parentElement instanceof Element ? textNode.parentElement : el;\n const source = classify(owner, raw.trim());\n const type = source.type || 'static-label';\n parts.push({\n text: raw,\n type: type,\n editable: source.editable !== false\n });\n }\n if (!parts.length) {\n const text = String(el.textContent || '').replace(/\\s+/g, ' ').trim();\n if (text) {\n const source = classify(el, text);\n parts.push({\n text: text,\n type: source.type || 'static-label',\n editable: source.editable !== false\n });\n }\n }\n return parts;\n }\n\n function summarizeUnitSource(parts, classify, el, fullText) {\n const types = {};\n parts.forEach(function (p) {\n types[p.type] = (types[p.type] || 0) + 1;\n });\n const hasDynamic = !!types['dynamic-data'];\n const hasBound = !!types['bound-unknown'];\n const hasStatic = !!types['static-label'] || !!types['unknown'];\n\n if (hasDynamic && (hasStatic || hasBound)) {\n return {\n type: 'mixed',\n editable: parts.some(function (p) {\n return p.editable;\n }),\n chips: ['Mixed'].concat(hasDynamic ? ['Dynamic'] : []).concat(hasBound ? ['Bound'] : [])\n };\n }\n if (hasDynamic && !hasStatic && !hasBound) {\n return { type: 'dynamic-data', editable: false, chips: ['Dynamic'] };\n }\n if (hasBound && !hasStatic) {\n return { type: 'bound-unknown', editable: false, chips: ['Bound'] };\n }\n const whole = classify(el, fullText);\n return {\n type: whole.type || 'static-label',\n editable: whole.editable !== false,\n chips: [\n whole.type === 'dynamic-data'\n ? 'Dynamic'\n : whole.type === 'bound-unknown'\n ? 'Bound'\n : 'Static'\n ]\n };\n }\n\n function buildUnit(el, classify, pathIndex) {\n const text = String(el.textContent || '').replace(/\\s+/g, ' ').trim();\n if (!text) return null;\n const parts = analyzeUnitParts(el, classify);\n const source = summarizeUnitSource(parts, classify, el, text);\n const path = String(pathIndex);\n return {\n id: 'copy-' + path,\n path: path,\n text: text,\n previousText: text,\n originalText: text,\n role: unitRole(el),\n tagName: el.tagName.toLowerCase(),\n selector: cssPath(el),\n parts: parts,\n textSource: {\n type: source.type,\n editable: source.editable,\n chips: source.chips\n },\n editable: source.editable\n };\n }\n\n /**\n * Merge consecutive sibling body wrappers under the same parent into one field\n * so a sentence split across nested div/spans is not exploded into N fields.\n */\n function mergeSiblingBodyUnits(units) {\n if (units.length < 2) return units;\n const merged = [];\n let i = 0;\n while (i < units.length) {\n const cur = units[i];\n if (cur.role !== 'text') {\n merged.push(cur);\n i += 1;\n continue;\n }\n let j = i + 1;\n const group = [cur];\n while (j < units.length && units[j].role === 'text') {\n const a = document.querySelector(group[group.length - 1].selector);\n const b = document.querySelector(units[j].selector);\n if (\n a &&\n b &&\n a.parentElement &&\n a.parentElement === b.parentElement &&\n isLeafTextWrapper(a) &&\n isLeafTextWrapper(b)\n ) {\n group.push(units[j]);\n j += 1;\n } else {\n break;\n }\n }\n if (group.length === 1) {\n merged.push(cur);\n } else {\n const parent = document.querySelector(group[0].selector)?.parentElement;\n if (parent) {\n const classify = TS().classifyTextSource || function () {\n return { type: 'static-label', editable: true };\n };\n const unit = buildUnit(parent, classify, merged.length);\n if (unit) merged.push(unit);\n else merged.push(cur);\n } else {\n merged.push(cur);\n }\n }\n i = j;\n }\n return merged.map(function (u, idx) {\n const path = String(idx);\n return Object.assign({}, u, { path: path, id: u.id || 'copy-' + path });\n });\n }\n\n /**\n * Coalesce copy into meaningful units (id, optional role, text, selector).\n * Do not split a sentence across text nodes — keep one field per block;\n * chip dynamic/bound fragments inside the unit instead.\n */\n function extractTextNodes(root) {\n const classify = TS().classifyTextSource || function () {\n return { type: 'static-label', editable: true };\n };\n const units = [];\n const seen = new Set();\n\n function pushUnit(el) {\n if (!el || seen.has(el)) return;\n const unit = buildUnit(el, classify, units.length);\n if (!unit) return;\n seen.add(el);\n units.push(unit);\n }\n\n function walk(el) {\n if (!(el instanceof Element)) return;\n if (panel.contains(el) || el.id === 'crank-highlight') return;\n if (isCopyUnitRoot(el)) {\n pushUnit(el);\n return; // do not split children into separate units\n }\n Array.from(el.children).forEach(walk);\n }\n\n walk(root);\n\n // Fallback: whole selection as one body if nothing matched\n if (!units.length) {\n const unit = buildUnit(root, classify, 0);\n if (unit) units.push(unit);\n }\n\n return assignUnitLabels(mergeSiblingBodyUnits(units));\n }\n\n function resolveUnitElement(root, node) {\n if (!root || !node) return null;\n if (node.selector) {\n try {\n const found = document.querySelector(node.selector);\n if (found && root.contains(found) && found !== root) return found;\n } catch (_) {}\n // Absolute cssPath may include ancestors; try the leaf selector inside root.\n try {\n const leaf = String(node.selector)\n .split('>')\n .pop()\n .trim();\n if (leaf) {\n const candidates = root.querySelectorAll(leaf);\n if (candidates.length === 1 && candidates[0] !== root) {\n return candidates[0];\n }\n }\n } catch (_) {}\n }\n return null;\n }\n\n function applyNodesToDom(root, nodes) {\n if (!root || !Array.isArray(nodes)) return 0;\n // Resolve all targets before mutating — never fall back to wiping `root`\n // (that applied one string to the whole selection).\n const resolved = nodes.map(function (node) {\n return { node: node, el: resolveUnitElement(root, node) };\n });\n\n // Path-index fallback against live copy units when selectors miss.\n const needPath = resolved.some(function (r) {\n return !r.el;\n });\n let byPath = null;\n if (needPath) {\n byPath = new Map();\n extractTextNodes(root).forEach(function (u) {\n try {\n const el = document.querySelector(u.selector);\n if (el && root.contains(el) && el !== root) {\n byPath.set(String(u.path), el);\n }\n } catch (_) {}\n });\n }\n\n let applied = 0;\n for (let i = 0; i < resolved.length; i++) {\n const node = resolved[i].node;\n let el = resolved[i].el;\n if (!el && byPath && node.path != null) {\n el = byPath.get(String(node.path)) || null;\n }\n if (!el) continue;\n const next = String(node.text || '');\n if (\n String(el.textContent || '')\n .replace(/\\s+/g, ' ')\n .trim() === next.replace(/\\s+/g, ' ').trim()\n ) {\n continue;\n }\n el.textContent = next;\n applied += 1;\n }\n return applied;\n }\n\n function restoreOriginal() {\n if (selectedEl && originalSnapshot.length) {\n applyNodesToDom(selectedEl, originalSnapshot);\n }\n }\n\n function connectWs() {\n try {\n socket = new WebSocket(wsUrlWithToken());\n socket.addEventListener('open', function () {\n setStatus('Connected to companion');\n });\n socket.addEventListener('message', function (ev) {\n let msg;\n try {\n msg = JSON.parse(ev.data);\n } catch (_) {\n return;\n }\n if (msg.type === 'VARIANTS_READY' && msg.session) {\n sessionId = msg.session.id;\n gleanActiveEpoch = 0;\n hideThinking();\n renderVariants(msg.session.variants || []);\n setStatus('Glean returned ' + (msg.session.variants || []).length + ' options');\n const gleanBtn = panel.querySelector('#vc-glean');\n if (gleanBtn) gleanBtn.disabled = !sessionId;\n }\n if (msg.type === 'GLEAN_PROGRESS') {\n // Ignore progress once variants are on screen (or from a prior request).\n if (!gleanActiveEpoch) return;\n if (msg.sessionId && sessionId && msg.sessionId !== sessionId) return;\n showThinking(msg.progress || {});\n }\n if (msg.type === 'GLEAN_ERROR') {\n gleanActiveEpoch = 0;\n hideThinking();\n setStatus('Glean error: ' + (msg.error || 'unknown'));\n const gleanBtn = panel.querySelector('#vc-glean');\n if (gleanBtn) gleanBtn.disabled = !sessionId;\n }\n if (msg.type === 'APPLY_VARIANT' && selectedEl) {\n applyNodesToDom(selectedEl, msg.nodes || []);\n if (msg.variantId) activeVariantId = msg.variantId;\n markActiveVariantCard(activeVariantId);\n if (msg.preview) {\n setStatus('Previewed in DOM (not saved)');\n } else if (msg.persistResult) {\n setStatus(formatPersistStatus(msg.persistResult, msg.variantId));\n } else {\n setStatus('Applied variant in DOM');\n }\n }\n if (msg.type === 'APPLY_RESULT') {\n if (msg.persistResult) {\n setStatus(formatPersistStatus(msg.persistResult, msg.variantId));\n } else if (msg.preview) {\n setStatus('Previewed in DOM (not saved)');\n }\n }\n if (msg.type === 'PREVIEW_TEXT' && selectedEl) {\n applyNodesToDom(selectedEl, msg.nodes || []);\n }\n if (msg.type === 'DISCARD_TEXT_PREVIEW') {\n restoreOriginal();\n setStatus('Preview discarded');\n }\n if (msg.type === 'SAVE_RESULT') {\n const p = msg.payload || {};\n setStatus(\n p.success\n ? 'Saved to source' + (p.file ? ': ' + p.file : '')\n : 'Save failed: ' + (p.error || 'unknown')\n );\n }\n if (msg.type === 'AGENT_PROMPT_QUEUED') {\n const t = msg.task || {};\n if (t.prompt) {\n // HTTP path already finished handoff + clipboard; WS only refreshes UI.\n finishAgentQueue(t, {\n fromWs: true,\n skipCopy: true\n });\n } else {\n const wsMsg =\n 'Queued — paste in your current Cursor chat (or ask: ' +\n AGENT_SHORT_PROMPT +\n ')';\n showToast(wsMsg, 'ok');\n setStatus(wsMsg);\n }\n }\n if (msg.type === 'ERROR') {\n setStatus('Error: ' + (msg.error || 'unknown'));\n }\n });\n socket.addEventListener('close', function () {\n setStatus(\n 'Companion disconnected — retrying… (start with: npx crank)'\n );\n setTimeout(connectWs, 1500);\n });\n socket.addEventListener('error', function () {\n setStatus(\n 'Companion not running — start with npx crank / open :3344'\n );\n });\n } catch (err) {\n setStatus(\n 'Companion not running — start with npx crank / open :3344'\n );\n }\n }\n\n function sendWs(message) {\n if (socket && socket.readyState === WebSocket.OPEN) {\n socket.send(JSON.stringify(message));\n return true;\n }\n return false;\n }\n\n // Short default for selection/preview/save. Glean /api/variants needs 3–5 min\n // (Robot Content Designer ~90–140s) — callers must pass timeoutMs explicitly.\n const GLEAN_VARIANTS_TIMEOUT_MS = 300000;\n\n async function postJson(path, body, options) {\n const opts = options || {};\n const timeoutMs = opts.timeoutMs != null ? opts.timeoutMs : 20000;\n const controller =\n typeof AbortController !== 'undefined' ? new AbortController() : null;\n let timer = null;\n if (controller && timeoutMs > 0) {\n timer = setTimeout(function () {\n try {\n controller.abort();\n } catch (_) {\n /* ignore */\n }\n }, timeoutMs);\n }\n let res;\n try {\n res = await fetch(COMPANION_HTTP + path, {\n method: opts.method || 'POST',\n headers: authHeaders({ 'Content-Type': 'application/json' }),\n body: opts.method === 'GET' ? undefined : JSON.stringify(body),\n signal: controller ? controller.signal : undefined\n });\n } catch (err) {\n if (timer) clearTimeout(timer);\n if (err && err.name === 'AbortError') {\n throw new Error('Request timed out after ' + timeoutMs + 'ms');\n }\n const m = (err && err.message) || String(err || '');\n if (\n /Failed to fetch|NetworkError|Load failed|Network request failed|fetch failed/i.test(\n m\n )\n ) {\n throw new Error(\n 'Companion not running — start with npx crank / open :3344'\n );\n }\n throw err;\n }\n if (timer) clearTimeout(timer);\n let data = {};\n try {\n data = await res.json();\n } catch (_) {\n data = {};\n }\n if (!res.ok) {\n if (res.status === 401 && (data.code === 'TEAM_TOKEN_REQUIRED' || data.code === 'TEAM_TOKEN_INVALID')) {\n setTeamToken('');\n const ok = await ensureTeamToken();\n if (ok) {\n return postJson(path, body, options);\n }\n }\n const err = new Error(data.error || 'HTTP ' + res.status);\n err.status = res.status;\n err.data = data;\n throw err;\n }\n return data;\n }\n\n function formatPersistStatus(persistResult, variantId) {\n if (!persistResult) {\n return 'Applied in DOM';\n }\n const bits = ['Applied in DOM'];\n if (persistResult.summary) bits.push(persistResult.summary);\n const locked = (persistResult.results || []).filter(function (r) {\n return r.status === 'skipped' && r.reason === 'locked';\n });\n const failed = (persistResult.results || []).filter(function (r) {\n return r.status === 'failed';\n });\n if (locked.length) {\n bits.push(\n locked.length +\n ' locked/dynamic field(s) not written — use Agent or edit source'\n );\n }\n if (failed.length) {\n bits.push(\n 'Save failed: ' + (failed[0].message || 'could not write source')\n );\n }\n if (variantId && !persistResult.persisted && !failed.length && locked.length) {\n bits.push('(DOM only)');\n }\n return bits.join(' · ');\n }\n\n function roleLabel(role, node, index, nodes) {\n if (node && node.label) return node.label;\n if (node && nodes) return unitDisplayLabel(node, index, nodes);\n const r = normalizeRole(role);\n if (r === 'heading') return 'Heading';\n if (r === 'action' || r === 'button') return 'CTA';\n if (r === 'label') return 'Label';\n return 'Copy';\n }\n\n function sourceChips(textSource) {\n if (textSource && Array.isArray(textSource.chips) && textSource.chips.length) {\n return textSource.chips;\n }\n const type = textSource && textSource.type;\n if (type === 'dynamic-data') return ['Dynamic'];\n if (type === 'bound-unknown') return ['Bound'];\n if (type === 'mixed') return ['Mixed'];\n return ['Static'];\n }\n\n function buildPanelShell() {\n const ui = UI();\n const btn = ui.vpButtonHtml || function (v, label, attrs) {\n return '<button type=\"button\" id=\"' + (attrs && attrs.id || '') + '\">' + label + '</button>';\n };\n const group = ui.vpButtonGroupHtml || function (html) {\n return '<div style=\"display:flex;gap:8px;flex-wrap:wrap;\">' + html + '</div>';\n };\n\n panel.innerHTML =\n '<div class=\"vp-panel-block\" style=\"padding:var(--vp-panel-padding, 1rem);\">' +\n '<div style=\"display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;\">' +\n '<strong style=\"font-size:var(--vp-font-size-body,14px);font-weight:var(--vp-font-weight-title,600);\">Crank</strong>' +\n btn('ghost', '×', { id: 'vc-close' }, { icon: true, size: 'compact' }) +\n '</div>' +\n '<p id=\"vc-status\" class=\"vp-muted vp-type-support\" style=\"margin:0 0 10px;\">Loading UI…</p>' +\n '<div id=\"vc-toast\" style=\"display:none;\"></div>' +\n '<div id=\"vc-glean-options\" style=\"margin:0 0 10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;\">' +\n '<label for=\"vc-option-count\" class=\"vp-type-label\" style=\"display:flex;align-items:center;gap:6px;cursor:help;\" title=\"Number of new rewritten alternatives (original draft is not shown). Fewer may reduce wait slightly; most latency is still agent runtime (~1–2 min).\">' +\n 'New alternatives' +\n '<span class=\"vp-muted vp-type-micro\" aria-hidden=\"true\">(?)</span>' +\n '</label>' +\n '<select id=\"vc-option-count\" class=\"vp-type-support\" style=\"min-width:4.5rem;padding:4px 8px;border:1px solid var(--border);border-radius:6px;background:var(--background);color:var(--foreground);\">' +\n [2, 3, 4, 5, 6]\n .map(function (n) {\n return (\n '<option value=\"' +\n n +\n '\"' +\n (n === DEFAULT_GLEAN_OPTION_COUNT ? ' selected' : '') +\n '>' +\n n +\n '</option>'\n );\n })\n .join('') +\n '</select>' +\n '</div>' +\n group(\n btn('primary', 'Select', { id: 'vc-select' }, { size: 'compact', flex: true }) +\n btn('secondary', 'Ask Glean', { id: 'vc-glean', disabled: true }, { size: 'compact', flex: true }),\n { stretch: true }\n ) +\n '<div id=\"vc-edit\" style=\"margin-top:12px;\"></div>' +\n '<div id=\"vc-thinking\" class=\"vp-muted vp-type-support\" style=\"display:none;margin-top:10px;padding:10px;border:1px solid var(--border);border-radius:8px;white-space:pre-wrap;line-height:1.5;font-size:12px;background:oklch(0.97 0.01 180);max-height:none;overflow:visible;\"></div>' +\n '<div id=\"vc-actions\" style=\"margin-top:10px;\">' +\n group(\n btn('secondary', 'Preview', { id: 'vc-preview', disabled: true }, { size: 'compact', flex: true }) +\n btn('ghost', 'Discard', { id: 'vc-discard', disabled: true }, { size: 'compact', flex: true }) +\n btn('primary', 'Save', { id: 'vc-save', disabled: true }, { size: 'compact', flex: true }),\n { stretch: true }\n ) +\n '</div>' +\n '<div id=\"vc-variants\" style=\"margin-top:12px;\"></div>' +\n '<div id=\"vc-agent-handoff\" style=\"display:none;margin:12px 0 0;padding:10px;border:1px solid var(--border);border-radius:8px;background:oklch(0.97 0.01 180);\"></div>' +\n '</div>';\n\n document.documentElement.appendChild(panel);\n const optionCountSel = panel.querySelector('#vc-option-count');\n if (optionCountSel) {\n try {\n const saved = localStorage.getItem(OPTION_COUNT_KEY);\n if (saved && [2, 3, 4, 5, 6].indexOf(parseInt(saved, 10)) >= 0) {\n optionCountSel.value = String(parseInt(saved, 10));\n }\n } catch (_) {}\n optionCountSel.addEventListener('change', function () {\n try {\n localStorage.setItem(OPTION_COUNT_KEY, optionCountSel.value);\n } catch (_) {}\n });\n }\n panel.querySelector('#vc-close').addEventListener('click', teardown);\n panel.querySelector('#vc-select').addEventListener('click', startSelect);\n panel.querySelector('#vc-glean').addEventListener('click', requestGlean);\n panel.querySelector('#vc-preview').addEventListener('click', previewEdits);\n panel.querySelector('#vc-discard').addEventListener('click', discardEdits);\n panel.querySelector('#vc-save').addEventListener('click', saveEdits);\n // Event delegation so Agent/Preview/Apply keep working after re-renders.\n const variantsBox = panel.querySelector('#vc-variants');\n if (variantsBox && !variantsBox.__crankDelegated) {\n variantsBox.__crankDelegated = true;\n variantsBox.addEventListener('click', function (ev) {\n const target = ev.target && ev.target.closest\n ? ev.target.closest(\n '[data-variant-agent],[data-variant-preview],[data-variant-apply]'\n )\n : null;\n if (!target || !variantsBox.contains(target)) return;\n if (ev.preventDefault) ev.preventDefault();\n if (ev.stopPropagation) ev.stopPropagation();\n const agentId = target.getAttribute('data-variant-agent');\n if (agentId) {\n queueAgentVariant(agentId);\n return;\n }\n const previewId = target.getAttribute('data-variant-preview');\n if (previewId) {\n applyVariant(previewId, { syncEditors: false, persist: false });\n return;\n }\n const applyId = target.getAttribute('data-variant-apply');\n if (applyId) {\n applyVariant(applyId, { syncEditors: true, persist: true });\n }\n });\n }\n }\n\n function renderNodes() {\n const ui = UI();\n const editEl = panel.querySelector('#vc-edit');\n if (!editEl) return;\n\n if (!selectedNodes.length) {\n editEl.innerHTML = '';\n setEditButtons(false);\n return;\n }\n\n const field = ui.vpFieldHtml || function (label, control) {\n return '<div><div>' + label + '</div>' + control + '</div>';\n };\n const area = ui.vpTextareaHtml || function (attrs, content) {\n return '<textarea id=\"' + (attrs.id || '') + '\"' + (attrs.disabled ? ' disabled' : '') + '>' + (content || '') + '</textarea>';\n };\n const badge = ui.vpCapabilityBadgeHtml || function (_mode, text) {\n return '<span class=\"vp-type-micro\">' + text + '</span>';\n };\n const escape = ui.escapeHtml || function (v) {\n return String(v || '');\n };\n\n editEl.innerHTML = selectedNodes\n .map(function (n, idx) {\n const label = roleLabel(n.role, n, idx, selectedNodes);\n const chips = sourceChips(n.textSource);\n const mode = n.editable ? 'editable' : 'read-only';\n const meta =\n chips\n .map(function (chip) {\n return badge(mode, chip);\n })\n .join(' ') +\n (n.editable\n ? ''\n : '<span class=\"vp-muted vp-type-micro\"> Locked</span>');\n\n const dynamicHints = (n.parts || [])\n .filter(function (p) {\n return p.type === 'dynamic-data' || p.type === 'bound-unknown';\n })\n .map(function (p) {\n return (\n '<span class=\"vp-type-micro\" style=\"display:inline-block;margin:2px 4px 0 0;padding:1px 6px;border:1px dashed var(--border);border-radius:999px;\">' +\n escape(String(p.text || '').trim()) +\n '</span>'\n );\n })\n .join('');\n\n if (!n.editable) {\n return (\n '<div class=\"vp-field\" style=\"margin-bottom:10px;\">' +\n '<div style=\"display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:4px;\">' +\n '<span class=\"vp-type-label\">' +\n escape(label) +\n '</span>' +\n meta +\n '</div>' +\n '<div class=\"vp-type-support\" style=\"padding:8px;border:1px dashed var(--border);border-radius:8px;opacity:0.85;\">' +\n escape(n.text) +\n '</div>' +\n (dynamicHints\n ? '<div style=\"margin-top:4px;\">' + dynamicHints + '</div>'\n : '') +\n '</div>'\n );\n }\n\n return (\n field(\n label,\n area(\n { id: 'vc-edit-' + n.path, rows: '2', 'data-path': n.path },\n n.text,\n { size: 'compact' }\n ),\n { size: 'compact', metaHtml: meta }\n ) +\n (dynamicHints\n ? '<div class=\"vp-muted vp-type-micro\" style=\"margin:-4px 0 10px;\">Vars: ' +\n dynamicHints +\n '</div>'\n : '')\n );\n })\n .join('');\n\n const anyEditable = selectedNodes.some(function (n) {\n return n.editable;\n });\n setEditButtons(anyEditable);\n }\n\n function setEditButtons(enabled) {\n ['vc-preview', 'vc-discard', 'vc-save', 'vc-glean'].forEach(function (id) {\n const el = panel.querySelector('#' + id);\n if (el) el.disabled = !enabled && id !== 'vc-glean' ? true : !sessionId && id === 'vc-glean' ? true : !enabled && id !== 'vc-glean';\n });\n const glean = panel.querySelector('#vc-glean');\n if (glean) glean.disabled = !sessionId;\n const preview = panel.querySelector('#vc-preview');\n const discard = panel.querySelector('#vc-discard');\n const save = panel.querySelector('#vc-save');\n if (preview) preview.disabled = !enabled;\n if (discard) discard.disabled = !enabled;\n if (save) save.disabled = !enabled;\n }\n\n function readEditedNodes() {\n return selectedNodes.map(function (n) {\n const input = panel.querySelector('#vc-edit-' + n.path);\n if (input && n.editable) {\n return Object.assign({}, n, { text: input.value });\n }\n return n;\n });\n }\n\n function previewEdits() {\n const nodes = readEditedNodes();\n if (selectedEl) applyNodesToDom(selectedEl, nodes);\n selectedNodes = nodes;\n sendWs({\n type: 'PREVIEW_TEXT_CHANGE',\n sessionId: sessionId,\n payload: { sessionId: sessionId, nodes: nodes }\n });\n setStatus('DOM preview applied (not saved to source yet)');\n }\n\n function discardEdits() {\n restoreOriginal();\n selectedNodes = originalSnapshot.map(function (n) {\n return Object.assign({}, n);\n });\n renderNodes();\n sendWs({ type: 'DISCARD_TEXT_PREVIEW', sessionId: sessionId });\n setStatus('Discarded — restored original DOM text');\n }\n\n function pathHintsFromLocation() {\n const hints = [];\n try {\n const pathname = String(location.pathname || '/');\n if (pathname && pathname !== '/') {\n hints.push(pathname);\n hints.push(pathname.replace(/^\\//, ''));\n const base = pathname.split('/').pop();\n if (base) hints.push(base);\n } else {\n hints.push('/index.html', 'index.html');\n }\n } catch (_) {}\n return hints;\n }\n\n async function saveEdits() {\n const nodes = readEditedNodes();\n previewEdits();\n setStatus('Saving editable strings to source…');\n const selector = selectedEl ? cssPath(selectedEl) : '';\n let saved = 0;\n let failed = 0;\n for (const node of nodes) {\n if (!node.editable) continue;\n if (String(node.text) === String(node.previousText || originalSnapshot.find(function (o) { return o.path === node.path; })?.text)) {\n continue;\n }\n const oldValue =\n (originalSnapshot.find(function (o) {\n return o.path === node.path;\n }) || {}).text || node.previousText || node.originalText;\n const payload = {\n selectorText: node.selector || selector,\n value: node.text,\n oldValue: oldValue,\n sourceUrl: String(document.baseURI || location.href),\n origin: String(location.origin || ''),\n pageUrl: String(location.href || ''),\n pathHints: pathHintsFromLocation()\n };\n const sent = sendWs({ type: 'SAVE_TEXT_CHANGE', payload: payload });\n if (!sent) {\n const data = await postJson('/api/save-text', payload);\n if (data.ok || data.success) saved += 1;\n else failed += 1;\n } else {\n saved += 1;\n }\n }\n setStatus(\n failed\n ? 'Save finished with errors (see status / companion logs)'\n : 'Save dispatched for ' + saved + ' string(s)'\n );\n }\n\n function variantFieldRows(variant) {\n const fields = variant && variant.fields ? variant.fields : null;\n const rows = [];\n const sessionLabels = (selectedNodes || []).map(function (n, idx) {\n return roleLabel(n.role, n, idx, selectedNodes);\n });\n if (\n fields &&\n (fields.heading ||\n fields.body ||\n fields.cta ||\n (Array.isArray(fields.bodies) && fields.bodies.length))\n ) {\n if (fields.heading) {\n rows.push({ label: sessionLabels[0] || 'Heading', text: fields.heading });\n }\n if (Array.isArray(fields.bodies) && fields.bodies.length) {\n fields.bodies.forEach(function (t, i) {\n if (String(t || '').trim()) {\n rows.push({\n label: sessionLabels[rows.length] || 'Body ' + (i + 1),\n text: t\n });\n }\n });\n } else if (fields.body) {\n rows.push({\n label: sessionLabels[rows.length] || 'Body',\n text: fields.body\n });\n }\n if (fields.cta) {\n rows.push({\n label: sessionLabels[sessionLabels.length - 1] || 'CTA',\n text: fields.cta\n });\n }\n return rows;\n }\n (variant.nodes || []).forEach(function (n, idx) {\n const text = String(n && n.text != null ? n.text : '').trim();\n if (!text) return;\n rows.push({\n label: roleLabel(n.role, n, idx, variant.nodes),\n text: text\n });\n });\n return rows;\n }\n\n function markActiveVariantCard(variantId) {\n const box = panel.querySelector('#vc-variants');\n if (!box) return;\n box.querySelectorAll('[data-variant-card]').forEach(function (card) {\n const active = card.getAttribute('data-variant-card') === String(variantId || '');\n card.style.borderColor = active\n ? 'color-mix(in oklch, var(--ring, #888) 45%, var(--border))'\n : '';\n card.style.boxShadow = active\n ? '0 0 0 1px color-mix(in oklch, var(--ring, #888) 25%, transparent)'\n : '';\n });\n }\n\n function syncEditorsFromVariant(variant) {\n if (!variant || !Array.isArray(variant.nodes) || !variant.nodes.length) return;\n const byPath = {};\n const byId = {};\n variant.nodes.forEach(function (n) {\n byPath[String(n.path)] = n;\n if (n.id) byId[String(n.id)] = n;\n });\n selectedNodes = selectedNodes.map(function (n) {\n const next = (n.id && byId[String(n.id)]) || byPath[String(n.path)];\n if (!next) return n;\n return Object.assign({}, n, { text: next.text });\n });\n // If selection was empty of matching paths, fall back to variant nodes.\n if (!selectedNodes.length) {\n selectedNodes = variant.nodes.map(function (n) {\n return Object.assign({}, n);\n });\n }\n renderNodes();\n setEditButtons(true);\n }\n\n function renderVariantCard(variant, ui) {\n const escape = ui.escapeHtml || function (v) {\n return String(v == null ? '' : v);\n };\n const escapeAttr = ui.escapeAttr || escape;\n const btn = ui.vpButtonHtml || function (_v, label, attrs) {\n const a = attrs || {};\n let attrHtml = ' type=\"button\"';\n Object.keys(a).forEach(function (key) {\n if (key === 'type' || a[key] == null || a[key] === false) return;\n if (a[key] === true) {\n attrHtml += ' ' + key;\n return;\n }\n attrHtml += ' ' + key + '=\"' + escapeAttr(String(a[key])) + '\"';\n });\n return '<button' + attrHtml + '>' + label + '</button>';\n };\n const group = ui.vpButtonGroupHtml || function (html) {\n return '<div style=\"display:flex;gap:8px;flex-wrap:wrap;\">' + html + '</div>';\n };\n const badge = ui.vpCapabilityBadgeHtml || function (_mode, text) {\n return '<span class=\"vp-chip\">' + escape(text) + '</span>';\n };\n const title = variant.label || variant.title || variant.id || 'Option';\n const rows = variantFieldRows(variant);\n let bodyHtml = '';\n\n if (rows.length) {\n bodyHtml = rows\n .map(function (row) {\n return (\n '<div class=\"vc-variant-field\" style=\"margin-top:8px;\">' +\n '<div class=\"vp-muted vp-type-micro\" style=\"margin-bottom:2px;\">' +\n escape(row.label || 'Copy') +\n '</div>' +\n '<div class=\"vp-type-body\" style=\"white-space:pre-wrap;word-break:break-word;color:var(--foreground);\">' +\n escape(row.text) +\n '</div>' +\n '</div>'\n );\n })\n .join('');\n } else if (variant.rawText) {\n bodyHtml =\n '<div class=\"vp-type-support\" style=\"margin-top:8px;white-space:pre-wrap;word-break:break-word;line-height:1.4;color:var(--foreground);\">' +\n escape(String(variant.rawText)) +\n '</div>';\n } else if (variant.summary) {\n bodyHtml =\n '<div class=\"vp-muted vp-type-support\" style=\"margin-top:8px;white-space:pre-wrap;word-break:break-word;\">' +\n escape(variant.summary) +\n '</div>';\n }\n\n const recommended = !!variant.recommended;\n return (\n '<article class=\"vp-panel-block vc-variant-card' +\n (recommended ? ' is-recommended' : '') +\n '\" data-variant-card=\"' +\n escape(variant.id) +\n '\" style=\"padding:10px 12px;margin-bottom:8px;\">' +\n '<div style=\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px;\">' +\n '<div class=\"vp-type-label\" style=\"font-weight:var(--vp-font-weight-title,620);color:var(--foreground);line-height:var(--vp-line-height-label,1.4);\">' +\n escape(title) +\n '</div>' +\n (recommended ? badge('preview', 'Recommended') : '') +\n '</div>' +\n bodyHtml +\n '<div style=\"margin-top:10px;\">' +\n group(\n btn(\n 'secondary',\n 'Preview',\n { 'data-variant-preview': variant.id, type: 'button' },\n { size: 'compact', flex: true }\n ) +\n btn(\n 'primary',\n 'Apply',\n { 'data-variant-apply': variant.id, type: 'button' },\n { size: 'compact', flex: true }\n ) +\n btn(\n 'secondary',\n 'Agent',\n { 'data-variant-agent': variant.id, type: 'button' },\n { size: 'compact', flex: true }\n ),\n { stretch: true }\n ) +\n '</div>' +\n '</article>'\n );\n }\n\n function renderVariants(variants) {\n const box = panel.querySelector('#vc-variants');\n const ui = UI();\n const escape = ui.escapeHtml || String;\n lastVariants = Array.isArray(variants) ? variants : [];\n if (!lastVariants.length) {\n box.innerHTML = '';\n return;\n }\n const titleHtml = ui.vpSectionTitleHtml\n ? ui.vpSectionTitleHtml('New alternatives')\n : '<div class=\"vp-section-title\"><span>' + escape('New alternatives') + '</span></div>';\n box.innerHTML =\n '<div style=\"margin-bottom:8px;\">' +\n titleHtml +\n '</div>' +\n lastVariants.map(function (v) {\n return renderVariantCard(v, ui);\n }).join('');\n\n markActiveVariantCard(activeVariantId);\n }\n\n async function queueAgentVariant(variantId) {\n if (!sessionId) {\n const msg = 'Select a region and Ask Glean before using Agent';\n showToast(msg, 'error');\n setStatus(msg);\n return;\n }\n if (!variantId) {\n const msg = 'Agent: missing variant id';\n showToast(msg, 'error');\n setStatus(msg);\n return;\n }\n const local = lastVariants.find(function (v) {\n return v && v.id === variantId;\n });\n activeVariantId = variantId;\n markActiveVariantCard(variantId);\n showToast('Queueing for Cursor agent…', 'info');\n setStatus('Queueing for Cursor agent…');\n\n // Prefer HTTP so we always get the executable prompt for clipboard + handoff UI.\n // (WS-only fire-and-forget looked like “nothing happened” when Cursor chat stayed quiet.)\n try {\n const data = await postJson(\n '/api/agent-prompt',\n {\n sessionId: sessionId,\n variantId: variantId,\n source: 'panel'\n },\n { timeoutMs: 15000 }\n );\n const task = (data && data.task) || {};\n if (!task.prompt) {\n throw new Error(\n (data && data.error) || 'Companion returned no agent prompt'\n );\n }\n if (!task.variantLabel && local && local.label) {\n task.variantLabel = local.label;\n }\n await finishAgentQueue(task, { force: true });\n } catch (err) {\n const raw = err && err.message ? String(err.message) : 'unknown';\n const down =\n /Companion not running|Failed to fetch|NetworkError|Load failed/i.test(\n raw\n );\n const msg = down\n ? 'Companion not running — start with npx crank / open :3344'\n : 'Agent queue failed: ' + raw;\n showAgentHandoffError(\n msg,\n down\n ? 'Expected companion at ' + COMPANION_HTTP\n : 'Is the companion running at ' + COMPANION_HTTP + '?'\n );\n showToast(msg, 'error');\n setStatus(msg);\n }\n }\n\n async function applyVariant(variantId, options) {\n if (!sessionId) return;\n const opts = options || {};\n const persist = opts.persist === true;\n const local = lastVariants.find(function (v) {\n return v && v.id === variantId;\n });\n setStatus(persist ? 'Applying (DOM + source)…' : 'Previewing…');\n activeVariantId = variantId;\n markActiveVariantCard(variantId);\n\n if (persist) {\n if (\n sendWs({\n type: 'APPLY_VARIANT',\n sessionId: sessionId,\n variantId: variantId,\n persist: true\n })\n ) {\n if (opts.syncEditors && local) syncEditorsFromVariant(local);\n return;\n }\n try {\n const data = await postJson('/api/apply', {\n sessionId: sessionId,\n variantId: variantId,\n persist: true\n });\n if (data.ok && selectedEl) {\n const variant = data.variant || local;\n applyNodesToDom(selectedEl, (variant && variant.nodes) || []);\n if (opts.syncEditors && variant) syncEditorsFromVariant(variant);\n setStatus(\n formatPersistStatus(\n data.persistResult,\n (variant && variant.label) || variantId\n )\n );\n } else {\n setStatus('Apply error: ' + (data.error || 'failed'));\n }\n } catch (err) {\n setStatus('Apply error: ' + (err.message || 'failed'));\n }\n return;\n }\n\n // Preview — DOM only\n if (\n sendWs({\n type: 'PREVIEW_VARIANT',\n sessionId: sessionId,\n variantId: variantId\n })\n ) {\n return;\n }\n try {\n const data = await postJson('/api/preview-variant', {\n sessionId: sessionId,\n variantId: variantId\n });\n if (data.ok && selectedEl) {\n const variant = data.variant || local;\n applyNodesToDom(selectedEl, (variant && variant.nodes) || []);\n setStatus(\n 'Previewed \"' +\n ((variant && variant.label) || variantId) +\n '\" in DOM (not saved)'\n );\n } else {\n setStatus('Preview error: ' + (data.error || 'failed'));\n }\n } catch (err) {\n // Fallback: local DOM only if companion route missing\n if (local && selectedEl) {\n applyNodesToDom(selectedEl, local.nodes || []);\n setStatus(\n 'Previewed \"' +\n (local.label || variantId) +\n '\" in DOM (local; companion preview unavailable)'\n );\n } else {\n setStatus('Preview error: ' + (err.message || 'failed'));\n }\n }\n }\n\n let thinkingLivePartial = '';\n let lastThinkingProgress = null;\n let thinkingStartedAt = 0;\n let thinkingUiTimer = null;\n\n function thinkingElapsedLine() {\n if (!thinkingStartedAt) return 'Elapsed 0s';\n const secs = Math.round((Date.now() - thinkingStartedAt) / 1000);\n return 'Elapsed ' + secs + 's';\n }\n\n /** Last N lines of stream text for paragraph-style thinking display. */\n function streamingBodyLines(partial, maxLines) {\n maxLines = maxLines || 3;\n const raw = String(partial || '').trim();\n if (!raw) return [];\n\n let lines = raw.split(/\\n/).map(function (l) {\n return l.trim();\n }).filter(Boolean);\n if (lines.length > maxLines) {\n return lines.slice(-maxLines);\n }\n if (lines.length >= 2) return lines;\n\n const compact = raw.replace(/\\s+/g, ' ').trim();\n const tail = compact.length > 320 ? '…' + compact.slice(-317) : compact;\n const width = 88;\n const wrapped = [];\n for (let i = tail.length; i > 0 && wrapped.length < maxLines; ) {\n const start = Math.max(0, i - width);\n const piece = tail.slice(start, i).trim();\n if (piece) wrapped.unshift(piece);\n i = start;\n if (start === 0) break;\n }\n return wrapped.slice(-maxLines);\n }\n\n function resolveLatestThought(progress) {\n if (!progress) return 'Waiting on Glean…';\n\n const elapsedSec = thinkingStartedAt\n ? Math.round((Date.now() - thinkingStartedAt) / 1000)\n : 0;\n const stage = progress.stage ? String(progress.stage) : '';\n const genericConnected = 'Connected — agent running…';\n\n if (\n progress.latestThought &&\n progress.latestThought !== genericConnected &&\n !/^Streaming agent output/i.test(String(progress.latestThought))\n ) {\n return String(progress.latestThought);\n }\n\n if (progress.streamChars > 0 && !(progress.live && progress.partial)) {\n return 'Generating copy… (' + progress.streamChars + ' chars received)';\n }\n\n if (stage === 'start') {\n return progress.latestThought || progress.text || 'Sending to Glean…';\n }\n\n // Silent stream / wait — Glean often sends no tokens for ~1 min; rotate by elapsed.\n if (stage === 'stream_wait' || stage === 'waiting' || progress.text === genericConnected) {\n if (elapsedSec < 15) {\n return progress.text || 'Connecting to live agent stream…';\n }\n if (elapsedSec < 35) return 'Agent is analyzing your copy…';\n if (elapsedSec < 60) return 'Agent is drafting alternatives…';\n if (elapsedSec < 90) return 'Agent is formatting options…';\n return 'Agent is finishing the response…';\n }\n\n if (progress.text) return String(progress.text);\n return 'Waiting on Glean…';\n }\n\n function renderThinking() {\n const el = panel.querySelector('#vc-thinking');\n if (!el || !gleanActiveEpoch) return;\n const progress = lastThinkingProgress || {};\n el.style.display = 'block';\n\n if (progress.live && progress.partial) {\n thinkingLivePartial = String(progress.partial);\n }\n\n const elapsed = thinkingElapsedLine();\n const displayLines = [];\n const partial = progress.partial || thinkingLivePartial;\n\n if (progress.live && partial) {\n const body = streamingBodyLines(partial, 3);\n if (body.length) {\n for (let i = 0; i < body.length; i++) displayLines.push(body[i]);\n } else {\n displayLines.push(resolveLatestThought(progress));\n }\n } else {\n displayLines.push(resolveLatestThought(progress));\n if (progress.detail && String(progress.detail).trim()) {\n displayLines.push(String(progress.detail).trim());\n }\n }\n\n displayLines.push(elapsed);\n\n el.style.whiteSpace = 'pre-wrap';\n el.style.overflow = 'hidden';\n el.style.textOverflow = '';\n el.style.maxHeight = 'min(6.5em, 96px)';\n el.style.lineHeight = '1.45';\n el.style.overflowY = 'auto';\n el.style.overflowX = 'hidden';\n el.style.fontFamily = progress.live && partial\n ? 'ui-monospace,SFMono-Regular,Menlo,monospace'\n : '';\n el.textContent = displayLines.join('\\n');\n el.scrollTop = el.scrollHeight;\n\n const stage = progress.stage ? String(progress.stage) : '';\n const statusPreview = displayLines[0] || '';\n if (stage === 'stub' || stage === 'done' || stage === 'fallback') {\n setStatus(statusPreview);\n } else if (stage === 'start') {\n setStatus('Sending to Glean…');\n } else if (progress.live) {\n setStatus('Glean is streaming…');\n } else {\n setStatus('Waiting on Glean…');\n }\n }\n\n function showThinking(progress) {\n lastThinkingProgress = progress || {};\n renderThinking();\n }\n\n function startThinkingClock() {\n thinkingStartedAt = Date.now();\n lastThinkingProgress = null;\n thinkingLivePartial = '';\n if (thinkingUiTimer) clearInterval(thinkingUiTimer);\n thinkingUiTimer = setInterval(function () {\n if (!gleanActiveEpoch) return;\n renderThinking();\n }, 1000);\n }\n\n function stopThinkingClock() {\n if (thinkingUiTimer) {\n clearInterval(thinkingUiTimer);\n thinkingUiTimer = null;\n }\n thinkingStartedAt = 0;\n lastThinkingProgress = null;\n thinkingLivePartial = '';\n }\n\n function hideThinking() {\n const el = panel.querySelector('#vc-thinking');\n if (el) {\n el.style.display = 'none';\n el.textContent = '';\n el.style.maxHeight = '';\n el.style.overflowY = '';\n el.style.overflowX = '';\n el.style.fontFamily = '';\n }\n stopThinkingClock();\n }\n\n async function requestGlean() {\n if (!sessionId) return;\n if (gleanActiveEpoch) {\n setStatus('Glean request already in progress…');\n return;\n }\n const epoch = ++gleanEpoch;\n gleanActiveEpoch = epoch;\n setStatus('Waiting on Glean…');\n startThinkingClock();\n showThinking({\n stage: 'stream_wait',\n text: 'Connecting to live agent stream…',\n detail: 'Robot Content Designer · usually 1–2 min',\n live: false\n });\n panel.querySelector('#vc-glean').disabled = true;\n let progressPoll = null;\n const pollProgress = async function () {\n if (gleanActiveEpoch !== epoch || !sessionId) return;\n try {\n const resp = await fetch(\n COMPANION_HTTP + '/api/glean-progress?sessionId=' + encodeURIComponent(sessionId),\n { cache: 'no-store', headers: authHeaders() }\n );\n const data = await resp.json();\n if (data.ok && data.progress && gleanActiveEpoch === epoch) {\n showThinking(data.progress);\n }\n } catch (_) {}\n };\n progressPoll = setInterval(pollProgress, 1000);\n pollProgress();\n // HTTP await is source of truth; WS + poll carry live GLEAN_PROGRESS.\n // Do not also send REQUEST_VARIANTS (would double-call Glean).\n try {\n const data = await postJson(\n '/api/variants',\n {\n sessionId: sessionId,\n optionCount: getGleanOptionCount(),\n options: { optionCount: getGleanOptionCount() }\n },\n { timeoutMs: GLEAN_VARIANTS_TIMEOUT_MS }\n );\n if (gleanActiveEpoch !== epoch) return;\n if (!data.ok) throw new Error(data.error || 'Glean failed');\n gleanActiveEpoch = 0;\n hideThinking();\n const variants = data.variants || (data.session && data.session.variants) || [];\n renderVariants(variants);\n setStatus('Glean returned ' + variants.length + ' options');\n } catch (err) {\n if (gleanActiveEpoch !== epoch) return;\n gleanActiveEpoch = 0;\n hideThinking();\n setStatus('Glean error: ' + err.message);\n } finally {\n if (progressPoll) clearInterval(progressPoll);\n if (gleanActiveEpoch === epoch) gleanActiveEpoch = 0;\n panel.querySelector('#vc-glean').disabled = !sessionId;\n }\n }\n\n function onMove(ev) {\n if (!selecting) return;\n const el = document.elementFromPoint(ev.clientX, ev.clientY);\n if (!el || panel.contains(el) || el === highlight) return;\n const target = el.closest('div') || el;\n const r = target.getBoundingClientRect();\n highlight.style.left = r.left + 'px';\n highlight.style.top = r.top + 'px';\n highlight.style.width = r.width + 'px';\n highlight.style.height = r.height + 'px';\n }\n\n async function onClick(ev) {\n if (!selecting) return;\n ev.preventDefault();\n ev.stopPropagation();\n const el = document.elementFromPoint(ev.clientX, ev.clientY);\n if (!el || panel.contains(el)) return;\n const target = el.closest('div') || el;\n stopSelect();\n selectedEl = target;\n selectedNodes = extractTextNodes(target);\n originalSnapshot = selectedNodes.map(function (n) {\n return Object.assign({}, n, {\n text: n.text,\n previousText: n.text,\n originalText: n.originalText != null ? n.originalText : n.text\n });\n });\n selectedNodes = selectedNodes.map(function (n) {\n return Object.assign({}, n, {\n originalText: n.originalText != null ? n.originalText : n.text\n });\n });\n renderNodes();\n setStatus('Selected ' + selectedNodes.length + ' copy field(s)');\n const payload = {\n origin: location.origin,\n pageUrl: location.href,\n selector: cssPath(target),\n tagName: target.tagName.toLowerCase(),\n nodes: selectedNodes,\n combinedText: selectedNodes.map(function (n) {\n return n.text;\n }).join('\\n'),\n meta: {\n pathHints: pathHintsFromLocation()\n }\n };\n try {\n if (sendWs({ type: 'SET_SELECTION', payload: payload })) {\n /* ack sets session via SELECTION — also POST for reliability */\n }\n const data = await postJson('/api/selection', payload);\n if (data.ok) {\n sessionId = data.session.id;\n panel.querySelector('#vc-glean').disabled = false;\n setStatus('Selection ready — preview/edit or Ask Glean');\n }\n } catch (err) {\n setStatus('Selection error: ' + err.message);\n }\n }\n\n function onKey(ev) {\n if (ev.key === 'Escape' && selecting) stopSelect();\n }\n\n function startSelect() {\n selecting = true;\n document.documentElement.appendChild(highlight);\n document.addEventListener('mousemove', onMove, true);\n document.addEventListener('click', onClick, true);\n document.addEventListener('keydown', onKey, true);\n setStatus('Hover a div, click to select. Esc cancels.');\n }\n\n function stopSelect() {\n selecting = false;\n highlight.remove();\n document.removeEventListener('mousemove', onMove, true);\n document.removeEventListener('click', onClick, true);\n document.removeEventListener('keydown', onKey, true);\n }\n\n function showReopenChip() {\n if (document.getElementById('vp-crank-reopen')) {\n return;\n }\n const chip = document.createElement('button');\n chip.id = 'vp-crank-reopen';\n chip.type = 'button';\n chip.textContent = 'Crank';\n chip.setAttribute('aria-label', 'Reopen Crank panel');\n chip.style.cssText =\n 'position:fixed;bottom:16px;right:16px;z-index:2147483646;padding:8px 12px;' +\n 'border-radius:999px;border:1px solid rgba(0,0,0,.12);background:#0f766e;color:#fff;' +\n 'font:600 12px/1.2 system-ui,sans-serif;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.18);';\n chip.addEventListener('click', function () {\n chip.remove();\n window.__CRANK_LOADER__ = false;\n window.__crankActive = false;\n if (typeof window.__crankBootEmbedded === 'function') {\n try {\n window.__crankBootEmbedded();\n return;\n } catch (_) {}\n }\n const s = document.createElement('script');\n if (window.__CRANK_ASSET_MAP__ && window.__CRANK_ASSET_MAP__['crank-bookmarklet.js']) {\n s.src = window.__CRANK_ASSET_MAP__['crank-bookmarklet.js'];\n } else if (ASSET_BASE && ASSET_BASE.indexOf('blob:crank/') !== 0) {\n s.src = ASSET_BASE + 'crank-bookmarklet.js?t=' + Date.now();\n } else {\n s.src = COMPANION_HTTP + '/bookmarklet.js?t=' + Date.now();\n }\n s.async = true;\n s.dataset.crankLoader = '1';\n document.documentElement.appendChild(s);\n });\n document.documentElement.appendChild(chip);\n }\n\n function teardown() {\n stopSelect();\n restoreOriginal();\n if (socket) socket.close();\n if (panel && panel.parentNode) panel.remove();\n if (highlight && highlight.parentNode) highlight.remove();\n window.__crankActive = false;\n window.__crankTeardown = null;\n window.__CRANK_LOADER__ = false;\n try {\n document.querySelectorAll('script[data-crank-loader=\"1\"]').forEach(function (el) {\n el.remove();\n });\n } catch (_) {}\n showReopenChip();\n }\n\n window.__crankTeardown = teardown;\n\n async function boot() {\n loadCss(assetUrl('crank-ui.css', '/ui/crank-ui.css') + '?v=' + Date.now());\n await loadScript(assetUrl('crank-ui-helpers.js', '/ui/crank-ui-helpers.js') + '?v=' + Date.now());\n await loadScript(assetUrl('text-source.js', '/ui/text-source.js') + '?v=' + Date.now());\n buildPanelShell();\n const authed = await ensureTeamToken();\n if (!authed) {\n setStatus('Crank team token required — reload after you have it');\n return;\n }\n connectWs();\n setStatus('Ready — companion ' + COMPANION_HTTP);\n }\n\n boot().catch(function (err) {\n console.error('[Crank] boot failed', err);\n if (panel && panel.parentNode) {\n panel.innerHTML =\n '<div class=\"vp-panel-block\" style=\"padding:var(--vp-panel-padding, 1rem);\">' +\n '<div class=\"vp-banner vp-banner--error vp-type-support\">Crank failed to load UI: ' +\n String(err && err.message ? err.message : err) +\n '</div></div>';\n }\n });\n})();\n";
3
+ export const CRANK_PANEL_CSS = "/* Crank inspector UI — shared with tool-ui design tokens (white theme) */\n@import url(\"https://cdn.jsdelivr.net/npm/@fontsource-variable/geist@5.2.8/index.css\");\n\n:host,\n#vp-shell {\n --background: oklch(0.985 0.002 255);\n --foreground: oklch(0.17 0.01 255);\n --card: oklch(1 0 0);\n --card-foreground: oklch(0.17 0.01 255);\n --primary: oklch(0.24 0.02 255);\n --primary-foreground: oklch(0.99 0 0);\n --secondary: oklch(0.95 0.004 255);\n --secondary-foreground: oklch(0.21 0.01 255);\n --muted: oklch(0.95 0.004 255);\n --muted-foreground: oklch(0.42 0.012 255);\n --accent: oklch(0.93 0.007 255);\n --accent-foreground: oklch(0.2 0.01 255);\n --hover: oklch(0.95 0.006 255);\n --active: oklch(0.92 0.009 255);\n --destructive: oklch(0.59 0.22 26);\n --input: oklch(0.9 0.005 255);\n --border: oklch(0.89 0.006 255);\n --ring: oklch(0.68 0.03 255);\n\n --vp-font-family-sans: \"Geist Variable\", ui-sans-serif, system-ui, sans-serif;\n --vp-font-size-micro: 11px;\n --vp-font-size-support: 12px;\n --vp-font-size-label: 13px;\n --vp-font-size-body: 14px;\n --vp-font-size-display: 16px;\n --vp-line-height-micro: 1.35;\n --vp-line-height-support: 1.4;\n --vp-line-height-label: 1.4;\n --vp-line-height-body: 1.45;\n --vp-line-height-display: 1.3;\n --vp-font-weight-regular: 400;\n --vp-font-weight-medium: 450;\n --vp-font-weight-semibold: 550;\n --vp-font-weight-title: 620;\n\n --vp-list-padding: 2px;\n --vp-list-gap: 2px;\n --vp-list-radius-inner: 0.8rem;\n --vp-list-radius-outer: calc(var(--vp-list-radius-inner) + var(--vp-list-padding));\n --vp-control-radius: 0.8rem;\n --vp-control-shell-height: 2.25rem;\n --vp-control-shell-radius: 12px;\n --vp-control-shell-padding-x: 0.875rem;\n --vp-control-shell-gap: 0.625rem;\n --vp-control-shell-font-size: 0.9375rem;\n --vp-control-shell-font-weight: var(--vp-font-weight-medium);\n --vp-control-shell-line-height: 1.15;\n\n --vp-compact-control-height: 1.75rem;\n --vp-compact-control-radius: calc(var(--vp-control-radius) * 0.5);\n --vp-compact-control-padding-x: 0.5rem;\n --vp-compact-control-gap: 0.25rem;\n --vp-compact-control-font-size: var(--vp-font-size-support);\n --vp-compact-control-font-weight: 500;\n --vp-compact-control-line-height: var(--vp-line-height-support);\n\n --vp-panel-padding: 1rem;\n --vp-inline-gap: 0.5rem;\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --ease-standard: cubic-bezier(0.2, 0, 0, 1);\n\n --vp-button-primary-gradient: linear-gradient(180deg, #323232 0%, #222222 100%);\n --vp-button-primary-gradient-hover: linear-gradient(180deg, #363636 0%, #252525 100%);\n --vp-button-primary-gradient-active: linear-gradient(180deg, #2e2e2e 0%, #1e1e1e 100%);\n --vp-button-primary-border: rgb(51 51 51 / 0.55);\n --vp-button-primary-shadow:\n inset 0 1px 0 0 rgb(255 255 255 / 0.15),\n inset 0 -1px 1.2px 0.35px #121212,\n 0 2px 4px -1px rgb(13 13 13 / 0.5);\n --vp-button-primary-shadow-hover:\n inset 0 1px 0 0 rgb(255 255 255 / 0.17),\n inset 0 -1px 1.2px 0.35px #121212,\n 0 2px 5px -1px rgb(13 13 13 / 0.52);\n --vp-button-primary-shadow-active:\n inset 0 1px 2px 0 rgb(0 0 0 / 0.28),\n inset 0 -0.5px 1px 0 rgb(255 255 255 / 0.07),\n 0 1px 2px -1px rgb(13 13 13 / 0.42);\n\n --vp-button-secondary-stroke: #d4d4d4;\n --vp-button-secondary-gradient: linear-gradient(180deg, #ffffff 0%, #f5f5f6 100%);\n --vp-button-secondary-gradient-hover: linear-gradient(180deg, #ffffff 0%, #f1f1f2 100%);\n --vp-button-secondary-gradient-active: linear-gradient(180deg, #f1f1f2 0%, #ebebec 100%);\n --vp-button-secondary-shadow:\n inset 0 1px 0 0 rgb(255 255 255 / 0.33),\n inset 0 -1px 1.2px 0.35px rgb(17 17 17 / 0.06),\n 0 2px 4px -1px rgb(0 0 0 / 0.15);\n --vp-button-secondary-shadow-hover:\n inset 0 1px 0 0 rgb(255 255 255 / 0.38),\n inset 0 -1px 1.2px 0.35px rgb(17 17 17 / 0.07),\n 0 2px 5px -1px rgb(0 0 0 / 0.16);\n --vp-button-secondary-shadow-active:\n inset 0 1px 2px 0 rgb(0 0 0 / 0.06),\n inset 0 -0.5px 1px 0 rgb(255 255 255 / 0.85),\n 0 1px 2px -1px rgb(0 0 0 / 0.12);\n\n --vp-input-surface-gradient: var(--vp-button-secondary-gradient);\n --vp-input-surface-gradient-hover: var(--vp-button-secondary-gradient-hover);\n --vp-input-surface-gradient-focus: var(--vp-button-secondary-gradient-hover);\n --vp-input-surface-gradient-disabled: linear-gradient(180deg, #fbfbfb 0%, #f9f9fa 100%);\n --vp-input-surface-shadow: var(--vp-button-secondary-shadow);\n --vp-input-surface-shadow-hover: var(--vp-button-secondary-shadow-hover);\n --vp-input-surface-shadow-focus: var(--vp-button-secondary-shadow-hover);\n --vp-input-surface-shadow-disabled:\n inset 0 1px 0 0 rgb(255 255 255 / 0.5),\n inset 0 -1px 1.2px 0.35px rgb(17 17 17 / 0.03),\n 0 1px 2px -1px rgb(0 0 0 / 0.06);\n\n --vp-button-ghost-gradient-hover: linear-gradient(180deg, #fafafa 0%, #f4f4f5 100%);\n --vp-button-ghost-gradient-active: linear-gradient(180deg, #f6f6f7 0%, #f1f1f2 100%);\n --vp-button-ghost-shadow-hover:\n inset 0 0.5px 1px 0 rgb(255 255 255 / 0.8),\n inset 0 -1px 1.2px 0 rgb(17 17 17 / 0.03),\n 0 1px 2px -1px rgb(17 17 17 / 0.04);\n --vp-button-ghost-shadow-active:\n inset 0 1px 1.5px 0 rgb(17 17 17 / 0.04),\n inset 0 -0.5px 1px 0 rgb(255 255 255 / 0.65),\n 0 1px 1px -1px rgb(17 17 17 / 0.03);\n\n --vp-list-surface-gradient: linear-gradient(180deg, #ffffff 0%, #fcfcfc 100%);\n --vp-list-highlight: oklch(0.55 0.01 255 / 0.07);\n\n --vp-alert-error-bg: oklch(0.59 0.22 26 / 0.08);\n --vp-alert-error-fg: oklch(0.45 0.18 26);\n --vp-alert-warning-bg: oklch(0.78 0.12 85 / 0.12);\n --vp-alert-warning-fg: oklch(0.42 0.1 75);\n --vp-toast-success-bg: oklch(0.72 0.14 155 / 0.12);\n --vp-toast-success-fg: oklch(0.35 0.08 155);\n --vp-conn-ready: #22c55e;\n --vp-conn-warning: #eab308;\n --vp-conn-error: #ef4444;\n}\n\n* {\n box-sizing: border-box;\n}\n\n#vp-shell {\n width: 380px;\n max-height: calc(100vh - 48px);\n display: flex;\n flex-direction: column;\n font-family: var(--vp-font-family-sans);\n color: var(--foreground);\n background: var(--background);\n border: 1px solid var(--border);\n border-radius: var(--vp-list-radius-outer);\n box-shadow: 0 18px 60px rgb(0 0 0 / 0.12);\n overflow: hidden;\n -webkit-font-smoothing: antialiased;\n}\n\n#vp-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 10px 12px;\n border-bottom: 1px solid var(--border);\n background: var(--background);\n cursor: move;\n}\n\n#vp-title {\n display: none;\n}\n\n#vp-header-actions {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-left: auto;\n}\n\n#vp-header-icons {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-right: 4px;\n}\n\n#vp-conn {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--vp-conn-ready);\n transition: background 200ms;\n}\n\n#vp-conn.ready {\n background: var(--vp-conn-ready);\n}\n\n#vp-conn.warning {\n background: var(--vp-conn-warning);\n}\n\n#vp-conn.error {\n background: var(--vp-conn-error);\n}\n\n#vp-alert {\n display: none;\n margin: 0;\n padding: 8px 12px;\n border-bottom: 1px solid var(--border);\n font-size: var(--vp-font-size-support);\n line-height: var(--vp-line-height-support);\n}\n\n#vp-alert.error {\n display: block;\n color: var(--vp-alert-error-fg);\n background: var(--vp-alert-error-bg);\n}\n\n#vp-alert.warning {\n display: block;\n color: var(--vp-alert-warning-fg);\n background: var(--vp-alert-warning-bg);\n}\n\n#vp-toast {\n display: none;\n margin: 0;\n padding: 8px 12px;\n border-bottom: 1px solid var(--border);\n font-size: var(--vp-font-size-support);\n line-height: var(--vp-line-height-support);\n color: var(--vp-toast-success-fg);\n background: var(--vp-toast-success-bg);\n}\n\n#vp-toast.error {\n display: block;\n color: var(--vp-alert-error-fg);\n background: var(--vp-alert-error-bg);\n}\n\n#vp-toast.success {\n display: block;\n}\n\n#vp-body {\n display: flex;\n flex-direction: column;\n gap: 10px;\n padding: 12px;\n overflow: auto;\n max-height: calc(100vh - 114px);\n background: var(--background);\n}\n\n#vp-body::-webkit-scrollbar {\n width: 8px;\n}\n\n#vp-body::-webkit-scrollbar-thumb {\n background: oklch(0.89 0.006 255);\n border-radius: 999px;\n}\n\n#vp-footer {\n position: sticky;\n bottom: 0;\n z-index: 2;\n padding-top: 8px;\n background: linear-gradient(180deg, rgb(255 255 255 / 0) 0%, rgb(255 255 255 / 0.96) 22%, #fff 100%);\n}\n\n#vp-live-preview-host {\n min-height: 72px;\n max-width: 280px;\n border: 1px dashed var(--border);\n border-radius: 8px;\n padding: 8px;\n display: flex;\n align-items: flex-start;\n justify-content: flex-start;\n overflow: auto;\n background: oklch(0.97 0.004 255);\n}\n\n.crank-hidden {\n display: none !important;\n}\n\n/* Typography utilities */\n.vp-type-micro {\n font-family: var(--vp-font-family-sans);\n font-size: var(--vp-font-size-micro);\n line-height: var(--vp-line-height-micro);\n}\n\n.vp-type-support {\n font-family: var(--vp-font-family-sans);\n font-size: var(--vp-font-size-support);\n line-height: var(--vp-line-height-support);\n}\n\n.vp-type-label {\n font-family: var(--vp-font-family-sans);\n font-size: var(--vp-font-size-label);\n line-height: var(--vp-line-height-label);\n}\n\n.vp-type-body {\n font-size: var(--vp-font-size-body);\n line-height: var(--vp-line-height-body);\n}\n\n.vp-text {\n color: var(--foreground);\n}\n\n.vp-muted {\n color: var(--muted-foreground);\n}\n\n.vp-subtle {\n color: var(--muted-foreground);\n font-size: var(--vp-font-size-support);\n line-height: var(--vp-line-height-support);\n}\n\n.vp-strong {\n color: var(--foreground);\n font-weight: var(--vp-font-weight-semibold);\n}\n\n.vp-divider {\n height: 1px;\n background: var(--border);\n margin: 8px 0;\n}\n\n.vp-section-title {\n display: flex;\n align-items: center;\n justify-content: space-between;\n text-transform: uppercase;\n color: var(--muted-foreground);\n font-size: var(--vp-font-size-micro);\n letter-spacing: 0.12em;\n margin: 8px 0;\n font-weight: var(--vp-font-weight-medium);\n}\n\n.vp-card {\n display: grid;\n gap: 8px;\n margin-bottom: 10px;\n padding: 8px;\n background: var(--card);\n border-radius: 8px;\n border: 1px solid var(--border);\n}\n\n.vp-property-row {\n display: grid;\n gap: 6px;\n margin-bottom: 8px;\n padding: 8px;\n background: var(--card);\n border-radius: 8px;\n border: 1px solid var(--border);\n}\n\n/* InputField — mirrors tool-ui/src/components/ui/input-field.tsx */\n.vp-field {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 0;\n}\n\n.vp-field.is-active .vp-field-label-visible,\n.vp-field:focus-within .vp-field-label-visible {\n color: var(--foreground);\n font-weight: var(--vp-font-weight-semibold);\n}\n\n.vp-field-label-grid {\n display: inline-grid;\n align-self: flex-start;\n max-width: 100%;\n}\n\n.vp-field-label-grid--compact {\n padding-left: var(--vp-compact-control-padding-x);\n font-size: var(--vp-compact-control-font-size);\n font-weight: var(--vp-compact-control-font-weight);\n line-height: var(--vp-compact-control-line-height);\n}\n\n.vp-field-label-grid--default {\n padding-left: var(--vp-control-shell-padding-x);\n font-size: var(--vp-control-shell-font-size);\n font-weight: var(--vp-control-shell-font-weight);\n line-height: var(--vp-control-shell-line-height);\n}\n\n.vp-field-label-reserve {\n visibility: hidden;\n grid-column: 1;\n grid-row: 1;\n font-weight: var(--vp-font-weight-semibold);\n white-space: nowrap;\n}\n\n.vp-field-label-visible {\n grid-column: 1;\n grid-row: 1;\n color: var(--muted-foreground);\n font-weight: var(--vp-font-weight-regular);\n transition:\n color 150ms var(--ease-standard),\n font-weight 150ms var(--ease-standard);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.vp-field-meta {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n padding-left: var(--vp-compact-control-padding-x);\n}\n\n.vp-field-select {\n position: relative;\n width: 100%;\n}\n\n.vp-field-select-trigger {\n display: inline-flex;\n min-width: 0;\n flex: 1;\n align-items: center;\n gap: 4px;\n width: 100%;\n border: none;\n background: transparent;\n box-shadow: none;\n outline: none;\n color: var(--muted-foreground);\n text-align: left;\n cursor: pointer;\n font: inherit;\n padding: 0;\n transition: color 150ms var(--ease-standard);\n}\n\n.vp-field.is-active .vp-field-select-trigger,\n.vp-field:focus-within .vp-field-select-trigger,\n.vp-field-select.is-open .vp-field-select-trigger {\n color: var(--foreground);\n}\n\n.vp-field-select-label {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.vp-field-select-chevron {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n color: var(--muted-foreground);\n transition:\n transform 150ms var(--ease-standard),\n color 150ms var(--ease-standard);\n}\n\n.vp-field-select.is-open .vp-field-select-chevron {\n transform: rotate(180deg);\n color: var(--foreground);\n}\n\n.vp-dropdown-menu {\n position: absolute;\n top: calc(100% + 6px);\n right: 0;\n left: 0;\n z-index: 40;\n min-width: 180px;\n padding: var(--vp-list-padding);\n border-radius: var(--vp-list-radius-outer);\n border: 1px solid var(--vp-button-secondary-stroke);\n background: var(--vp-list-surface-gradient);\n box-shadow: var(--vp-button-secondary-shadow);\n display: flex;\n flex-direction: column;\n gap: var(--vp-list-gap);\n transform-origin: top center;\n animation: vp-dropdown-in 160ms var(--ease-standard);\n}\n\n.vp-dropdown-menu.crank-hidden {\n display: none !important;\n animation: none;\n}\n\n@keyframes vp-dropdown-in {\n from {\n opacity: 0;\n transform: translateY(-4px) scaleY(0.96);\n }\n to {\n opacity: 1;\n transform: translateY(0) scaleY(1);\n }\n}\n\n.vp-menu-item {\n position: relative;\n z-index: 1;\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n padding: 6px 8px;\n border: none;\n border-radius: var(--vp-list-radius-inner);\n background: transparent;\n color: var(--muted-foreground);\n font-size: var(--vp-font-size-label);\n line-height: var(--vp-line-height-label);\n font-weight: var(--vp-font-weight-regular);\n text-align: left;\n cursor: pointer;\n font-family: inherit;\n transition:\n color 150ms var(--ease-standard),\n font-weight 150ms var(--ease-standard),\n background 150ms var(--ease-standard);\n}\n\n.vp-menu-item:hover,\n.vp-menu-item:focus-visible {\n color: var(--foreground);\n background: var(--vp-list-highlight);\n outline: none;\n}\n\n.vp-menu-item.is-checked {\n color: var(--foreground);\n font-weight: var(--vp-font-weight-semibold);\n}\n\n.vp-menu-item__label {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.vp-menu-item__check {\n flex-shrink: 0;\n width: 14px;\n height: 14px;\n color: var(--foreground);\n}\n\n.vp-control-row {\n display: grid;\n gap: 6px;\n align-items: center;\n grid-template-columns: auto 1fr auto;\n}\n\n.vp-token-row {\n padding: 6px 8px;\n border: 1px solid var(--border);\n border-radius: var(--vp-compact-control-radius);\n background: var(--card);\n}\n\n.vp-token-row__name {\n color: var(--foreground);\n font-size: var(--vp-font-size-support);\n line-height: var(--vp-line-height-support);\n word-break: break-word;\n}\n\n.vp-token-row__value {\n color: var(--muted-foreground);\n font-size: var(--vp-font-size-micro);\n line-height: var(--vp-line-height-micro);\n margin-top: 2px;\n word-break: break-word;\n}\n\n.vp-cap-badge {\n display: inline-block;\n padding: 2px 6px;\n border-radius: 999px;\n font-size: var(--vp-font-size-micro);\n line-height: var(--vp-line-height-micro);\n font-weight: var(--vp-font-weight-medium);\n}\n\n.vp-cap-badge--editable {\n color: var(--vp-toast-success-fg);\n background: var(--vp-toast-success-bg);\n border: 1px solid color-mix(in oklch, var(--vp-toast-success-fg) 35%, transparent);\n}\n\n.vp-cap-badge--readonly {\n color: var(--vp-alert-error-fg);\n background: var(--vp-alert-error-bg);\n border: 1px solid color-mix(in oklch, var(--destructive) 35%, transparent);\n}\n\n.vp-cap-badge--preview {\n color: var(--vp-alert-warning-fg);\n background: var(--vp-alert-warning-bg);\n border: 1px solid color-mix(in oklch, var(--vp-alert-warning-fg) 35%, transparent);\n}\n\n.vp-context-btn {\n display: block;\n width: 100%;\n text-align: left;\n font-size: var(--vp-font-size-support);\n line-height: var(--vp-line-height-support);\n color: var(--foreground);\n margin-bottom: 4px;\n word-break: break-word;\n border: 1px solid transparent;\n border-radius: var(--vp-compact-control-radius);\n background: transparent;\n padding: 4px 6px;\n cursor: pointer;\n font-family: inherit;\n transition: background 150ms var(--ease-out), border-color 150ms var(--ease-out);\n}\n\n.vp-context-btn:hover {\n background: var(--hover);\n}\n\n.vp-context-btn--emphasis {\n border-color: var(--border);\n background: var(--card);\n padding: 5px 7px;\n}\n\n.vp-context-btn--soft {\n border-color: var(--border);\n background: var(--accent);\n}\n\n.vp-segment-btn {\n padding: 6px;\n border: 1px solid var(--border);\n background: var(--secondary);\n color: var(--foreground);\n border-radius: 4px;\n cursor: pointer;\n font-size: var(--vp-font-size-support);\n font-weight: var(--vp-font-weight-regular);\n font-family: inherit;\n transition:\n background 150ms var(--ease-out),\n border-color 150ms var(--ease-out),\n color 150ms var(--ease-out);\n}\n\n.vp-segment-btn:hover {\n background: var(--hover);\n}\n\n.vp-segment-btn.is-selected {\n border-color: var(--ring);\n background: var(--vp-list-highlight);\n font-weight: var(--vp-font-weight-semibold);\n}\n\n.vp-segment-btn--compact {\n padding: 6px 4px;\n font-size: var(--vp-font-size-micro);\n}\n\n.vp-pill {\n display: inline-block;\n background: var(--secondary);\n border: 1px solid var(--border);\n color: var(--foreground);\n border-radius: 999px;\n padding: 4px 8px;\n font-size: var(--vp-font-size-micro);\n line-height: var(--vp-line-height-micro);\n}\n\n.vp-pill--accent {\n background: color-mix(in oklch, var(--ring) 12%, var(--card));\n border-color: color-mix(in oklch, var(--ring) 45%, var(--border));\n color: var(--foreground);\n}\n\n.vp-pill--muted {\n color: var(--muted-foreground);\n}\n\n.vp-audit-entry {\n padding: 8px;\n border: 1px solid var(--border);\n border-radius: var(--vp-compact-control-radius);\n background: var(--card);\n margin-bottom: 6px;\n}\n\n.vp-audit-entry.is-current {\n border-color: var(--ring);\n background: color-mix(in oklch, var(--ring) 8%, var(--card));\n}\n\n.vp-audit-entry.is-past {\n opacity: 0.72;\n}\n\n.vp-audit-entry__property {\n color: var(--foreground);\n font-size: var(--vp-font-size-support);\n font-weight: var(--vp-font-weight-semibold);\n}\n\n.vp-audit-entry.is-past .vp-audit-entry__property {\n color: var(--muted-foreground);\n}\n\n.vp-audit-entry__meta {\n color: var(--muted-foreground);\n font-size: var(--vp-font-size-micro);\n margin-top: 2px;\n}\n\n.vp-audit-entry__old {\n color: var(--destructive);\n font-size: var(--vp-font-size-micro);\n margin-top: 4px;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n\n.vp-audit-entry__new {\n color: var(--vp-toast-success-fg);\n font-size: var(--vp-font-size-micro);\n margin-top: 2px;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n\n.vp-status-badge {\n display: inline-block;\n padding: 2px 6px;\n border-radius: 999px;\n font-size: 9px;\n font-weight: var(--vp-font-weight-bold);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n.vp-status-badge--current {\n border: 1px solid color-mix(in oklch, var(--ring) 45%, var(--border));\n color: var(--foreground);\n background: color-mix(in oklch, var(--ring) 12%, var(--card));\n}\n\n.vp-status-badge--pending {\n border: 1px solid var(--border);\n color: var(--muted-foreground);\n background: var(--secondary);\n}\n\n.vp-status-badge--saved {\n border: 1px solid color-mix(in oklch, var(--vp-toast-success-fg) 45%, var(--border));\n color: var(--vp-toast-success-fg);\n background: var(--vp-toast-success-bg);\n}\n\n.vp-status-badge--failed {\n border: 1px solid color-mix(in oklch, var(--destructive) 45%, var(--border));\n color: var(--vp-alert-error-fg);\n background: var(--vp-alert-error-bg);\n}\n\n.vp-banner--info {\n background: color-mix(in oklch, var(--ring) 8%, var(--card));\n border-color: color-mix(in oklch, var(--ring) 45%, var(--border));\n color: var(--foreground);\n}\n\n.vp-component-mode-banner {\n border: 1px solid color-mix(in oklch, var(--ring) 45%, var(--border));\n background: color-mix(in oklch, var(--ring) 8%, var(--card));\n border-radius: 8px;\n padding: 8px;\n margin-bottom: 10px;\n}\n\n.vp-usage-row {\n border: 1px solid var(--border);\n border-radius: var(--vp-compact-control-radius);\n background: var(--card);\n padding: 6px 7px;\n}\n\n.vp-usage-row__file {\n font-size: var(--vp-font-size-support);\n color: var(--foreground);\n word-break: break-word;\n}\n\n.vp-usage-row__snippet {\n margin-top: 4px;\n font-size: var(--vp-font-size-micro);\n color: var(--muted-foreground);\n word-break: break-word;\n}\n\n.vp-snapshot-panel {\n border: 1px solid var(--border);\n border-radius: 8px;\n padding: 8px;\n margin-bottom: 10px;\n background: var(--card);\n}\n\n.vp-snapshot-host {\n min-height: 72px;\n max-width: 280px;\n border: 1px dashed var(--border);\n border-radius: 8px;\n padding: 8px;\n display: flex;\n align-items: flex-start;\n justify-content: flex-start;\n overflow: auto;\n background: color-mix(in oklch, var(--ring) 6%, var(--accent));\n}\n\n.vp-sizing-row {\n display: grid;\n gap: 6px;\n grid-template-columns: auto 1fr auto;\n align-items: center;\n padding: 8px;\n background: var(--secondary);\n border-radius: var(--vp-compact-control-radius);\n border: 1px solid var(--border);\n}\n\n.vp-advanced-panel {\n display: grid;\n gap: 6px;\n padding: 8px;\n background: var(--accent);\n border-radius: var(--vp-compact-control-radius);\n border: 1px solid var(--border);\n}\n\n.vp-element-badge {\n display: inline-block;\n border-radius: 999px;\n padding: 4px 8px;\n background: var(--secondary);\n border: 1px solid var(--border);\n color: var(--muted-foreground);\n font-size: var(--vp-font-size-micro);\n}\n\n.vp-element-badge--react {\n background: color-mix(in oklch, var(--ring) 10%, var(--card));\n border-color: color-mix(in oklch, var(--ring) 35%, var(--border));\n color: var(--foreground);\n}\n\n.vp-diff-banner {\n margin: 0 0 10px;\n padding: 8px;\n border: 1px solid color-mix(in oklch, var(--destructive) 25%, var(--border));\n border-radius: 8px;\n background: var(--vp-alert-error-bg);\n color: var(--vp-alert-error-fg);\n font-size: var(--vp-font-size-support);\n line-height: var(--vp-line-height-support);\n}\n\n.vp-diff-banner--compact {\n margin: 0 0 8px;\n border-radius: var(--vp-compact-control-radius);\n}\n\n.vp-diff-banner__title {\n font-weight: var(--vp-font-weight-semibold);\n margin-bottom: 3px;\n color: var(--foreground);\n}\n\n.vp-diff-banner__hint {\n color: var(--muted-foreground);\n}\n\n.vp-diff-line {\n font-size: var(--vp-font-size-micro);\n color: var(--foreground);\n}\n\n.vp-diff-line__shared {\n color: var(--muted-foreground);\n}\n\n.vp-diff-line__local {\n color: var(--foreground);\n font-weight: var(--vp-font-weight-medium);\n}\n\n.vp-typography-token-banner {\n margin: 0 0 10px;\n padding: 8px;\n border: 1px solid color-mix(in oklch, var(--ring) 30%, var(--border));\n border-radius: var(--vp-compact-control-radius);\n background: color-mix(in oklch, var(--ring) 8%, var(--card));\n}\n\n.vp-banner {\n padding: 8px;\n border-radius: var(--vp-compact-control-radius);\n border: 1px solid var(--border);\n background: var(--accent);\n color: var(--foreground);\n font-size: var(--vp-font-size-support);\n line-height: var(--vp-line-height-support);\n}\n\n.vp-banner--warning {\n background: var(--vp-alert-warning-bg);\n border-color: color-mix(in oklch, var(--vp-alert-warning-fg) 25%, var(--border));\n color: var(--vp-alert-warning-fg);\n}\n\n.vp-banner--error {\n background: var(--vp-alert-error-bg);\n border-color: color-mix(in oklch, var(--destructive) 25%, var(--border));\n color: var(--vp-alert-error-fg);\n}\n\n.vp-empty-state {\n color: var(--muted-foreground);\n padding: 12px;\n text-align: center;\n font-size: var(--vp-font-size-support);\n}\n\n.vp-panel-block {\n border: 1px solid var(--border);\n border-radius: 8px;\n padding: 8px;\n margin-bottom: 10px;\n background: var(--card);\n}\n\n.vp-check-row {\n display: flex;\n align-items: center;\n gap: 6px;\n cursor: pointer;\n font-size: var(--vp-font-size-support);\n color: var(--foreground);\n}\n\n.vp-chip {\n display: inline-block;\n padding: 2px 7px;\n border-radius: 999px;\n border: 1px solid var(--border);\n background: var(--background);\n color: var(--muted-foreground);\n font-size: var(--vp-font-size-support);\n}\n\n.vp-badge-token {\n display: inline-block;\n padding: 2px 6px;\n background: color-mix(in oklch, var(--ring) 12%, var(--card));\n border: 1px solid color-mix(in oklch, var(--ring) 30%, var(--border));\n border-radius: 4px;\n font-size: var(--vp-font-size-micro);\n color: var(--foreground);\n}\n\n.vp-badge-override {\n display: inline-block;\n padding: 2px 6px;\n background: var(--vp-alert-error-bg);\n border: 1px solid oklch(0.59 0.22 26 / 0.35);\n border-radius: 4px;\n font-size: var(--vp-font-size-micro);\n color: var(--vp-alert-error-fg);\n}\n\n.vp-error-text {\n color: var(--vp-alert-error-fg);\n padding: 12px;\n}\n\n/* Control shells */\n.vp-control-shell {\n box-sizing: border-box;\n min-height: var(--vp-control-shell-height);\n height: var(--vp-control-shell-height);\n gap: var(--vp-control-shell-gap);\n padding-inline: var(--vp-control-shell-padding-x);\n border-radius: var(--vp-control-shell-radius);\n}\n\n.vp-control-typography {\n font-family: var(--vp-font-family-sans);\n font-size: var(--vp-control-shell-font-size);\n font-weight: var(--vp-control-shell-font-weight);\n line-height: var(--vp-control-shell-line-height);\n}\n\n.vp-compact-control-shell {\n min-height: var(--vp-compact-control-height);\n height: var(--vp-compact-control-height);\n gap: var(--vp-compact-control-gap);\n padding-inline: var(--vp-compact-control-padding-x);\n border-radius: var(--vp-compact-control-radius);\n}\n\n.vp-compact-control-typography {\n font-family: var(--vp-font-family-sans);\n font-size: var(--vp-compact-control-font-size);\n font-weight: var(--vp-compact-control-font-weight);\n line-height: var(--vp-compact-control-line-height);\n}\n\n.vp-button-surface-control {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border-width: 1px;\n border-style: solid;\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n}\n\n.vp-button-primary-surface {\n border-color: var(--vp-button-primary-border);\n background: var(--vp-button-primary-gradient);\n box-shadow: var(--vp-button-primary-shadow);\n color: var(--primary-foreground);\n transition:\n background 150ms var(--ease-out),\n box-shadow 150ms var(--ease-out),\n border-color 150ms var(--ease-out),\n transform 150ms var(--ease-out);\n}\n\n.vp-button-primary-surface:hover:not(:disabled):not([aria-disabled=\"true\"]) {\n background: var(--vp-button-primary-gradient-hover);\n box-shadow: var(--vp-button-primary-shadow-hover);\n}\n\n.vp-button-primary-surface:active:not(:disabled):not([aria-disabled=\"true\"]) {\n background: var(--vp-button-primary-gradient-active);\n box-shadow: var(--vp-button-primary-shadow-active);\n transform: scale(0.98);\n}\n\n.vp-button-secondary-surface {\n border-color: var(--vp-button-secondary-stroke);\n background: var(--vp-button-secondary-gradient);\n box-shadow: var(--vp-button-secondary-shadow);\n color: var(--foreground);\n transition:\n background 150ms var(--ease-out),\n box-shadow 150ms var(--ease-out),\n border-color 150ms var(--ease-out),\n transform 150ms var(--ease-out);\n}\n\n.vp-button-secondary-surface:hover:not(:disabled):not([aria-disabled=\"true\"]) {\n background: var(--vp-button-secondary-gradient-hover);\n box-shadow: var(--vp-button-secondary-shadow-hover);\n}\n\n.vp-button-secondary-surface:active:not(:disabled):not([aria-disabled=\"true\"]) {\n background: var(--vp-button-secondary-gradient-active);\n box-shadow: var(--vp-button-secondary-shadow-active);\n transform: scale(0.98);\n}\n\n.vp-control-surface,\n.vp-input-surface {\n border-width: 1px;\n border-style: solid;\n border-color: var(--vp-button-secondary-stroke);\n background: var(--vp-input-surface-gradient);\n box-shadow: var(--vp-input-surface-shadow);\n transition:\n background 150ms var(--ease-out),\n box-shadow 150ms var(--ease-out),\n border-color 150ms var(--ease-out);\n}\n\n.vp-control-surface:hover:not(.vp-control-surface-disabled):not(:focus-within),\n.vp-input-surface:hover:not(.vp-input-surface-disabled):not(:focus-within),\n.vp-input-surface[data-active=\"true\"]:not(:focus-within):not(.vp-input-surface-disabled),\n.vp-control-surface[data-active=\"true\"]:not(:focus-within):not(.vp-control-surface-disabled) {\n background: var(--vp-input-surface-gradient-hover);\n box-shadow: var(--vp-input-surface-shadow-hover);\n}\n\n.vp-control-surface:focus-within:not(.vp-control-surface-disabled),\n.vp-input-surface:focus-within:not(.vp-input-surface-disabled) {\n background: var(--vp-input-surface-gradient-focus);\n box-shadow: var(--vp-input-surface-shadow-focus);\n}\n\n.vp-input-surface:focus-within:not(.vp-input-surface-disabled) {\n outline: 2px solid var(--ring);\n outline-offset: 0;\n}\n\n.vp-control-surface-disabled,\n.vp-input-surface-disabled {\n background: var(--vp-input-surface-gradient-disabled);\n box-shadow: var(--vp-input-surface-shadow-disabled);\n opacity: 0.65;\n}\n\n.vp-button-ghost-surface {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n background: transparent;\n box-shadow: none;\n color: var(--muted-foreground);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition:\n background 150ms var(--ease-out),\n box-shadow 150ms var(--ease-out),\n color 150ms var(--ease-out),\n transform 150ms var(--ease-out);\n}\n\n.vp-button-ghost-surface:hover:not(:disabled) {\n background: var(--vp-button-ghost-gradient-hover);\n box-shadow: var(--vp-button-ghost-shadow-hover);\n color: var(--foreground);\n}\n\n.vp-button-ghost-surface:active:not(:disabled) {\n background: var(--vp-button-ghost-gradient-active);\n box-shadow: var(--vp-button-ghost-shadow-active);\n transform: scale(0.97);\n}\n\n.vp-button-ghost-surface.is-active {\n color: var(--foreground);\n background: var(--vp-list-highlight);\n}\n\n.vp-input-surface {\n display: flex;\n align-items: center;\n width: 100%;\n}\n\n.vp-input-surface--textarea {\n align-items: stretch;\n height: auto;\n min-height: var(--vp-compact-control-height);\n padding-block: 6px;\n}\n\n.vp-input-surface--fixed {\n width: auto;\n flex: 0 0 auto;\n}\n\n.vp-input-native,\n.vp-select-native,\n.vp-textarea-native {\n flex: 1;\n min-width: 0;\n width: 100%;\n border: none;\n background: transparent;\n box-shadow: none;\n outline: none;\n color: var(--foreground);\n font-family: inherit;\n font-size: inherit;\n font-weight: inherit;\n line-height: inherit;\n text-align: left;\n padding: 0;\n margin: 0;\n}\n\n.vp-textarea-native {\n resize: vertical;\n min-height: 3.5rem;\n line-height: var(--vp-line-height-support);\n padding: 0;\n}\n\n.vp-select-native {\n cursor: pointer;\n appearance: none;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 0 center;\n padding-right: 1rem;\n}\n\n.vp-input-native[type=\"color\"] {\n height: 30px;\n padding: 0;\n cursor: pointer;\n}\n\n.vp-input-native[type=\"range\"] {\n accent-color: oklch(0.4 0.06 255);\n}\n\n.vp-btn-row {\n display: flex;\n gap: 8px;\n}\n\n.vp-btn-row > .vp-button-surface-control,\n.vp-btn-row > .vp-button-primary-surface,\n.vp-btn-row > .vp-button-secondary-surface {\n flex: 1;\n}\n\n.vp-button-group {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n align-items: center;\n}\n\n.vp-button-group--stretch {\n display: flex;\n gap: 8px;\n}\n\n.vp-button-group--stretch > button {\n flex: 1;\n min-width: 0;\n}\n\n.vp-btn-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: var(--vp-compact-control-height);\n height: var(--vp-compact-control-height);\n min-width: var(--vp-compact-control-height);\n padding: 0;\n border-radius: var(--vp-compact-control-radius);\n}\n\n/* Segmented tabs (tool-ui TabsList style)\n Golden rule: --vp-list-radius-outer = --vp-list-radius-inner + --vp-list-padding */\n.vp-list-surface.vp-control-surface {\n background: var(--vp-list-surface-gradient);\n}\n\n.vp-tabs {\n position: relative;\n display: inline-flex;\n align-items: center;\n gap: var(--vp-list-gap);\n padding: var(--vp-list-padding);\n width: 100%;\n margin-bottom: 8px;\n border-radius: var(--vp-list-radius-outer);\n background: var(--vp-list-surface-gradient);\n box-sizing: border-box;\n}\n\n.vp-tabs.vp-control-surface:focus-within,\n.vp-tabs.vp-control-surface:hover {\n background: var(--vp-list-surface-gradient);\n box-shadow: var(--vp-input-surface-shadow);\n}\n\n.vp-tabs.vp-control-surface:focus-within {\n outline: none;\n}\n\n.vp-control-surface-overlay {\n background: transparent;\n border: none;\n box-shadow: none;\n}\n\n.vp-tab-indicator.vp-control-surface-overlay {\n background: var(--vp-list-highlight);\n}\n\n.vp-tab-indicator {\n position: absolute;\n top: 0;\n left: 0;\n pointer-events: none;\n border-radius: var(--vp-list-radius-inner);\n background: var(--vp-list-highlight);\n will-change: transform, opacity;\n}\n\n.vp-tab-indicator--selected {\n z-index: 0;\n opacity: 1;\n transition: opacity 80ms var(--ease-out);\n}\n\n.vp-tab-indicator--hover {\n z-index: 0;\n opacity: 0;\n transition: opacity 80ms var(--ease-out);\n}\n\n.vp-tab-indicator--hover.is-visible {\n opacity: 0.4;\n}\n\n.vp-tab {\n position: relative;\n z-index: 1;\n flex: 1;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n background: transparent;\n cursor: pointer;\n padding-block: 0.375rem;\n padding-inline: 0.5rem;\n border-radius: var(--vp-list-radius-inner);\n color: var(--muted-foreground);\n font-family: inherit;\n outline: none;\n box-sizing: border-box;\n transition:\n color 150ms var(--ease-standard),\n transform 150ms var(--ease-out);\n}\n\n.vp-tab:focus {\n outline: none;\n}\n\n.vp-tab:focus-visible {\n outline: none;\n}\n\n.vp-tab:active {\n transform: scale(0.98);\n}\n\n.vp-tab:hover,\n.vp-tab.is-hovered {\n color: var(--foreground);\n}\n\n.vp-tab.is-hovered .vp-tab-label-visible {\n color: var(--foreground);\n}\n\n.vp-tab.is-active {\n color: var(--foreground);\n background: transparent;\n}\n\n.vp-tab-label-grid {\n display: inline-grid;\n max-width: 100%;\n justify-items: start;\n}\n\n.vp-tab-label-reserve {\n grid-column: 1;\n grid-row: 1;\n visibility: hidden;\n font-weight: var(--vp-font-weight-semibold);\n white-space: nowrap;\n}\n\n.vp-tab-label-visible {\n grid-column: 1;\n grid-row: 1;\n font-weight: var(--vp-font-weight-regular);\n white-space: nowrap;\n transition:\n color 150ms var(--ease-standard),\n font-weight 150ms var(--ease-standard);\n}\n\n.vp-tab-label-visible.is-active,\n.vp-tab.is-active .vp-tab-label-visible {\n color: var(--foreground);\n font-weight: var(--vp-font-weight-semibold);\n}\n\n.vp-list-button {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n width: 100%;\n padding: 7px 8px;\n border: 1px solid var(--border);\n border-radius: var(--vp-compact-control-radius);\n background: var(--background);\n color: var(--foreground);\n font-size: var(--vp-font-size-support);\n cursor: pointer;\n text-align: left;\n font-family: inherit;\n transition:\n background 150ms var(--ease-out),\n border-color 150ms var(--ease-out);\n}\n\n.vp-list-button:hover {\n background: var(--vp-list-highlight);\n}\n\n.vp-field-label {\n font-family: var(--vp-font-family-sans);\n font-size: var(--vp-font-size-label);\n font-weight: var(--vp-font-weight-medium);\n color: var(--foreground);\n}\n\n.vp-field-label--micro {\n font-size: var(--vp-font-size-micro);\n color: var(--foreground);\n font-weight: var(--vp-font-weight-medium);\n display: block;\n margin-bottom: 3px;\n}\n\n.vp-field-label--scrub {\n cursor: grab;\n user-select: none;\n font-size: var(--vp-font-size-support);\n color: var(--foreground);\n font-weight: var(--vp-font-weight-medium);\n}\n\n#vp-footer {\n background: linear-gradient(\n 180deg,\n color-mix(in oklch, var(--background) 0%, transparent) 0%,\n color-mix(in oklch, var(--background) 96%, transparent) 22%,\n var(--background) 100%\n );\n}\n\n#vp-live-preview-host {\n background: var(--accent);\n}\n\nbutton:disabled,\n.vp-button-primary-surface:disabled,\n.vp-button-secondary-surface:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n transform: none;\n}\n\n/* Launch hub (companion origin) */\n.vp-hub-app-list {\n display: grid;\n gap: 8px;\n}\n\n.vp-hub-app-card {\n display: block;\n width: 100%;\n text-align: left;\n padding: 12px 14px;\n border: 1px solid var(--border);\n border-radius: var(--vp-list-radius-inner);\n background: var(--background);\n cursor: pointer;\n transition: border-color 120ms ease, box-shadow 120ms ease, opacity 120ms ease;\n}\n\n.vp-hub-app-card:hover:not(:disabled) {\n border-color: color-mix(in oklch, var(--primary) 45%, var(--border));\n}\n\n.vp-hub-app-card.is-launching {\n border-color: var(--primary);\n box-shadow: 0 0 0 1px color-mix(in oklch, var(--primary) 35%, transparent);\n}\n\n.vp-hub-app-card:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n}\n\n.vp-hub-app-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n}\n\n.vp-hub-app-main {\n min-width: 0;\n flex: 1;\n}\n\n.vp-hub-app-cta {\n flex-shrink: 0;\n color: var(--primary);\n font-weight: var(--vp-font-weight-semibold);\n}\n\n.vp-hub-fix-panel {\n padding: 12px 14px;\n border: 1px solid color-mix(in oklch, var(--accent) 28%, var(--border));\n border-radius: var(--vp-list-radius-inner);\n background: color-mix(in oklch, var(--accent) 6%, var(--background));\n}\n\n.vp-hub-fix-title {\n font-size: var(--vp-font-size-label);\n font-weight: var(--vp-font-weight-semibold);\n color: var(--foreground);\n}\n\n.vp-hub-app-card.is-selected {\n border-color: var(--primary);\n box-shadow: 0 0 0 1px color-mix(in oklch, var(--primary) 65%, transparent);\n}\n\n.vp-hub-app-origin {\n font-size: var(--vp-font-size-label);\n font-weight: var(--vp-font-weight-semibold);\n color: var(--foreground);\n}\n\n.vp-hub-app-meta {\n line-height: 1.35;\n}\n\n.vp-hub-badge {\n display: inline-block;\n margin-right: 6px;\n padding: 1px 6px;\n border-radius: 4px;\n font-size: 10px;\n font-weight: var(--vp-font-weight-medium);\n background: color-mix(in oklch, var(--muted) 55%, transparent);\n color: var(--muted-foreground);\n vertical-align: middle;\n}\n\n.vp-hub-badge--running {\n background: color-mix(in oklch, var(--primary) 18%, transparent);\n color: var(--primary);\n}\n\n.vp-hub-badge--saved {\n background: color-mix(in oklch, var(--accent) 35%, transparent);\n color: var(--accent-foreground);\n}\n\n.vp-hub-badge--denied {\n background: color-mix(in oklch, var(--destructive) 18%, transparent);\n color: var(--destructive);\n}\n\n.vp-hub-badge--review {\n background: color-mix(in oklch, var(--accent) 28%, transparent);\n color: var(--accent-foreground);\n}\n\n.vp-hub-mismatch-warning {\n margin-top: 10px;\n padding: 10px 12px;\n border: 1px solid color-mix(in oklch, var(--accent) 30%, var(--border));\n border-radius: var(--vp-list-radius-inner);\n background: color-mix(in oklch, var(--accent) 8%, var(--background));\n line-height: 1.45;\n}\n\n.vp-hub-folder-panel {\n padding: 12px;\n border: 1px solid var(--border);\n border-radius: var(--vp-list-radius-inner);\n background: color-mix(in oklch, var(--background) 96%, var(--muted));\n}\n\n.vp-hub-check {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 10px;\n}\n\n.vp-hub-access-warning {\n margin-top: 10px;\n padding: 8px 10px;\n border-radius: var(--vp-list-radius-inner);\n border: 1px solid color-mix(in oklch, var(--destructive) 30%, var(--border));\n background: var(--vp-alert-error-bg);\n color: var(--vp-alert-error-fg);\n line-height: 1.45;\n}\n\n.vp-hub-status {\n margin: 0;\n line-height: 1.45;\n white-space: pre-line;\n}\n\n.vp-hub-status--ok {\n color: var(--primary);\n}\n\n.vp-hub-status--error {\n color: var(--destructive);\n}\n\n.vp-hub-status--muted {\n color: var(--muted-foreground);\n}\n\n/* Component studio — variant matrix modal */\n.vp-component-studio {\n position: fixed;\n inset: 0;\n z-index: 2147483645;\n display: none;\n pointer-events: none;\n font-family: var(--vp-font-family-sans);\n}\n\n.vp-component-studio.is-open {\n display: block;\n pointer-events: none;\n}\n\n.vp-component-studio-backdrop {\n position: absolute;\n inset: 0;\n background: rgb(12 14 18 / 0.2);\n pointer-events: none;\n}\n\n.vp-component-studio-panel {\n position: absolute;\n left: 20px;\n top: 20px;\n bottom: 20px;\n width: min(720px, calc(100vw - 440px));\n max-width: calc(100vw - 440px);\n display: grid;\n grid-template-rows: auto auto auto 1fr;\n gap: 12px;\n padding: 14px;\n border-radius: calc(var(--vp-list-radius-outer) + 2px);\n border: 1px solid var(--border);\n background: var(--background);\n box-shadow: 0 24px 64px rgb(0 0 0 / 0.28);\n min-width: 0;\n pointer-events: auto;\n}\n\n.vp-component-studio-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n}\n\n.vp-component-studio-title {\n margin: 0;\n font-size: var(--vp-font-size-display);\n font-weight: var(--vp-font-weight-title);\n color: var(--foreground);\n line-height: var(--vp-line-height-display);\n}\n\n.vp-component-studio-subtitle {\n margin: 4px 0 0;\n font-size: var(--vp-font-size-support);\n color: var(--muted-foreground);\n line-height: var(--vp-line-height-support);\n}\n\n.vp-component-studio-meta {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n}\n\n.vp-component-studio-matrix-wrap {\n min-height: 0;\n overflow: auto;\n border: 1px solid var(--border);\n border-radius: var(--vp-list-radius-inner);\n background: var(--card);\n}\n\n.vp-studio-preview-pane {\n display: flex;\n align-items: center;\n justify-content: center;\n min-height: 156px;\n padding: 12px;\n border: 1px solid var(--border);\n border-radius: var(--vp-list-radius-inner);\n background: var(--card);\n}\n\n.vp-studio-preview-host {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 100%;\n min-height: 120px;\n}\n\n.vp-studio-preview-mount {\n width: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: crosshair;\n}\n\n.vp-studio-preview-stage {\n width: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.vp-studio-preview-viewport {\n display: inline-flex;\n max-width: 100%;\n overflow: auto;\n padding: 12px;\n border-radius: var(--radius);\n background-image:\n linear-gradient(45deg, rgb(0 0 0 / 0.04) 25%, transparent 25%),\n linear-gradient(-45deg, rgb(0 0 0 / 0.04) 25%, transparent 25%),\n linear-gradient(45deg, transparent 75%, rgb(0 0 0 / 0.04) 75%),\n linear-gradient(-45deg, transparent 75%, rgb(0 0 0 / 0.04) 75%);\n background-size: 16px 16px;\n background-position: 0 0, 0 8px, 8px -8px, -8px 0;\n}\n\n.vp-studio-preview-clone {\n pointer-events: auto;\n}\n\n.vp-studio-live-preview-card {\n display: flex;\n flex-direction: column;\n gap: 6px;\n width: 100%;\n padding: 14px 16px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--background);\n text-align: left;\n}\n\n.vp-studio-live-preview-label {\n font-size: var(--vp-font-size-micro);\n font-weight: var(--vp-font-weight-semibold);\n color: var(--ring);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n}\n\n.vp-studio-live-preview-name {\n font-size: var(--vp-font-size-display);\n font-weight: var(--vp-font-weight-title);\n color: var(--foreground);\n}\n\n.vp-studio-live-preview-meta {\n font-size: var(--vp-font-size-support);\n color: var(--muted-foreground);\n}\n\n.vp-studio-live-preview-hint {\n font-size: var(--vp-font-size-micro);\n line-height: 1.4;\n color: var(--muted-foreground);\n}\n\n.vp-studio-variant-list {\n display: flex;\n justify-content: center;\n padding: 16px;\n}\n\n.vp-variant-matrix {\n width: 100%;\n border-collapse: separate;\n border-spacing: 0;\n min-width: 520px;\n}\n\n.vp-variant-matrix th,\n.vp-variant-matrix td {\n border-bottom: 1px solid var(--border);\n border-right: 1px solid var(--border);\n vertical-align: middle;\n padding: 0;\n}\n\n.vp-variant-matrix tr:last-child th,\n.vp-variant-matrix tr:last-child td {\n border-bottom: none;\n}\n\n.vp-variant-matrix th:last-child,\n.vp-variant-matrix td:last-child {\n border-right: none;\n}\n\n.vp-variant-matrix thead th {\n position: sticky;\n top: 0;\n z-index: 2;\n background: var(--secondary);\n color: var(--foreground);\n font-size: var(--vp-font-size-support);\n font-weight: var(--vp-font-weight-semibold);\n text-align: center;\n padding: 10px 12px;\n}\n\n.vp-variant-matrix .vp-variant-matrix-corner {\n left: 0;\n z-index: 3;\n}\n\n.vp-variant-matrix .vp-variant-matrix-row-label {\n position: sticky;\n left: 0;\n z-index: 1;\n background: var(--secondary);\n color: var(--foreground);\n font-size: var(--vp-font-size-support);\n font-weight: var(--vp-font-weight-semibold);\n text-align: left;\n padding: 10px 12px;\n min-width: 108px;\n white-space: nowrap;\n}\n\n.vp-studio-cell {\n min-width: 0;\n min-height: 0;\n width: 100%;\n padding: 8px 10px;\n border: 0;\n border-radius: var(--radius);\n background: var(--card);\n cursor: pointer;\n font: inherit;\n font-size: var(--vp-font-size-micro);\n color: var(--foreground);\n text-align: center;\n transition: background-color 150ms var(--ease-standard), box-shadow 150ms var(--ease-standard);\n}\n\n.vp-studio-cell:hover {\n background: var(--hover);\n}\n\n.vp-studio-cell.is-selected {\n background: color-mix(in oklch, var(--ring) 12%, var(--card));\n box-shadow: inset 0 0 0 2px var(--ring);\n}\n\n.vp-studio-cell.is-empty {\n cursor: default;\n background: var(--muted);\n}\n\n.vp-studio-cell.is-empty:hover {\n background: var(--muted);\n}\n\n.vp-studio-cell-empty-label {\n font-size: var(--vp-font-size-support);\n color: var(--muted-foreground);\n text-align: center;\n}\n\n.vp-studio-single-cell-wrap {\n display: flex;\n align-items: center;\n justify-content: center;\n min-height: 200px;\n padding: 24px;\n}\n\n.vp-studio-single-cell-wrap .vp-studio-cell {\n min-width: 240px;\n min-height: 120px;\n}\n\n.vp-studio-generic-preview {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 6px;\n min-height: 48px;\n padding: 12px 16px;\n font-size: var(--vp-font-size-support);\n color: var(--muted-foreground);\n text-align: center;\n border: 1px dashed var(--border);\n border-radius: var(--radius);\n background: var(--muted);\n}\n\n.vp-studio-generic-preview-name {\n font-weight: var(--vp-font-weight-semibold);\n color: var(--foreground);\n}\n\n.vp-studio-generic-preview-hint {\n font-size: var(--vp-font-size-micro);\n line-height: 1.35;\n max-width: 220px;\n}\n\n.vp-studio-preview-host-spacer {\n min-height: 64px;\n}\n\n.vp-component-studio-usages {\n max-height: 120px;\n overflow: auto;\n display: grid;\n gap: 6px;\n}\n\n.vp-component-studio-usage-item {\n font-size: var(--vp-font-size-micro);\n color: var(--muted-foreground);\n word-break: break-word;\n}\n\n.vp-component-studio-no-schema {\n padding: 24px;\n color: var(--muted-foreground);\n font-size: var(--vp-font-size-support);\n line-height: 1.45;\n}\n\n";
4
+ export const CRANK_HELPERS_JS = "/**\n * Crank UI helpers — tool-ui InputField / Button markup helpers.\n */\n(function (root) {\n 'use strict';\n\n function escapeHtml(value) {\n return String(value == null ? '' : value)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n }\n\n function escapeAttr(value) {\n return escapeHtml(value).replace(/\\n/g, ' ');\n }\n\n /* Crank UI markup helpers — mirrors tool-ui InputField / Button structure */\n\n function vpJoinClasses() {\n const parts = [];\n for (let i = 0; i < arguments.length; i++) {\n const value = arguments[i];\n if (value) {\n parts.push(String(value));\n }\n }\n return parts.join(' ');\n }\n\n function vpShellSizeClass(size) {\n return size === 'compact'\n ? 'vp-compact-control-shell vp-compact-control-typography'\n : 'vp-control-shell vp-control-typography';\n }\n\n function vpBuildTagAttrs(attrs) {\n if (!attrs || typeof attrs !== 'object') {\n return '';\n }\n let html = '';\n Object.keys(attrs).forEach((key) => {\n const value = attrs[key];\n if (value === undefined || value === null || value === false) {\n return;\n }\n if (value === true) {\n html += ' ' + key;\n return;\n }\n html += ' ' + key + '=\"' + escapeAttr(String(value)) + '\"';\n });\n return html;\n }\n\n function vpInputSurface(attrs, innerHtml, options) {\n const opts = options || {};\n const size = opts.size === 'compact' ? 'compact' : 'default';\n const shellClass = vpShellSizeClass(size);\n const widthClass = opts.width ? '' : ' style=\"width:100%;\"';\n const widthStyle = opts.width ? ' style=\"width:' + escapeAttr(String(opts.width)) + ';\"' : widthClass;\n const extraClass = opts.className ? ' ' + opts.className : '';\n const fixedClass = opts.width ? ' vp-input-surface--fixed' : '';\n const disabledClass = attrs && attrs.disabled ? ' vp-input-surface-disabled' : '';\n return '<div class=\"vp-input-surface vp-control-surface' + fixedClass + disabledClass + ' ' + shellClass + extraClass + '\"' + widthStyle + '>'\n + innerHtml\n + '</div>';\n }\n\n function vpInputHtml(attrs, options) {\n const opts = options || {};\n const size = opts.size === 'compact' ? 'compact' : 'default';\n const typeClass = size === 'compact' ? 'vp-compact-control-typography' : 'vp-control-typography';\n const inputAttrs = Object.assign({}, attrs || {}, { class: vpJoinClasses('vp-input-native', typeClass, attrs && attrs.class) });\n return vpInputSurface(attrs, '<input' + vpBuildTagAttrs(inputAttrs) + ' />', opts);\n }\n\n function vpChevronSvgHtml() {\n return '<svg class=\"vp-field-select-chevron\" width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M6 9l6 6 6-6\"/></svg>';\n }\n\n function vpParseSelectOptions(optionsHtml) {\n const options = [];\n const pattern = /<option([^>]*)>([\\s\\S]*?)<\\/option>/gi;\n let match = pattern.exec(optionsHtml || '');\n while (match) {\n const attrStr = match[1] || '';\n const label = match[2] || '';\n const valueMatch = attrStr.match(/value=\"([^\"]*)\"/i);\n const value = valueMatch ? valueMatch[1] : label;\n options.push({\n value: value,\n label: label,\n selected: /\\bselected\\b/i.test(attrStr),\n disabled: /\\bdisabled\\b/i.test(attrStr)\n });\n match = pattern.exec(optionsHtml || '');\n }\n return options;\n }\n\n function vpNormalizeSelectOptions(optionList, optionsHtml) {\n if (Array.isArray(optionList) && optionList.length) {\n return optionList.map(function (entry) {\n return {\n value: String(entry.value != null ? entry.value : ''),\n label: String(entry.label != null ? entry.label : entry.value != null ? entry.value : ''),\n selected: !!entry.selected,\n disabled: !!entry.disabled\n };\n });\n }\n return vpParseSelectOptions(optionsHtml || '');\n }\n\n function vpFieldLabelHtml(label, options) {\n const opts = options || {};\n const size = opts.size === 'compact' ? 'compact' : 'default';\n const sizeClass = size === 'compact' ? 'vp-field-label-grid--compact' : 'vp-field-label-grid--default';\n const text = String(label || '');\n if (!text || opts.hideLabel) {\n return '';\n }\n return '<span class=\"vp-field-label-grid ' + sizeClass + ' vp-compact-control-typography\">'\n + '<span class=\"vp-field-label-reserve\" aria-hidden=\"true\">' + escapeHtml(text) + '</span>'\n + '<span class=\"vp-field-label-visible\">' + escapeHtml(text) + '</span>'\n + '</span>';\n }\n\n function vpFieldHtml(label, controlHtml, options) {\n const opts = options || {};\n const labelHtml = vpFieldLabelHtml(label, opts);\n const extraClass = opts.className ? ' ' + opts.className : '';\n const metaHtml = opts.metaHtml ? '<div class=\"vp-field-meta\">' + opts.metaHtml + '</div>' : '';\n return '<div class=\"vp-field' + extraClass + '\">'\n + labelHtml\n + (controlHtml || '')\n + metaHtml\n + '</div>';\n }\n\n function vpFieldSelectHtml(attrs, optionsHtml, options) {\n const opts = options || {};\n const size = opts.size === 'compact' ? 'compact' : 'default';\n const shellClass = vpShellSizeClass(size);\n const typeClass = size === 'compact' ? 'vp-compact-control-typography' : 'vp-control-typography';\n const optionEntries = vpNormalizeSelectOptions(opts.options, optionsHtml);\n let selectedValue = attrs && attrs.value != null ? String(attrs.value) : '';\n if (!selectedValue) {\n const selectedEntry = optionEntries.find(function (entry) { return entry.selected; });\n if (selectedEntry) {\n selectedValue = selectedEntry.value;\n } else if (optionEntries.length) {\n selectedValue = optionEntries[0].value;\n }\n }\n const selectedEntry = optionEntries.find(function (entry) {\n return entry.value === selectedValue;\n }) || optionEntries[0];\n const selectedLabel = selectedEntry ? selectedEntry.label : (opts.placeholder || 'Select');\n const hiddenAttrs = Object.assign({}, attrs || {}, {\n type: 'hidden',\n class: 'vp-field-select-value',\n value: selectedValue\n });\n const menuItems = optionEntries.map(function (entry) {\n const isChecked = entry.value === selectedValue;\n return '<button type=\"button\" class=\"vp-menu-item' + (isChecked ? ' is-checked' : '') + '\" role=\"menuitemradio\" aria-checked=\"' + (isChecked ? 'true' : 'false') + '\" data-value=\"' + escapeAttr(entry.value) + '\"' + (entry.disabled ? ' disabled' : '') + '>'\n + '<span class=\"vp-menu-item__label\">' + escapeHtml(entry.label) + '</span>'\n + (isChecked ? '<svg class=\"vp-menu-item__check\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 12L9 17L20 6\"/></svg>' : '')\n + '</button>';\n }).join('');\n const triggerLabel = escapeHtml(selectedLabel);\n const widthStyle = opts.width ? ' style=\"width:' + escapeAttr(String(opts.width)) + ';\"' : ' style=\"width:100%;\"';\n return '<div class=\"vp-field-select\"' + widthStyle + '>'\n + '<input' + vpBuildTagAttrs(hiddenAttrs) + ' />'\n + '<div class=\"vp-input-surface vp-control-surface ' + shellClass + '\" data-crank-part=\"control-surface\" style=\"width:100%;\">'\n + '<button type=\"button\" class=\"vp-field-select-trigger ' + typeClass + '\" aria-haspopup=\"listbox\" aria-expanded=\"false\">'\n + '<span class=\"vp-field-select-label\">' + triggerLabel + '</span>'\n + vpChevronSvgHtml()\n + '</button>'\n + '</div>'\n + '<div class=\"vp-dropdown-menu crank-hidden\" role=\"menu\">' + menuItems + '</div>'\n + '</div>';\n }\n\n function vpSelectHtml(attrs, optionsHtml, options) {\n return vpFieldSelectHtml(attrs, optionsHtml, options);\n }\n\n function vpTextareaHtml(attrs, content, options) {\n const opts = options || {};\n const size = opts.size === 'compact' ? 'compact' : 'default';\n const typeClass = size === 'compact' ? 'vp-compact-control-typography' : 'vp-control-typography';\n const areaAttrs = Object.assign({}, attrs || {}, { class: vpJoinClasses('vp-textarea-native', typeClass, attrs && attrs.class) });\n const shellClass = vpShellSizeClass(size);\n return '<div class=\"vp-input-surface vp-input-surface--textarea vp-control-surface ' + shellClass + '\" data-crank-part=\"control-surface\" style=\"width:100%;\">'\n + '<textarea' + vpBuildTagAttrs(areaAttrs) + '>' + escapeHtml(content || '') + '</textarea>'\n + '</div>';\n }\n\n function vpButtonShellClasses(variant, size) {\n const isCompact = size === 'compact';\n const shellClass = isCompact ? 'vp-compact-control-shell' : 'vp-control-shell';\n const typeClass = isCompact ? 'vp-compact-control-typography' : 'vp-control-typography';\n return vpJoinClasses(shellClass, typeClass);\n }\n\n function vpButtonHtml(variant, label, attrs, options) {\n const opts = options || {};\n const size = opts.size === 'compact' ? 'compact' : 'default';\n let surfaceClass = 'vp-button-secondary-surface';\n if (variant === 'primary') {\n surfaceClass = 'vp-button-primary-surface';\n } else if (variant === 'ghost') {\n surfaceClass = 'vp-button-ghost-surface';\n }\n const iconClass = opts.icon ? ' vp-btn-icon' : '';\n const activeClass = opts.active ? ' is-active' : '';\n const surfaceControlClass = variant === 'ghost' ? '' : 'vp-button-surface-control';\n const buttonAttrs = Object.assign({}, attrs || {}, {\n type: (attrs && attrs.type) || 'button',\n class: vpJoinClasses(\n surfaceClass,\n surfaceControlClass,\n vpButtonShellClasses(variant, size),\n iconClass,\n activeClass,\n opts.className,\n attrs && attrs.class\n )\n });\n const style = opts.flex ? ' style=\"flex:1;min-width:0;\"' : '';\n return '<button' + vpBuildTagAttrs(buttonAttrs) + style + '>' + (label || '') + '</button>';\n }\n\n function vpButtonGroupHtml(buttonsHtml, options) {\n const opts = options || {};\n const groupClass = vpJoinClasses(\n 'vp-button-group',\n opts.stretch ? 'vp-button-group--stretch' : '',\n opts.className\n );\n return '<div class=\"' + groupClass + '\">' + (buttonsHtml || '') + '</div>';\n }\n\n function vpTabLabelHtml(label, active) {\n const text = String(label || '');\n const activeClass = active ? ' is-active' : '';\n return '<span class=\"vp-tab-label-grid vp-type-support\">'\n + '<span class=\"vp-tab-label-reserve\" aria-hidden=\"true\">' + escapeHtml(text) + '</span>'\n + '<span class=\"vp-tab-label-visible' + activeClass + '\">' + escapeHtml(text) + '</span>'\n + '</span>';\n }\n\n function vpTabsHtml(tabs) {\n const items = (tabs || []).map((tab, index) => {\n const active = !!tab.active;\n const activeClass = active ? ' is-active' : '';\n const attrs = Object.assign({}, tab.attrs || {}, {\n type: 'button',\n role: 'tab',\n class: 'vp-tab' + activeClass,\n id: tab.id,\n 'aria-selected': active ? 'true' : 'false',\n 'data-proximity-index': String(index)\n });\n return '<button' + vpBuildTagAttrs(attrs) + '>' + vpTabLabelHtml(tab.label || '', active) + '</button>';\n }).join('');\n return '<div class=\"vp-tabs vp-control-surface vp-list-surface\" role=\"tablist\">'\n + '<div class=\"vp-tab-indicator vp-tab-indicator--selected vp-control-surface-overlay\" aria-hidden=\"true\"></div>'\n + '<div class=\"vp-tab-indicator vp-tab-indicator--hover vp-control-surface-overlay\" aria-hidden=\"true\"></div>'\n + items\n + '</div>';\n }\n\n function vpChipHtml(text) {\n return '<span class=\"vp-chip\">' + escapeHtml(text || '') + '</span>';\n }\n\n function vpCapabilityBadgeHtml(mode, text) {\n const cls = mode === 'editable'\n ? 'vp-cap-badge vp-cap-badge--editable'\n : mode === 'read-only'\n ? 'vp-cap-badge vp-cap-badge--readonly'\n : 'vp-cap-badge vp-cap-badge--preview';\n return '<span class=\"' + cls + '\">' + escapeHtml(text || '') + '</span>';\n }\n\n function vpCapabilityLabel(mode) {\n if (mode === 'editable') {\n return 'Can save';\n }\n if (mode === 'read-only') {\n return 'Read only';\n }\n return 'Preview only';\n }\n\n function vpSegmentButtonHtml(label, attrs, selected, compact) {\n const buttonAttrs = Object.assign({}, attrs || {}, {\n type: 'button',\n class: vpJoinClasses(\n 'vp-segment-btn',\n compact ? 'vp-segment-btn--compact' : '',\n selected ? 'is-selected' : '',\n attrs && attrs.class\n )\n });\n return '<button' + vpBuildTagAttrs(buttonAttrs) + '>' + label + '</button>';\n }\n\n function vpErrorBannerHtml(message) {\n return '<div class=\"vp-banner vp-banner--error vp-type-support\">' + escapeHtml(message || '') + '</div>';\n }\n\n function vpDividerHtml() {\n return '<div class=\"vp-divider\"></div>';\n }\n\n function vpSectionTitleHtml(text) {\n return '<div class=\"vp-section-title\"><span>' + escapeHtml(text || '') + '</span></div>';\n }\n\n function vpListButtonHtml(innerHtml, attrs) {\n const buttonAttrs = Object.assign({}, attrs || {}, {\n type: 'button',\n class: vpJoinClasses('vp-list-button', attrs && attrs.class)\n });\n return '<button' + vpBuildTagAttrs(buttonAttrs) + '>' + innerHtml + '</button>';\n }\n\n\n root.CrankUI = {\n escapeHtml: escapeHtml,\n escapeAttr: escapeAttr,\n vpFieldHtml: vpFieldHtml,\n vpFieldLabelHtml: vpFieldLabelHtml,\n vpInputHtml: vpInputHtml,\n vpTextareaHtml: vpTextareaHtml,\n vpButtonHtml: vpButtonHtml,\n vpButtonGroupHtml: vpButtonGroupHtml,\n vpFieldSelectHtml: vpFieldSelectHtml,\n vpSelectHtml: vpSelectHtml,\n vpTabsHtml: vpTabsHtml,\n vpChipHtml: vpChipHtml,\n vpCapabilityBadgeHtml: vpCapabilityBadgeHtml,\n vpCapabilityLabel: vpCapabilityLabel,\n vpSectionTitleHtml: vpSectionTitleHtml,\n vpDividerHtml: vpDividerHtml,\n vpErrorBannerHtml: vpErrorBannerHtml\n };\n})(typeof window !== 'undefined' ? window : globalThis);\n";
5
+ export const CRANK_TEXT_SOURCE_JS = "/**\n * Text source classification — Crank bookmarklet classifyTextSource.\n * Hardcoded / static labels are editable; dynamic/bound data is locked.\n */\n(function (root) {\n 'use strict';\n\n function normalizeTextForCompare(value) {\n return String(value || '')\n .replace(/\\s+/g, ' ')\n .trim()\n .toLowerCase();\n }\n\n function looksLikeDataValue(text) {\n const value = String(text || '').trim();\n if (!value) return false;\n if (/^[\\d,.$%+-]+$/.test(value)) return true;\n if (/\\b\\d{4}-\\d{2}-\\d{2}\\b/.test(value) || /\\b\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}\\b/.test(value)) return true;\n if (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value)) return true;\n if (/^https?:\\/\\//i.test(value)) return true;\n if (/^[A-Z]{2,}-\\d{2,}$/.test(value) || /^#?[A-F0-9]{8,}$/i.test(value)) return true;\n return false;\n }\n\n function looksLikeStaticLabel(text) {\n const value = String(text || '').trim();\n if (!value) return false;\n const normalized = value\n .replace(/^[\\[\\(\\{<\\s]+/, '')\n .replace(/[\\]\\)\\}>\\s]+$/, '')\n .replace(/[.:;!?]+$/, '')\n .trim();\n const candidate = normalized || value;\n if (candidate.length > 40) return false;\n if (looksLikeDataValue(candidate)) return false;\n if (/^[A-Z0-9_\\-.]+$/.test(candidate) && candidate.length <= 6) return false;\n return /[A-Za-z]/.test(candidate);\n }\n\n function getDisplayNameFromFiber(fiber) {\n if (!fiber) return '';\n try {\n const t = fiber.type || fiber.elementType;\n if (!t) return '';\n if (typeof t === 'string') return t;\n if (typeof t.displayName === 'string' && t.displayName) return t.displayName;\n if (typeof t.name === 'string' && t.name) return t.name;\n } catch (_err) {}\n return '';\n }\n\n function findReactFiber(element) {\n if (!element) return null;\n const keys = Object.keys(element);\n for (let i = 0; i < keys.length; i++) {\n if (keys[i].indexOf('__reactFiber$') === 0 || keys[i].indexOf('__reactInternalInstance$') === 0) {\n return element[keys[i]];\n }\n }\n return null;\n }\n\n function getNearestComponentFiber(element) {\n let fiber = findReactFiber(element);\n let hops = 0;\n while (fiber && hops < 24) {\n const name = getDisplayNameFromFiber(fiber);\n if (name && name[0] && name[0] === name[0].toUpperCase() && name.indexOf('http') !== 0) {\n return fiber;\n }\n fiber = fiber.return || null;\n hops += 1;\n }\n return findReactFiber(element);\n }\n\n function getMatchingComponentTextProps(componentFiber, text) {\n const startFiber = componentFiber || null;\n if (!startFiber) return [];\n\n const textNorm = normalizeTextForCompare(text);\n if (!textNorm) return [];\n\n const maxFiberHops = looksLikeStaticLabel(textNorm) ? 2 : 8;\n const matches = [];\n const seen = {};\n\n function visit(value, keyPath, depth) {\n if (depth > 3 || value === null || value === undefined) return;\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n const str = normalizeTextForCompare(String(value));\n if (!str) return;\n const exactMatch = str === textNorm;\n const allowContainment = textNorm.length >= 24 && str.length >= 24;\n const containmentMatch =\n allowContainment && (textNorm.indexOf(str) !== -1 || str.indexOf(textNorm) !== -1);\n if (exactMatch || containmentMatch) {\n const key = String(keyPath || '').trim() || 'value';\n if (!seen[key]) {\n seen[key] = true;\n matches.push({ key: key, value: String(value) });\n }\n }\n return;\n }\n if (Array.isArray(value)) {\n value.slice(0, 8).forEach(function (item, index) {\n visit(item, keyPath + '[' + index + ']', depth + 1);\n });\n return;\n }\n if (typeof value === 'object') {\n Object.keys(value).slice(0, 20).forEach(function (key) {\n if (/^(children|className|style|ref|key|on[A-Z])/.test(key)) return;\n visit(value[key], keyPath ? keyPath + '.' + key : key, depth + 1);\n });\n }\n }\n\n let cursor = startFiber;\n let hops = 0;\n while (cursor && hops < maxFiberHops) {\n const props =\n cursor && (cursor.memoizedProps || cursor.pendingProps)\n ? cursor.memoizedProps || cursor.pendingProps\n : null;\n if (props && typeof props === 'object') {\n const ownerName = getDisplayNameFromFiber(cursor) || 'Component';\n Object.keys(props).forEach(function (key) {\n if (/^(children|className|style|ref|key|on[A-Z])/.test(key)) return;\n visit(props[key], ownerName + '.' + key, 0);\n });\n }\n cursor = cursor.return || null;\n hops += 1;\n }\n return matches;\n }\n\n function classifyTextSource(element, textOverride) {\n const text = String(\n textOverride != null ? textOverride : (element && element.textContent) || ''\n )\n .replace(/\\s+/g, ' ')\n .trim();\n\n if (!text) {\n return {\n type: 'unknown',\n label: 'No editable text detected',\n editable: false,\n reason: 'Selected node has no direct text target to edit.'\n };\n }\n\n const componentFiber = getNearestComponentFiber(element);\n const matchedProps = getMatchingComponentTextProps(componentFiber, text);\n\n if (matchedProps.length) {\n const dataLikeText = looksLikeDataValue(text);\n const hasStaticLabelPattern = looksLikeStaticLabel(text);\n const dataKeyMatches = matchedProps.filter(function (entry) {\n return /(id|uuid|code|vin|plate|license|count|qty|amount|total|price|cost|date|time|email|phone|created|updated|api|data|record|order|row|item|entity|seed|metric|score|value|number)/i.test(\n entry.key\n );\n }).length;\n const labelKeyMatches = matchedProps.filter(function (entry) {\n return /(label|title|text|heading|caption|placeholder|button|cta|copy|tab|menu|nav|section|subtitle|description)/i.test(\n entry.key\n );\n }).length;\n\n let dynamicScore = 0;\n let staticScore = 0;\n if (dataLikeText) dynamicScore += 3;\n if (hasStaticLabelPattern) staticScore += 2;\n dynamicScore += Math.min(3, dataKeyMatches);\n staticScore += Math.min(3, labelKeyMatches);\n if (matchedProps.length >= 3 && dataKeyMatches >= 2) dynamicScore += 1;\n if (matchedProps.length === 1 && labelKeyMatches > 0) staticScore += 1;\n if (hasStaticLabelPattern && !dataLikeText && dataKeyMatches === 0 && matchedProps.length <= 2) {\n staticScore += 2;\n }\n if (text.length <= 20 && !dataLikeText) staticScore += 1;\n\n if (dynamicScore >= 4 && dynamicScore >= staticScore + 1) {\n return {\n type: 'dynamic-data',\n label: 'Dynamic data (bound via props)',\n editable: false,\n reason: 'This text matches component data props and is likely API/seeder driven.',\n matchedProps: matchedProps\n };\n }\n if (staticScore >= 4 && staticScore >= dynamicScore + 1) {\n return {\n type: 'static-label',\n label: 'Static label (high confidence)',\n editable: true,\n reason: 'Signals strongly indicate this is UI copy/label text defined in frontend source.',\n matchedProps: matchedProps\n };\n }\n return {\n type: 'bound-unknown',\n label: 'Bound text (needs confirmation)',\n editable: false,\n reason:\n 'Text is prop-bound but confidence is mixed. Confirm in component source before enabling edits.',\n matchedProps: matchedProps\n };\n }\n\n if (looksLikeDataValue(text)) {\n return {\n type: 'dynamic-data',\n label: 'Dynamic data pattern',\n editable: false,\n reason: 'Looks like runtime data (number/date/email/url/id) rather than fixed label copy.'\n };\n }\n\n return {\n type: 'static-label',\n label: 'Static/default label',\n editable: true,\n reason: 'No runtime data binding detected for this text.'\n };\n }\n\n root.CrankTextSource = {\n classifyTextSource: classifyTextSource,\n looksLikeDataValue: looksLikeDataValue,\n looksLikeStaticLabel: looksLikeStaticLabel,\n getNearestComponentFiber: getNearestComponentFiber,\n getDisplayNameFromFiber: getDisplayNameFromFiber\n };\n})(typeof window !== 'undefined' ? window : globalThis);\n";
package/react.d.ts CHANGED
@@ -1,25 +1,28 @@
1
1
  import type { ReactElement } from 'react';
2
2
 
3
3
  export interface CrankProps {
4
- /** Full loader script URL (overrides companionUrl). */
4
+ /** @deprecated Panel is bundled; only use for legacy companion-hosted loaders. */
5
5
  src?: string;
6
- /** Companion base URL, e.g. https://3344-….replit.dev */
6
+ /** Companion API base URL, e.g. https://3344-….replit.dev */
7
7
  companionUrl?: string;
8
8
  /** Alias for companionUrl. */
9
9
  url?: string;
10
+ /** Show even when NODE_ENV is production (Retune-style). */
11
+ force?: boolean;
10
12
  }
11
13
 
12
14
  export declare function resolveCrankCompanionUrl(props?: CrankProps): string;
13
15
  export declare function resolveCrankLoaderSrc(props?: CrankProps): string;
16
+ /** Infer https://3344-… from the current Replit preview hostname. */
17
+ export declare function detectReplitCompanionUrl(): string | null;
14
18
 
15
19
  /**
16
- * Retune-style mount injects companion `/dev-loader.js` once. Renders null.
20
+ * Mount near app root. Boots the **bundled** panel (same-origin) Retune pattern.
17
21
  *
18
22
  * import { Crank } from '@ahmednawaz/crank/react'
19
23
  * <Crank />
20
24
  *
21
- * Optional: `window.__CRANK_URL__` or `VITE_CRANK_URL` for Replit public companion host.
22
- * Keep companion running: `npx @ahmednawaz/crank start`
25
+ * Keep companion running for Glean/MCP: `npx @ahmednawaz/crank start`
23
26
  */
24
27
  export declare function Crank(props?: CrankProps): ReactElement | null;
25
28
  export declare const CrankLoader: typeof Crank;
@@ -29,6 +32,8 @@ declare global {
29
32
  interface Window {
30
33
  __CRANK_URL__?: string;
31
34
  __CRANK_LOADER__?: boolean;
35
+ __CRANK_COMPANION__?: string;
36
+ __CRANK_ASSET_BASE__?: string;
32
37
  }
33
38
  }
34
39
 
package/react.js CHANGED
@@ -1,10 +1,15 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * CJS build of @ahmednawaz/crank/react (browser-safe, no Node/fs).
5
- * Vite / modern apps should import the ESM build via package exports "import" condition.
4
+ * CJS build of @ahmednawaz/crank/react.
5
+ * Vite apps should use the ESM build (package "import" condition).
6
+ *
7
+ * CJS cannot use import.meta.url asset resolution — falls back to companion
8
+ * loader, then Replit public URL.
6
9
  */
7
10
 
11
+ const PROXY_LOADER = '/@crank/dev-loader.js';
12
+
8
13
  function getReact() {
9
14
  try {
10
15
  return require('react');
@@ -15,6 +20,21 @@ function getReact() {
15
20
  }
16
21
  }
17
22
 
23
+ function detectReplitCompanionUrl() {
24
+ if (typeof window === 'undefined') {
25
+ return null;
26
+ }
27
+ try {
28
+ const host = window.location.hostname || '';
29
+ if (!/\.replit\.dev$|\.repl\.co$|\.replit\.app$/.test(host)) {
30
+ return null;
31
+ }
32
+ return 'https://3344-' + host.replace(/^\d+-/, '');
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
18
38
  function resolveCrankCompanionUrl(props) {
19
39
  props = props || {};
20
40
  if (typeof window !== 'undefined' && window.__CRANK_URL__) {
@@ -26,7 +46,11 @@ function resolveCrankCompanionUrl(props) {
26
46
  if (props.url) {
27
47
  return String(props.url).replace(/\/$/, '');
28
48
  }
29
- return 'http://localhost:3344';
49
+ const replit = detectReplitCompanionUrl();
50
+ if (replit) {
51
+ return replit;
52
+ }
53
+ return 'http://127.0.0.1:3344';
30
54
  }
31
55
 
32
56
  function resolveCrankLoaderSrc(props) {
@@ -34,32 +58,65 @@ function resolveCrankLoaderSrc(props) {
34
58
  if (props.src) {
35
59
  return String(props.src);
36
60
  }
37
- return `${resolveCrankCompanionUrl(props)}/dev-loader.js`;
61
+ if (typeof window !== 'undefined' && !props.companionUrl && !props.url && !window.__CRANK_URL__) {
62
+ return PROXY_LOADER;
63
+ }
64
+ return resolveCrankCompanionUrl(props) + '/dev-loader.js';
65
+ }
66
+
67
+ function appendLoaderScript(src, onError) {
68
+ const script = document.createElement('script');
69
+ script.src = src.indexOf('?') >= 0 ? src : src + '?t=' + Date.now();
70
+ script.async = true;
71
+ script.dataset.crankLoader = '1';
72
+ if (onError) {
73
+ script.onerror = onError;
74
+ }
75
+ document.documentElement.appendChild(script);
76
+ return script;
38
77
  }
39
78
 
40
79
  function Crank(props) {
41
80
  const React = getReact();
42
81
  props = props || {};
43
- const src = resolveCrankLoaderSrc(props);
82
+ const preferredSrc = resolveCrankLoaderSrc(props);
44
83
 
45
- React.useEffect(() => {
84
+ React.useEffect(function () {
46
85
  if (typeof document === 'undefined') {
47
86
  return undefined;
48
87
  }
49
- if (document.querySelector('script[data-crank-loader="1"]')) {
88
+ if (document.getElementById('vp-shell') || document.getElementById('vp-crank-reopen')) {
50
89
  return undefined;
51
90
  }
52
- if (window.__CRANK_LOADER__ && document.getElementById('vp-shell')) {
91
+ if (document.querySelector('script[data-crank-loader="1"]')) {
53
92
  return undefined;
54
93
  }
55
94
 
56
- const script = document.createElement('script');
57
- script.src = src.indexOf('?') >= 0 ? src : src + '?t=' + Date.now();
58
- script.async = true;
59
- script.dataset.crankLoader = '1';
60
- document.documentElement.appendChild(script);
95
+ // Prefer ESM panel-boot when available (dynamic import).
96
+ var companionUrl = resolveCrankCompanionUrl(props);
97
+ try {
98
+ import('./panel-boot.mjs')
99
+ .then(function (mod) {
100
+ if (document.getElementById('vp-shell')) return;
101
+ mod.bootCrankPanel({ companionUrl: companionUrl });
102
+ })
103
+ .catch(function () {
104
+ var fallback = companionUrl + '/dev-loader.js';
105
+ appendLoaderScript(preferredSrc, function () {
106
+ if (preferredSrc !== fallback && !document.getElementById('vp-shell')) {
107
+ document.querySelectorAll('script[data-crank-loader="1"]').forEach(function (el) {
108
+ el.remove();
109
+ });
110
+ window.__CRANK_LOADER__ = false;
111
+ appendLoaderScript(fallback);
112
+ }
113
+ });
114
+ });
115
+ } catch (_) {
116
+ appendLoaderScript(preferredSrc);
117
+ }
61
118
  return undefined;
62
- }, [src]);
119
+ }, [preferredSrc]);
63
120
 
64
121
  return null;
65
122
  }
@@ -67,6 +124,7 @@ function Crank(props) {
67
124
  module.exports = {
68
125
  Crank,
69
126
  CrankLoader: Crank,
127
+ detectReplitCompanionUrl,
70
128
  resolveCrankCompanionUrl,
71
129
  resolveCrankLoaderSrc,
72
130
  default: Crank