// ==UserScript== // @name TRMNL Better Variables // @namespace https://github.com/ExcuseMi/trmnl-userscripts // @version 1.1.3 // @description Adds an interactive JSON tree viewer + YAML export with copy features inside the existing variables accordion. // @author ExcuseMi // @match https://trmnl.com/plugin_settings/*/markup/edit* // @icon https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/refs/heads/main/images/trmnl.svg // @downloadURL https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/main/better-variables.user.js // @updateURL https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/main/better-variables.user.js // @grant none // @run-at document-body // ==/UserScript== (function () { 'use strict'; const WIDGET_ID = 'trmnl-better-vars-widget'; const STYLE_ID = 'trmnl-better-vars-style'; let cachedData = null; let format = 'json'; // 'json' | 'yaml' // --------------------------------------------------------------------------- // Data extraction // --------------------------------------------------------------------------- function extractVariables() { const result = {}; document.querySelectorAll('[data-variable-fold-target="toggleButton"]').forEach(btn => { const code = btn.querySelector('code'); if (!code) return; const contentId = btn.getAttribute('aria-controls'); let pre = contentId ? document.getElementById(contentId)?.querySelector('pre') : null; if (!pre) { let sib = btn.nextElementSibling; while (sib && !pre) { pre = sib.tagName === 'PRE' ? sib : sib.querySelector('pre'); sib = sib.nextElementSibling; } } if (!pre) return; const name = code.textContent.replace(/\{\{\s*|\s*\}\}/g, '').trim(); const raw = pre.textContent.trim(); let value; try { value = JSON.parse(raw); } catch { value = raw; } result[name] = value; }); return result; } // --------------------------------------------------------------------------- // Serializers // --------------------------------------------------------------------------- function toJson(data) { return JSON.stringify(data, null, 2); } function toYaml(data) { function yamlStr(s) { if (s === '' || /[:#\[\]{},&*?|<>=!%@`\n\r]/.test(s) || /^(true|false|null|yes|no|on|off)$/i.test(s) || /^\s|\s$/.test(s) || /^\d/.test(s)) { return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r/g, '\\r').replace(/\n/g, '\\n') + '"'; } return s; } function serialize(val, depth) { const pad = ' '.repeat(depth); if (val === null) return 'null'; if (typeof val === 'boolean') return String(val); if (typeof val === 'number') return String(val); if (typeof val === 'string') return yamlStr(val); if (Array.isArray(val)) { if (!val.length) return '[]'; return val.map(v => { // Object in array: put first key on the dash line if (v !== null && typeof v === 'object' && !Array.isArray(v)) { const ents = Object.entries(v); if (!ents.length) return `\n${pad}- {}`; return ents.map(([k2, v2], i2) => { const sv2 = serialize(v2, depth + 1); const blk2 = typeof v2 === 'object' && v2 !== null && (Array.isArray(v2) ? v2.length > 0 : Object.keys(v2).length > 0); const line = `${k2}:${blk2 ? sv2 : ' ' + sv2}`; return i2 === 0 ? `\n${pad}- ${line}` : `\n${pad} ${line}`; }).join(''); } return `\n${pad}- ${serialize(v, depth + 1)}`; }).join(''); } const entries = Object.entries(val); if (!entries.length) return '{}'; return entries.map(([k, v]) => { const sv = serialize(v, depth + 1); const block = typeof v === 'object' && v !== null && (Array.isArray(v) ? v.length > 0 : Object.keys(v).length > 0); return `\n${pad}${k}:${block ? sv : ' ' + sv}`; }).join(''); } const lines = []; for (const [k, v] of Object.entries(data)) { const sv = serialize(v, 1); const block = typeof v === 'object' && v !== null && (Array.isArray(v) ? v.length > 0 : Object.keys(v).length > 0); lines.push(`${k}:${block ? sv : ' ' + sv}`); } return lines.join('\n') + '\n'; } function currentText() { return format === 'yaml' ? toYaml(cachedData) : toJson(cachedData); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function mk(tag, cls, text) { const el = document.createElement(tag); if (cls) el.className = cls; if (text !== undefined) el.textContent = text; return el; } async function copyText(text, el, flash) { try { await navigator.clipboard.writeText(text); } catch { const ta = Object.assign(mk('textarea'), { value: text }); document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } if (!el || !flash) return; const prev = el.textContent; el.textContent = flash; setTimeout(() => { el.textContent = prev; }, 900); } // --------------------------------------------------------------------------- // JSON tree builder // --------------------------------------------------------------------------- function childPath(parentPath, key, isArr) { if (!parentPath) return String(key); return isArr ? `${parentPath}[${key}]` : `${parentPath}.${key}`; } function scalarNode(val, path) { const span = mk('span'); if (val === null) { span.className = 'jv-null'; span.textContent = 'null'; } else if (typeof val === 'boolean') { span.className = 'jv-bool'; span.textContent = String(val); } else if (typeof val === 'number') { span.className = 'jv-num'; span.textContent = String(val); } else { span.className = 'jv-str'; span.textContent = `"${val}"`; } if (path) { span.classList.add('jv-copy'); span.title = 'Click to copy value'; span.addEventListener('click', e => { e.stopPropagation(); copyText(val === null ? 'null' : String(val), span, '✓'); }); } return span; } function keyNode(key, path) { // Numbers are array indices → display as [n], strings as "key" const isIdx = typeof key === 'number'; const span = mk('span', 'jv-key', isIdx ? `[${key}]` : `"${key}"`); if (path) { span.classList.add('jv-copy'); span.title = `Click to copy {{ ${path} }}`; span.addEventListener('click', e => { e.stopPropagation(); copyText(`{{ ${path} }}`, span, '✓'); }); } return span; } function buildNode(val, key, isLast, path) { const isArr = Array.isArray(val); const isObj = val !== null && typeof val === 'object'; const comma = isLast ? '' : ','; if (!isObj) { const row = mk('div', 'jv-row'); if (key !== null) { row.appendChild(keyNode(key, path)); row.appendChild(mk('span', 'jv-p', ': ')); } row.appendChild(scalarNode(val, path)); if (comma) row.appendChild(mk('span', 'jv-p', comma)); return row; } const entries = isArr ? val.map((v, i) => [i, v]) : Object.entries(val); const open = isArr ? '[' : '{'; const close = isArr ? ']' : '}'; if (!entries.length) { const row = mk('div', 'jv-row'); if (key !== null) { row.appendChild(keyNode(key, path)); row.appendChild(mk('span', 'jv-p', ': ')); } row.appendChild(mk('span', 'jv-p', open + close + comma)); return row; } const wrap = mk('div', 'jv-node'); const header = mk('div', 'jv-header'); if (key !== null) { header.appendChild(keyNode(key, path)); header.appendChild(mk('span', 'jv-p', ': ')); } header.appendChild(mk('span', 'jv-p', open)); const preview = mk('span', 'jv-preview', isArr ? `… ${val.length} items${close}${comma}` : `… ${entries.length} keys${close}${comma}`); preview.style.display = 'none'; header.appendChild(preview); // Copy subtree button (visible on hover) const copyObjBtn = mk('span', 'jv-obj-copy', '⧉'); copyObjBtn.title = 'Copy as JSON'; copyObjBtn.addEventListener('click', e => { e.stopPropagation(); copyText(JSON.stringify(val, null, 2), copyObjBtn, '✓'); }); header.appendChild(copyObjBtn); wrap.appendChild(header); const body = mk('div', 'jv-body'); entries.forEach(([k, v], i) => body.appendChild(buildNode(v, isArr ? i : k, i === entries.length - 1, childPath(path, k, isArr))) ); wrap.appendChild(body); const closeRow = mk('div', 'jv-row'); closeRow.appendChild(mk('span', 'jv-p', close + comma)); wrap.appendChild(closeRow); let collapsed = false; header.addEventListener('click', () => { collapsed = !collapsed; header.classList.toggle('jv-collapsed', collapsed); body.style.display = collapsed ? 'none' : ''; closeRow.style.display = collapsed ? 'none' : ''; preview.style.display = collapsed ? '' : 'none'; }); return wrap; } // --------------------------------------------------------------------------- // YAML syntax-highlighted viewer with interactive features // --------------------------------------------------------------------------- function yamlValWithCopy(val, path) { const span = mk('span'); // Handle different value types if (val === 'null' || val === '~') { span.className = 'jv-null jv-copy'; span.textContent = val; } else if (val === 'true' || val === 'false') { span.className = 'jv-bool jv-copy'; span.textContent = val; } else if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(val)) { span.className = 'jv-num jv-copy'; span.textContent = val; } else { span.className = 'jv-str jv-copy'; span.textContent = val; } if (path) { span.title = 'Click to copy value'; span.addEventListener('click', e => { e.stopPropagation(); // Remove quotes if present const value = val.startsWith('"') && val.endsWith('"') ? val.slice(1, -1) : val; copyText(value, span, '✓'); }); } return span; } function buildYamlView(text, data) { const container = mk('div', 'ev-yaml-view'); const lines = text.split('\n'); // Drop trailing empty line from toYaml's trailing \n if (lines[lines.length - 1] === '') lines.pop(); // Build a map of paths to values for the YAML structure function buildPathMap(obj, basePath = '') { let map = new Map(); for (const [key, value] of Object.entries(obj)) { const currentPath = basePath ? `${basePath}.${key}` : key; if (value && typeof value === 'object' && !Array.isArray(value)) { // Nested object map.set(currentPath, { type: 'object', value }); const nestedMap = buildPathMap(value, currentPath); nestedMap.forEach((v, k) => map.set(k, v)); } else if (Array.isArray(value)) { // Array map.set(currentPath, { type: 'array', value }); value.forEach((item, index) => { const arrayPath = `${currentPath}[${index}]`; if (item && typeof item === 'object') { map.set(arrayPath, { type: 'object', value: item }); const nestedMap = buildPathMap(item, arrayPath); nestedMap.forEach((v, k) => map.set(k, v)); } else { map.set(arrayPath, { type: typeof item, value: item }); } }); } else { // Scalar map.set(currentPath, { type: typeof value, value }); } } return map; } const pathMap = buildPathMap(data); lines.forEach(line => { const row = mk('div', 'yv-row'); // Handle array items with key-value pairs: " - key: value" const mDashKey = line.match(/^(\s*)-\s+([\w-]+):\s*(.*)$/); if (mDashKey) { const [, indent, key, val] = mDashKey; row.appendChild(document.createTextNode(indent)); // Dash const dash = mk('span', 'yv-dash', '- '); row.appendChild(dash); // Key with copy functionality const keySpan = mk('span', 'jv-key jv-copy', key); keySpan.title = `Click to copy {{ ${key} }}`; keySpan.addEventListener('click', e => { e.stopPropagation(); copyText(`{{ ${key} }}`, keySpan, '✓'); }); row.appendChild(keySpan); row.appendChild(mk('span', 'jv-p', ':')); if (val) { row.appendChild(document.createTextNode(' ')); // Try to find the actual value in our path map const value = val.startsWith('"') && val.endsWith('"') ? val.slice(1, -1) : val; row.appendChild(yamlValWithCopy(value, key)); } container.appendChild(row); return; } // Handle bare array items: " - value" const mDash = line.match(/^(\s*)-\s*(.*)$/); if (mDash) { const [, indent, rest] = mDash; row.appendChild(document.createTextNode(indent)); row.appendChild(mk('span', 'yv-dash', '- ')); if (rest) { // For array items, we need to determine if it's a scalar or object start if (rest.startsWith('{') || rest.startsWith('}') || rest.startsWith('[') || rest.startsWith(']')) { row.appendChild(mk('span', 'jv-p', rest)); } else { row.appendChild(yamlValWithCopy(rest)); } } container.appendChild(row); return; } // Handle key-value pairs: " key: value" const mKey = line.match(/^(\s*)([\w-]+):\s*(.*)$/); if (mKey) { const [, indent, key, val] = mKey; row.appendChild(document.createTextNode(indent)); // Key with copy functionality const keySpan = mk('span', 'jv-key jv-copy', key); keySpan.title = `Click to copy {{ ${key} }}`; keySpan.addEventListener('click', e => { e.stopPropagation(); copyText(`{{ ${key} }}`, keySpan, '✓'); }); row.appendChild(keySpan); row.appendChild(mk('span', 'jv-p', ':')); if (val) { row.appendChild(document.createTextNode(' ')); // Check if it's a complex structure start if (val.startsWith('{') || val.startsWith('}') || val.startsWith('[') || val.startsWith(']')) { row.appendChild(mk('span', 'jv-p', val)); } else { row.appendChild(yamlValWithCopy(val, key)); } } container.appendChild(row); return; } // Handle brackets and other structural elements if (line.includes('{') || line.includes('}') || line.includes('[') || line.includes(']')) { const parts = line.split(/([{}[\]])/g); parts.forEach(part => { if (part === '{' || part === '}' || part === '[' || part === ']') { row.appendChild(mk('span', 'jv-p', part)); } else if (part) { row.appendChild(document.createTextNode(part)); } }); } else { row.appendChild(document.createTextNode(line)); } container.appendChild(row); }); // Post-process: tag each row with its depth and wire up collapse const allRows = Array.from(container.children); allRows.forEach((row, i) => { row.dataset.yvDepth = lines[i].match(/^(\s*)/)[1].length; }); allRows.forEach((row, i) => { const depth = +row.dataset.yvDepth; const nextDepth = +(allRows[i + 1]?.dataset.yvDepth ?? -1); if (nextDepth > depth) { const children = []; for (let j = i + 1; j < allRows.length; j++) { if (+allRows[j].dataset.yvDepth <= depth) break; children.push(allRows[j]); } row.classList.add('yv-parent'); row.addEventListener('click', e => { if (e.target.classList.contains('jv-copy')) return; const col = row.classList.toggle('yv-collapsed'); children.forEach(c => { c.style.display = col ? 'none' : ''; }); }); } }); return container; } // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- function injectStyle() { if (document.getElementById(STYLE_ID)) return; const W = `#${WIDGET_ID}`; const s = mk('style'); s.id = STYLE_ID; s.textContent = ` /* Widget root — hard cap matching TRMNL's own max-w-[80vw] on the variable list */ ${W} { overflow:hidden; word-break:break-all; overflow-wrap:anywhere; } /* JSON tree */ ${W} .jv-body { padding-left:1.25rem; } ${W} .jv-row { display:block; word-break:break-all; overflow-wrap:anywhere; line-height:1.6; padding-left:0.9rem; } ${W} .jv-header { display:flex; align-items:baseline; gap:0.15rem; cursor:pointer; border-radius:3px; padding:1px 2px 1px 0.9rem; position:relative; } ${W} .jv-header:hover { background:rgba(0,0,0,.04); } .dark ${W} .jv-header:hover { background:rgba(255,255,255,.05); } ${W} .jv-header::before { content:'▾'; position:absolute; left:2px; font-size:9px; color:#9ca3af; user-select:none; line-height:1.6; } ${W} .jv-header.jv-collapsed::before { content:'▸'; } ${W} .jv-key { color:#2563eb; } ${W} .jv-str { color:#16a34a; } ${W} .jv-num { color:#dc2626; } ${W} .jv-bool { color:#9333ea; } ${W} .jv-null { color:#9ca3af; } ${W} .jv-p { color:#6b7280; } ${W} .jv-preview { color:#9ca3af; font-style:italic; } ${W} .jv-copy { cursor:copy; border-radius:2px; padding:0 2px; } ${W} .jv-copy:hover { background:rgba(0,0,0,.06); outline:1px dashed #9ca3af; } ${W} .jv-obj-copy { display:none; margin-left:auto; padding:0 3px; font-size:10px; cursor:copy; color:#9ca3af; user-select:none; border-radius:2px; } ${W} .jv-header:hover .jv-obj-copy { display:inline; } ${W} .jv-obj-copy:hover { background:rgba(0,0,0,.08); color:#374151; } .dark ${W} .jv-header::before { color:#64748b; } .dark ${W} .jv-key { color:#7dd3fc; } .dark ${W} .jv-str { color:#86efac; } .dark ${W} .jv-num { color:#fca5a5; } .dark ${W} .jv-bool { color:#c4b5fd; } .dark ${W} .jv-null { color:#64748b; } .dark ${W} .jv-p { color:#94a3b8; } .dark ${W} .jv-preview { color:#64748b; } .dark ${W} .jv-copy:hover { background:rgba(255,255,255,.07); } .dark ${W} .jv-obj-copy:hover { background:rgba(255,255,255,.09); color:#e5e7eb; } /* Enhanced YAML viewer styles */ ${W} .ev-yaml-view { line-height: 1.7; } ${W} .yv-row { display: block; white-space: pre-wrap; word-break: break-all; overflow-wrap: anywhere; line-height: 1.7; } ${W} .yv-parent { cursor:pointer; border-radius:3px; } ${W} .yv-parent:hover { background:rgba(0,0,0,.04); } .dark ${W} .yv-parent:hover { background:rgba(255,255,255,.05); } ${W} .yv-dash { color: #9ca3af; margin-right: 0.15rem; } .dark ${W} .yv-dash { color: #64748b; } /* Key hover effect specific to YAML */ ${W} .jv-key.jv-copy:hover { background: rgba(37, 99, 235, 0.1); outline: 1px dashed #2563eb; } .dark ${W} .jv-key.jv-copy:hover { background: rgba(125, 211, 252, 0.1); outline: 1px dashed #7dd3fc; } `; document.head.appendChild(s); } // --------------------------------------------------------------------------- // Injection // --------------------------------------------------------------------------- function injectUI() { if (document.getElementById(WIDGET_ID)) return false; const body = document.getElementById('accordion-open-body-1'); if (!body) return false; const container = body.querySelector('[data-controller="variable-fold"]'); if (!container) return false; // Prevent the container from growing wider than its natural layout width container.style.overflow = 'hidden'; const data = extractVariables(); if (!Object.keys(data).length) return false; cachedData = data; // Inject directly into the existing accordion — no custom accordion wrapper const widget = mk('div'); widget.id = WIDGET_ID; widget.style.cssText = 'max-width:80vw; overflow:hidden;'; const bodyDiv = mk('div'); bodyDiv.className = 'text-gray-700 dark:text-white text-sm font-mono'; // Format toggle (segmented control) const fmtGroup = mk('div', 'flex rounded-md shadow-sm'); const jsonBtn = mk('button', 'px-4 py-2 text-sm font-medium border border-gray-200 rounded-l-lg bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 focus:z-10 focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-600', 'JSON'); const yamlBtn = mk('button', 'px-4 py-2 text-sm font-medium border border-gray-200 rounded-r-lg bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 focus:z-10 focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-600', 'YAML'); jsonBtn.type = 'button'; yamlBtn.type = 'button'; // Copy button with proper styling const copyBtn = mk('button', 'px-4 py-2 text-sm font-medium border border-gray-200 rounded-lg bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 focus:z-10 focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-600 inline-flex items-center gap-2'); copyBtn.type = 'button'; copyBtn.innerHTML = ` Copy JSON`; // Download button const downloadBtn = mk('button', 'px-4 py-2 text-sm font-medium border border-gray-200 rounded-lg bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 focus:z-10 focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-600 inline-flex items-center gap-2'); downloadBtn.type = 'button'; downloadBtn.innerHTML = ` Download .json`; const sizeEl = mk('span', 'ml-auto text-xs text-gray-500 dark:text-gray-400'); const btnCls = 'px-3 py-2 text-sm font-medium border border-gray-200 rounded-lg bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 focus:z-10 focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-600'; const collapseAllBtn = mk('button', btnCls, 'Collapse'); collapseAllBtn.type = 'button'; collapseAllBtn.title = 'Collapse all'; const expandAllBtn = mk('button', btnCls, 'Expand'); expandAllBtn.type = 'button'; expandAllBtn.title = 'Expand all'; // Toolbar container const toolbar = mk('div', 'flex items-center gap-2 mb-4 flex-wrap'); toolbar.appendChild(fmtGroup); toolbar.appendChild(copyBtn); toolbar.appendChild(downloadBtn); toolbar.appendChild(collapseAllBtn); toolbar.appendChild(expandAllBtn); toolbar.appendChild(sizeEl); // Content areas const treeEl = mk('div'); // JSON interactive tree const yamlEl = mk('div'); // YAML viewer (rebuilt on switch) yamlEl.style.display = 'none'; bodyDiv.appendChild(toolbar); bodyDiv.appendChild(treeEl); bodyDiv.appendChild(yamlEl); widget.appendChild(bodyDiv); collapseAllBtn.addEventListener('click', () => { if (format === 'json') { treeEl.querySelectorAll('.jv-header:not(.jv-collapsed)').forEach(h => h.click()); } else { yamlEl.querySelectorAll('.yv-parent').forEach(h => h.classList.add('yv-collapsed')); yamlEl.querySelectorAll('[data-yv-depth]').forEach(r => { if (+r.dataset.yvDepth > 0) r.style.display = 'none'; }); } }); expandAllBtn.addEventListener('click', () => { if (format === 'json') { treeEl.querySelectorAll('.jv-header.jv-collapsed').forEach(h => h.click()); } else { yamlEl.querySelectorAll('.yv-collapsed').forEach(h => h.classList.remove('yv-collapsed')); yamlEl.querySelectorAll('[data-yv-depth]').forEach(r => { r.style.display = ''; }); } }); function refresh() { copyBtn.innerHTML = ` Copy`; downloadBtn.innerHTML = ` Download`; jsonBtn.classList.toggle('bg-gray-300', format === 'json'); jsonBtn.classList.toggle('dark:bg-gray-600', format === 'json'); yamlBtn.classList.toggle('bg-gray-300', format === 'yaml'); yamlBtn.classList.toggle('dark:bg-gray-600', format === 'yaml'); if (format === 'json') { treeEl.replaceChildren(buildNode(cachedData, null, true, '')); treeEl.style.display = ''; yamlEl.style.display = 'none'; sizeEl.textContent = `${(toJson(cachedData).length / 1024).toFixed(1)} KB`; } else { const yaml = toYaml(cachedData); yamlEl.replaceChildren(buildYamlView(yaml, cachedData)); yamlEl.style.display = ''; treeEl.style.display = 'none'; sizeEl.textContent = `${(yaml.length / 1024).toFixed(1)} KB`; } } fmtGroup.appendChild(jsonBtn); fmtGroup.appendChild(yamlBtn); jsonBtn.addEventListener('click', () => { format = 'json'; refresh(); }); yamlBtn.addEventListener('click', () => { format = 'yaml'; refresh(); }); copyBtn.addEventListener('click', async () => { const originalText = copyBtn.textContent; await copyText(currentText(), copyBtn, 'Copied!'); }); downloadBtn.addEventListener('click', () => { const pluginId = window.location.pathname.match(/\/plugin_settings\/(\d+)\//)?.[1] ?? 'plugin'; const isYaml = format === 'yaml'; const ext = isYaml ? 'yml' : 'json'; const a = Object.assign(mk('a'), { href: URL.createObjectURL(new Blob([currentText()], { type: isYaml ? 'text/yaml' : 'application/json' })), download: `trmnl-${pluginId}-variables.${ext}`, }); a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); }); refresh(); // Keep the container visible (it provides the dark bg + border styling), // but hide the inner variable card list and replace with our widget const innerList = container.querySelector('.space-y-3'); if (innerList) innerList.style.display = 'none'; container.appendChild(widget); return true; } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- injectStyle(); injectUI(); const observer = new MutationObserver(() => { if (!document.getElementById(WIDGET_ID)) injectUI(); }); observer.observe(document.body, { childList: true, subtree: true }); document.addEventListener('turbo:load', () => { document.getElementById(WIDGET_ID)?.remove(); document.querySelectorAll('[data-controller="variable-fold"] .space-y-3') .forEach(el => { el.style.display = ''; }); document.querySelector('[data-controller="variable-fold"]')?.style.setProperty('overflow', ''); cachedData = null; format = 'json'; injectUI(); }); })();