{"is":"instructionGraph001","item":{"content":{"html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Node Viewer</title>\n<script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n<script src=\"/AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ.b178e012-3910-415a-be34-353fc411de99\"></script>\n<script src=\"/AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ.8e7cc85b-b011-45fb-bbde-4661e85000f9\"></script>\n<style>\n*{margin:0;padding:0;box-sizing:border-box}\n:root{\n  --bg:#0d1117;--surface:#161b22;--surface2:#1c2129;--border:#30363d;\n  --text:#e6edf3;--text-dim:#8b949e;--text-dimmer:#484f58;\n  --key:#ce93d8;--string:#81d4fa;--number:#ffcc80;--bool:#f48fb1;\n  --bracket1:#f9e64f;--bracket2:#da70d6;--bracket3:#42c0fb;--bracket4:#69f0ae;\n  --accent:#58a6ff;--accent-dim:rgba(88,166,255,.15);\n  --green:#3fb950;--red:#f85149;\n}\nbody{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;line-height:1.6;min-height:100vh}\na{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}\n\n/* Header */\n.header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 20px;display:flex;align-items:center;gap:12px;position:sticky;top:0;z-index:100;backdrop-filter:blur(12px);background:rgba(22,27,34,.85)}\n.header-logo{font-size:14px;font-weight:600;color:var(--text-dim);letter-spacing:.08em;text-transform:uppercase;white-space:nowrap}\n.header-ref{font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:12px;color:var(--text-dimmer);overflow:hidden;text-overflow:ellipsis;flex:1;white-space:nowrap}\n.btn{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text);font-size:13px;cursor:pointer;transition:all .15s;font-family:inherit;white-space:nowrap}\n.btn:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}\n.btn-primary{background:var(--accent);color:#fff;border-color:var(--accent)}\n.btn-primary:hover{background:#79b8ff;border-color:#79b8ff}\n\n/* Main */\n.main{max-width:960px;margin:0 auto;padding:20px}\n\n/* Object card */\n.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-bottom:16px}\n.card-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:12px}\n.card-title{font-size:18px;font-weight:600}\n.card-meta{display:flex;gap:12px;flex-wrap:wrap}\n.badge{display:inline-flex;align-items:center;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase}\n.badge-type{background:rgba(206,147,216,.15);color:var(--key);border:1px solid rgba(206,147,216,.25)}\n.badge-rev{background:rgba(255,204,128,.1);color:var(--number);border:1px solid rgba(255,204,128,.2)}\n\n/* Sections */\n.section{border-top:1px solid var(--border)}\n.section:first-child{border-top:none}\n.section-header{padding:12px 20px;cursor:pointer;display:flex;align-items:center;gap:10px;user-select:none;transition:background .15s}\n.section-header:hover{background:var(--surface2)}\n.section-chevron{width:16px;height:16px;transition:transform .25s ease;color:var(--text-dim);flex-shrink:0}\n.section-chevron.open{transform:rotate(90deg)}\n.section-label{font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim)}\n.section-count{font-size:11px;color:var(--text-dimmer);margin-left:4px}\n.section-body{overflow:hidden;transition:max-height .35s ease,opacity .25s ease;max-height:0;opacity:0}\n.section-body.open{opacity:1}\n.section-content{padding:4px 20px 16px}\n\n/* JSON tree */\n.json-tree{font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:13px;line-height:1.7;white-space:pre-wrap;word-break:break-word}\n.json-key{color:var(--key)}\n.json-string{color:var(--string)}\n.json-number{color:var(--number)}\n.json-bool,.json-null{color:var(--bool)}\n.json-ref{color:var(--accent);cursor:pointer;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px;font:inherit}\n.json-ref:hover{text-decoration-style:solid;color:#79b8ff}\n.json-bracket{font-weight:700;cursor:pointer;transition:color .15s}\n.json-bracket:hover{filter:brightness(1.3)}\n.depth-0{color:var(--bracket1)}.depth-1{color:var(--bracket2)}.depth-2{color:var(--bracket3)}.depth-3{color:var(--bracket4)}\n.json-ellipsis{color:var(--text-dimmer);cursor:pointer;font-style:italic;padding:0 4px}\n.json-ellipsis:hover{color:var(--text-dim)}\n.json-collapsed-preview{color:var(--text-dimmer);font-size:12px}\n\n/* Markdown */\n.md-content{font-size:16px;line-height:1.8;color:var(--text);font-family:'Segoe UI',Helvetica,Arial,sans-serif}\n.md-content h1,.md-content h2,.md-content h3{color:#fff;margin:24px 0 12px;font-weight:700;letter-spacing:-.01em}\n.md-content h1{font-size:26px;border-bottom:2px solid var(--accent);padding-bottom:10px;text-shadow:0 0 30px rgba(88,166,255,.15)}\n.md-content h2{font-size:21px;border-left:3px solid var(--bracket2);padding-left:12px}\n.md-content h3{font-size:18px;color:var(--bracket3)}\n.md-content p{margin:10px 0}\n.md-content code{background:rgba(88,166,255,.1);border:1px solid rgba(88,166,255,.15);padding:2px 7px;border-radius:4px;font-size:14px;font-family:ui-monospace,'SF Mono',Menlo,monospace;color:var(--bracket3)}\n.md-content pre{background:#0a0d12;border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;margin:16px 0;box-shadow:inset 0 1px 3px rgba(0,0,0,.4)}\n.md-content pre code{background:none;border:none;padding:0;font-size:13px;line-height:1.7;color:var(--green)}\n.md-content ul,.md-content ol{margin:10px 0;padding-left:24px}\n.md-content li{margin:6px 0}\n.md-content li::marker{color:var(--accent)}\n.md-content blockquote{border-left:3px solid var(--bracket2);padding-left:16px;color:var(--text-dim);margin:12px 0;font-style:italic;background:rgba(218,112,214,.04);padding:12px 16px;border-radius:0 6px 6px 0}\n.md-content a{color:var(--accent);text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px}\n.md-content a:hover{text-decoration-style:solid;color:#79b8ff}\n.md-content table{border-collapse:collapse;margin:16px 0;width:100%}\n.md-content th,.md-content td{border:1px solid var(--border);padding:8px 14px;text-align:left;font-size:14px}\n.md-content th{background:rgba(88,166,255,.08);font-weight:600;color:var(--accent);text-transform:uppercase;font-size:12px;letter-spacing:.04em}\n.md-content strong{color:#fff;font-weight:700}\n.md-content hr{border:none;border-top:1px solid var(--border);margin:24px 0}\n\n/* Relations */\n.rel-group{margin-bottom:12px}\n.rel-group-name{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dimmer);margin-bottom:4px}\n.rel-item{padding:4px 8px;border-radius:4px;transition:background .15s;font-size:13px}\n.rel-item:hover{background:var(--accent-dim)}\n.rel-item-row{display:flex;align-items:baseline;gap:8px}\n.rel-item a{font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:12px}\n.rel-hint{color:var(--text-dimmer);font-size:12px}\n.rel-instruction{color:var(--text-dim);font-size:12px;margin:2px 0 4px 8px;line-height:1.5;font-style:italic}\n\n/* Inbound */\n.inbound-item{display:flex;align-items:baseline;gap:8px;padding:6px 8px;border-radius:4px;transition:background .15s;font-size:13px;border-bottom:1px solid rgba(48,54,61,.4)}\n.inbound-item:last-child{border-bottom:none}\n.inbound-item:hover{background:var(--accent-dim)}\n.inbound-type{font-size:11px;font-weight:600;color:var(--key);text-transform:uppercase}\n.inbound-via{font-size:11px;color:var(--text-dimmer)}\n\n/* Loading & error */\n.loading{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:40vh;gap:16px}\n.spinner{width:32px;height:32px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}\n@keyframes spin{to{transform:rotate(360deg)}}\n.error{background:rgba(248,81,73,.1);border:1px solid rgba(248,81,73,.3);border-radius:8px;padding:16px 20px;color:var(--red)}\n\n/* Footer */\n.footer-meta{padding:12px 20px;background:var(--surface2);border-top:1px solid var(--border);display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:var(--text-dimmer);font-family:ui-monospace,'SF Mono',Menlo,monospace}\n.footer-meta span{white-space:nowrap}\n\n/* Inline editing */\n.field-editable{cursor:pointer;transition:background .15s;border-radius:4px}\n.card-title.field-editable{padding:2px 6px;margin:-2px -6px}\n.card-title.field-editable:hover{background:var(--accent-dim)}\n.badge.field-editable:hover{filter:brightness(1.3)}\n.md-content.field-editable{cursor:pointer;padding:8px;margin:-8px;border-radius:6px}\n.md-content.field-editable:hover{background:rgba(88,166,255,.06);outline:1px dashed rgba(88,166,255,.3)}\n.edit-inline{background:var(--surface2);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-family:inherit;padding:4px 8px;outline:none;font-size:inherit}\n.edit-inline-ta{background:var(--surface2);border:1px solid var(--accent);border-radius:6px;color:var(--text);font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:13px;padding:8px 10px;outline:none;resize:vertical;width:100%;line-height:1.6;box-sizing:border-box}\n.save-bar{position:fixed;bottom:0;left:0;right:0;background:var(--accent);color:#fff;padding:12px 20px;display:flex;align-items:center;justify-content:center;gap:12px;z-index:200;font-size:14px;font-weight:600;box-shadow:0 -4px 16px rgba(0,0,0,.3)}\n.save-bar button{padding:6px 16px;border-radius:6px;border:2px solid #fff;background:transparent;color:#fff;cursor:pointer;font-size:13px;font-weight:600;font-family:inherit}\n.save-bar button:hover{background:rgba(255,255,255,.2)}\n.save-bar .sb-primary{background:#fff;color:var(--bg)}\n.save-bar .sb-primary:hover{background:rgba(255,255,255,.85)}\n\n/* New object modal */\n.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:300;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px)}\n.modal-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;width:92%;max-width:600px;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,.5)}\n.modal-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px}\n.modal-header h2{font-size:16px;font-weight:600;flex:1;margin:0;color:var(--text)}\n.modal-close{background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:20px;padding:4px 8px;border-radius:4px}\n.modal-close:hover{color:var(--text);background:var(--surface2)}\n.modal-body{flex:1;overflow-y:auto;padding:16px 20px}\n.modal-footer{padding:12px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px}\n.type-search{width:100%;padding:8px 12px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px;font-family:inherit;margin-bottom:12px}\n.type-search:focus{outline:none;border-color:var(--accent)}\n.type-list{list-style:none}\n.type-item{padding:10px 12px;border-radius:6px;cursor:pointer;transition:background .15s;border-bottom:1px solid rgba(48,54,61,.3)}\n.type-item:hover{background:var(--accent-dim)}\n.type-item-name{font-weight:600;font-size:14px;color:var(--text)}\n.type-item-desc{font-size:12px;color:var(--text-dim);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.form-section{margin-bottom:16px}\n.form-section-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dimmer);margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border)}\n.form-group{margin-bottom:12px}\n.form-label{display:block;font-size:13px;font-weight:500;color:var(--text-dim);margin-bottom:4px}\n.form-label .req{color:var(--red);margin-left:2px}\n.form-hint{font-size:11px;color:var(--text-dimmer);margin-top:2px;font-style:italic}\n.form-input{width:100%;padding:8px 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;font-family:inherit;box-sizing:border-box}\n.form-input:focus{outline:none;border-color:var(--accent)}\n.form-input[readonly]{color:var(--text-dimmer);background:var(--surface2);cursor:not-allowed}\n.form-textarea{width:100%;padding:8px 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;font-family:ui-monospace,'SF Mono',Menlo,monospace;line-height:1.6;resize:vertical;min-height:80px;box-sizing:border-box}\n.form-textarea:focus{outline:none;border-color:var(--accent)}\n.form-back{background:none;border:none;color:var(--accent);cursor:pointer;font-size:14px;padding:0}\n.form-back:hover{text-decoration:underline}\n\n/* Responsive */\n/* Auth panel positioning — override library defaults */\n#dv-auth-panel{position:fixed!important;top:50px!important;right:16px!important;bottom:auto!important;left:auto!important;z-index:200;width:260px!important;border-radius:8px!important;border:1px solid var(--border);box-shadow:0 4px 16px rgba(0,0,0,.4)}\n#dv-auth-panel .dv-bar{display:none!important}\n#dv-auth-panel .dv-body{max-height:70vh}\n\n@media(max-width:640px){\n  .main{padding:12px}\n  .header{padding:8px 12px;gap:8px}\n  .card-header{padding:12px;flex-direction:column;align-items:flex-start}\n  .section-content{padding:4px 12px 12px}\n  .footer-meta{padding:8px 12px;gap:8px}\n  #dv-auth-panel{right:8px!important;width:240px!important}\n}\n</style>\n</head>\n<body>\n<div class=\"header\">\n  <div class=\"header-logo\">Node Viewer</div>\n  <div class=\"header-ref\" id=\"header-ref\"></div>\n  <button class=\"btn\" id=\"btn-home\" title=\"Go to ROOT\">Home</button>\n  <button class=\"btn\" id=\"btn-graph1\" title=\"Open in Graph Viewer (UML style)\">Graph 1</button>\n  <button class=\"btn\" id=\"btn-graph2\" title=\"Open in Graph Viewer (cosmic style)\">Graph 2</button>\n  <button class=\"btn btn-primary\" id=\"btn-goto\" title=\"Open raw node URL\">Go to Node</button>\n  <button class=\"btn\" id=\"btn-new\" style=\"display:none\" title=\"Create new object\">+ New</button>\n  <button class=\"btn\" id=\"btn-my-identity\" style=\"display:none\" title=\"View your identity node\">My Identity</button>\n  <button class=\"btn\" id=\"btn-login\" title=\"Login to view private data\">Login</button>\n</div>\n<div class=\"main\" id=\"app\">\n  <div class=\"loading\"><div class=\"spinner\"></div><div style=\"color:var(--text-dim);font-size:14px\">Loading node...</div></div>\n</div>\n\n<script>\n(function(){\n'use strict';\n\nconst API = window.location.origin;\nconst ROOT_REF = 'AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ.00000000-0000-0000-0000-000000000000';\nconst GRAPH_V1_REF = 'AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ.09904511-738f-4399-b11d-56dcbc2b3ea7'; // UML/white\nconst GRAPH_V2_REF = 'AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ.6515ca70-9b42-4866-87f1-ff68a2b892f0'; // cosmic/colorful\nconst SELF_REFS = [ // this PAGE and its parent APP — show root instead of ourselves\n  'AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ.b3f5a7c9-2d4e-4f60-9b8a-0c1d2e3f4a5b',\n  'AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ.a2e4f6b8-1c3d-4e5f-8a7b-9c0d1e2f3a4b',\n];\n\nlet currentObj = null;\nconst pendingEdits = {};\n\n// Ref pattern: 44-char base64url pubkey + '.' + UUID\nconst REF_RE = /[A-Za-z0-9_-]{43,44}\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;\n\nfunction getRef() {\n  // Check ?ref= param first, then extract from URL path\n  const p = new URLSearchParams(location.search);\n  if (p.get('ref')) return p.get('ref');\n  const path = location.pathname.replace(/^\\//, '');\n  const match = path.match(REF_RE);\n  if (match && !SELF_REFS.includes(match[0])) return match[0];\n  return ROOT_REF;\n}\n\nfunction navigateTo(ref) {\n  // Navigate using clean path URLs: /pubkey.uuid\n  history.pushState({ref}, '', '/' + ref);\n  loadNode(ref);\n}\n\nfunction abbrevRef(ref) {\n  const parts = ref.split('.');\n  if (parts.length < 2) return ref;\n  const pk = parts[0];\n  return pk.slice(0,4) + '...' + pk.slice(-4) + '.' + parts.slice(1).join('.').slice(0,8) + '...';\n}\n\nfunction objectName(item) {\n  if (!item) return '?';\n  const c = item.content || {};\n  return c.name || c.title || ((item.type || 'OBJECT') + ' ' + (item.id || '').slice(0,8));\n}\n\n// Bracket depth colors\nconst BRACKET_COLORS = ['var(--bracket1)','var(--bracket2)','var(--bracket3)','var(--bracket4)'];\n\nfunction bracketColor(depth) {\n  return BRACKET_COLORS[depth % BRACKET_COLORS.length];\n}\n\n// Render JSON as a colorful, collapsible tree\nfunction renderJsonTree(data, depth, path, collapsed) {\n  if (data === null) return '<span class=\"json-null\">null</span>';\n  if (data === undefined) return '<span class=\"json-null\">undefined</span>';\n  if (typeof data === 'boolean') return '<span class=\"json-bool\">' + data + '</span>';\n  if (typeof data === 'number') return '<span class=\"json-number\">' + data + '</span>';\n  if (typeof data === 'string') {\n    // Check if it's a dataverse ref\n    const escaped = escHtml(data);\n    if (data.match(REF_RE) && data === data.match(REF_RE)[0]) {\n      return '\"<a class=\"json-ref\" href=\"/' + escaped + '\" data-ref=\"' + escaped + '\" title=\"Navigate to ' + escaped + '\">' + escaped + '</a>\"';\n    }\n    // Truncate very long strings for display\n    if (data.length > 200) {\n      const id = 'str-' + path + '-' + Math.random().toString(36).slice(2,8);\n      return '\"<span class=\"json-string\" id=\"' + id + '\">' + escHtml(data.slice(0,200)) +\n        '<span class=\"json-ellipsis\" onclick=\"this.parentNode.innerHTML=\\'' + escHtml(escJs(data)) + '\\'\">' +\n        '... (' + data.length + ' chars)</span></span>\"';\n    }\n    return '\"<span class=\"json-string\">' + escaped + '</span>\"';\n  }\n\n  const isArr = Array.isArray(data);\n  const entries = isArr ? data.map((v,i) => [i,v]) : Object.entries(data);\n  const open = isArr ? '[' : '{';\n  const close = isArr ? ']' : '}';\n  const nodeId = 'node-' + path.replace(/[^a-z0-9]/gi, '-') + '-' + depth;\n\n  if (entries.length === 0) {\n    return '<span class=\"json-bracket\" style=\"color:' + bracketColor(depth) + '\">' + open + close + '</span>';\n  }\n\n  const shouldCollapse = collapsed || (depth >= 3 && entries.length > 3);\n  const preview = isArr\n    ? entries.length + ' item' + (entries.length !== 1 ? 's' : '')\n    : entries.map(e => e[0]).slice(0,3).join(', ') + (entries.length > 3 ? ', ...' : '');\n\n  let html = '<span class=\"json-bracket\" style=\"color:' + bracketColor(depth) + '\" data-toggle=\"' + nodeId + '\">' + open + '</span>';\n\n  // Collapsed preview\n  html += '<span class=\"json-collapsed-preview\" id=\"' + nodeId + '-preview\" style=\"display:' + (shouldCollapse ? 'inline' : 'none') + '\">';\n  html += ' <span class=\"json-ellipsis\" data-toggle=\"' + nodeId + '\">' + escHtml(preview) + '</span> ';\n  html += '<span class=\"json-bracket\" style=\"color:' + bracketColor(depth) + '\">' + close + '</span></span>';\n\n  // Expanded content\n  html += '<span id=\"' + nodeId + '\" style=\"display:' + (shouldCollapse ? 'none' : 'inline') + '\">';\n  html += '\\n';\n  entries.forEach(([key, val], i) => {\n    const childPath = path + '.' + key;\n    const indent = '  '.repeat(depth + 1);\n    html += indent;\n    if (!isArr) {\n      html += '<span class=\"json-key\">\"' + escHtml(String(key)) + '\"</span>: ';\n    }\n    html += renderJsonTree(val, depth + 1, childPath, false);\n    if (i < entries.length - 1) html += ',';\n    html += '\\n';\n  });\n  html += '  '.repeat(depth) + '<span class=\"json-bracket\" style=\"color:' + bracketColor(depth) + '\">' + close + '</span>';\n  html += '</span>';\n\n  return html;\n}\n\nfunction escHtml(s) {\n  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;').replace(/'/g,'&#39;');\n}\n\nfunction escJs(s) {\n  return String(s).replace(/\\\\/g,'\\\\\\\\').replace(/'/g,\"\\\\'\").replace(/\"/g,'\\\\\"').replace(/\\n/g,'\\\\n').replace(/\\r/g,'\\\\r').replace(/</g,'\\\\x3c');\n}\n\n// Toggle collapsed JSON nodes\ndocument.addEventListener('click', function(e) {\n  // Inline field editing\n  const editEl = e.target.closest('[data-edit-field]');\n  if (editEl && !editEl.querySelector('.edit-inline,.edit-inline-ta') && !e.target.closest('a')) {\n    e.stopPropagation();\n    handleFieldEdit(editEl);\n    return;\n  }\n\n  // JSON bracket/ellipsis toggle\n  const toggle = e.target.dataset?.toggle;\n  if (toggle) {\n    const node = document.getElementById(toggle);\n    const preview = document.getElementById(toggle + '-preview');\n    if (node && preview) {\n      const isHidden = node.style.display === 'none';\n      node.style.display = isHidden ? 'inline' : 'none';\n      preview.style.display = isHidden ? 'none' : 'inline';\n    }\n    return;\n  }\n\n  // Section collapse/expand\n  const header = e.target.closest('.section-header');\n  if (header) {\n    const body = header.nextElementSibling;\n    const chevron = header.querySelector('.section-chevron');\n    if (body && body.classList.contains('section-body')) {\n      const isOpen = body.classList.contains('open');\n      if (isOpen) {\n        body.style.maxHeight = body.scrollHeight + 'px';\n        requestAnimationFrame(() => {\n          body.style.maxHeight = '0';\n          body.style.opacity = '0';\n        });\n        body.classList.remove('open');\n        chevron?.classList.remove('open');\n      } else {\n        body.classList.add('open');\n        chevron?.classList.add('open');\n        body.style.maxHeight = body.scrollHeight + 'px';\n        body.style.opacity = '1';\n        setTimeout(() => { body.style.maxHeight = 'none'; }, 350);\n      }\n    }\n    return;\n  }\n\n  // Ref click — only intercept plain left-click for SPA navigation;\n  // middle-click / ctrl+click / cmd+click follow the real href naturally\n  const refEl = e.target.closest('[data-ref]');\n  if (refEl) {\n    const ref = refEl.dataset.ref;\n    if (ref && ref.match(REF_RE) && e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey) {\n      e.preventDefault();\n      navigateTo(ref.match(REF_RE)[0]);\n    }\n    return;\n  }\n});\n\n// Back/forward navigation\nwindow.addEventListener('popstate', function(e) {\n  loadNode(getRef());\n});\n\n// Header buttons\ndocument.getElementById('btn-home').addEventListener('click', function() {\n  navigateTo(ROOT_REF);\n});\ndocument.getElementById('btn-graph1').addEventListener('click', function() {\n  window.open(API + '/' + GRAPH_V1_REF + '#' + getRef(), '_blank');\n});\ndocument.getElementById('btn-graph2').addEventListener('click', function() {\n  window.open(API + '/' + GRAPH_V2_REF + '#' + getRef(), '_blank');\n});\ndocument.getElementById('btn-goto').addEventListener('click', function() {\n  const ref = getRef();\n  window.open(API + '/' + ref, '_blank');\n});\ndocument.getElementById('btn-my-identity').addEventListener('click', function() {\n  const identityRef = this.dataset.ref || (window.DV && DV.wallet && DV.wallet.identityRef);\n  if (identityRef) navigateTo(identityRef);\n});\n\n// Chevron SVG\nfunction chevronSvg(open) {\n  return '<svg class=\"section-chevron' + (open ? ' open' : '') + '\" viewBox=\"0 0 16 16\" fill=\"currentColor\"><path d=\"M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z\"/></svg>';\n}\n\nfunction makeSection(label, count, contentHtml, startOpen, sectionId) {\n  const countStr = count != null ? '<span class=\"section-count\">(' + count + ')</span>' : '';\n  const idAttr = sectionId ? ' id=\"' + sectionId + '\"' : '';\n  return '<div class=\"section\"' + idAttr + '>' +\n    '<div class=\"section-header\">' + chevronSvg(startOpen) +\n    '<span class=\"section-label\">' + label + '</span>' + countStr +\n    '</div>' +\n    '<div class=\"section-body' + (startOpen ? ' open' : '') + '\" style=\"' + (startOpen ? 'max-height:none;opacity:1' : 'max-height:0;opacity:0') + '\">' +\n    '<div class=\"section-content\">' + contentHtml + '</div>' +\n    '</div></div>';\n}\n\nasync function fetchJson(url) {\n  const r = await fetch(url, {headers: {'Accept': 'application/json'}});\n  if (!r.ok) throw new Error('HTTP ' + r.status);\n  return r.json();\n}\n\nasync function loadNode(ref) {\n  // Clear pending edits\n  for (const k in pendingEdits) delete pendingEdits[k];\n  const bar = document.getElementById('save-bar');\n  if (bar) bar.remove();\n\n  const app = document.getElementById('app');\n  const headerRef = document.getElementById('header-ref');\n  headerRef.textContent = ref;\n  app.innerHTML = '<div class=\"loading\"><div class=\"spinner\"></div><div style=\"color:var(--text-dim);font-size:14px\">Loading node...</div></div>';\n\n  try {\n    const obj = await fetchJson(API + '/' + ref);\n    currentObj = obj;\n    const item = obj.item;\n    if (!item) throw new Error('Not a valid dataverse001 object');\n\n    const name = objectName(item);\n    const type = item.type || 'OBJECT';\n    const rev = item.revision != null ? item.revision : '?';\n\n    // Check edit permission\n    const canEditObj = typeof DV !== 'undefined' && DV.isLoggedIn && DV.isLoggedIn() && DV.isOwn && DV.isOwn(item);\n    const edCl = canEditObj ? ' field-editable' : '';\n\n    // Build sections\n    let sections = '';\n\n    // 1. Full envelope JSON (colorful tree)\n    const jsonHtml = '<div class=\"json-tree\">' + renderJsonTree(obj, 0, 'root', false) + '</div>';\n    sections += makeSection('Object JSON', null, jsonHtml, true);\n\n    // 2. Instruction (markdown) — open by default, scroll target\n    if (item.instruction) {\n      let mdHtml;\n      const edAttr = canEditObj ? ' data-edit-field=\"instruction\"' : '';\n      if (typeof marked !== 'undefined' && marked.parse) {\n        mdHtml = '<div class=\"md-content' + edCl + '\"' + edAttr + '>' + marked.parse(item.instruction) + '</div>';\n      } else {\n        mdHtml = '<pre class=\"' + edCl.trim() + '\" style=\"white-space:pre-wrap;color:var(--text)\"' + edAttr + '>' + escHtml(item.instruction) + '</pre>';\n      }\n      sections += makeSection('Instruction', null, mdHtml, true, 'section-instruction');\n    }\n\n    // 2b. Content fields (editable)\n    const CONTENT_SKIP = new Set(['html', 'data', 'html_file', 'content_type']);\n    if (item.content) {\n      const contentKeys = Object.keys(item.content).filter(function(k) { return !CONTENT_SKIP.has(k); });\n      if (contentKeys.length > 0) {\n        let cHtml = '';\n        for (const k of contentKeys) {\n          const v = item.content[k];\n          const val = typeof v === 'string' ? v : JSON.stringify(v);\n          const display = val.length > 200 ? val.slice(0, 200) + '...' : val;\n          const eFld = canEditObj ? ' class=\"field-editable\" data-edit-field=\"content.' + escHtml(k) + '\"' : '';\n          cHtml += '<div class=\"rel-item\"><div class=\"rel-item-row\">';\n          cHtml += '<span class=\"rel-group-name\" style=\"min-width:80px\">' + escHtml(k) + '</span>';\n          cHtml += '<span' + eFld + ' style=\"flex:1;color:var(--text-dim)\">' + escHtml(display) + '</span>';\n          cHtml += '</div></div>';\n        }\n        sections += makeSection('Content', contentKeys.length, cHtml, false);\n      }\n    }\n\n    // 3. Outgoing relations\n    if (item.relations && Object.keys(item.relations).length > 0) {\n      let relHtml = '';\n      for (const [relName, entries] of Object.entries(item.relations)) {\n        relHtml += '<div class=\"rel-group\"><div class=\"rel-group-name\">' + escHtml(relName) + '</div>';\n        for (const entry of entries) {\n          const entryRef = entry.ref;\n          const label = entry.title || entry.name || '';\n          const detail = entry.instruction || entry.description || '';\n          relHtml += '<div class=\"rel-item\">';\n          relHtml += '<div class=\"rel-item-row\">';\n          relHtml += '<a href=\"/' + escHtml(entryRef) + '\" data-ref=\"' + escHtml(entryRef) + '\">' + abbrevRef(entryRef) + '</a>';\n          if (label) relHtml += '<span class=\"rel-hint\">' + escHtml(label) + '</span>';\n          relHtml += '</div>';\n          if (detail) relHtml += '<div class=\"rel-instruction\">' + escHtml(detail) + '</div>';\n          relHtml += '</div>';\n        }\n        relHtml += '</div>';\n      }\n      const totalRels = Object.values(item.relations).reduce((s,a) => s + a.length, 0);\n      sections += makeSection('Outgoing Relations', totalRels, relHtml, true);\n    }\n\n    // 4. Inbound relations (async, last 25)\n    const inboundId = 'inbound-' + Date.now();\n    sections += makeSection('Inbound Relations', '...', '<div id=\"' + inboundId + '\"><div class=\"loading\" style=\"min-height:60px\"><div class=\"spinner\"></div></div></div>', false);\n\n    // Build card\n    let html = '<div class=\"card\">';\n    html += '<div class=\"card-header\"><div class=\"card-title' + edCl + '\"' + (canEditObj ? ' data-edit-field=\"name\"' : '') + '>' + escHtml(name) + '</div>';\n    html += '<div class=\"card-meta\">';\n    html += '<span class=\"badge badge-type' + edCl + '\"' + (canEditObj ? ' data-edit-field=\"type\"' : '') + '>' + escHtml(type) + '</span>';\n    html += '<span class=\"badge badge-rev\">rev ' + rev + '</span></div></div>';\n    html += sections;\n\n    // Footer metadata\n    html += '<div class=\"footer-meta\">';\n    html += '<span>pubkey: ' + escHtml((item.pubkey || '').slice(0,8) + '...' + (item.pubkey || '').slice(-4)) + '</span>';\n    if (item.created_at) html += '<span>created: ' + escHtml(item.created_at) + '</span>';\n    if (item.updated_at) html += '<span>updated: ' + escHtml(item.updated_at) + '</span>';\n    if (obj.signature) html += '<span>sig: ' + escHtml((obj.signature || '').slice(0,12)) + '...</span>';\n    html += '</div>';\n    html += '</div>';\n\n    app.innerHTML = html;\n\n    // Make refs in rendered markdown clickable\n    app.querySelectorAll('.md-content').forEach(function(el) {\n      el.innerHTML = el.innerHTML.replace(\n        /([A-Za-z0-9_-]{43,44}\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g,\n        '<a class=\"json-ref\" href=\"/$1\" data-ref=\"$1\" style=\"cursor:pointer\">$1</a>'\n      );\n    });\n\n    // Scroll to instruction section if it exists\n    const instrSection = document.getElementById('section-instruction');\n    if (instrSection) {\n      requestAnimationFrame(() => {\n        instrSection.scrollIntoView({behavior: 'smooth', block: 'start'});\n      });\n    }\n\n    // Load inbound relations\n    loadInbound(ref, inboundId);\n\n  } catch (err) {\n    app.innerHTML = '<div class=\"error\">Failed to load node: ' + escHtml(err.message) + '</div>';\n  }\n}\n\nasync function loadInbound(ref, containerId) {\n  const container = document.getElementById(containerId);\n  if (!container) return;\n\n  try {\n    const data = await fetchJson(API + '/' + ref + '/inbound?limit=50&include=inbound_counts');\n    const items = data.items || [];\n\n    // Update section count\n    const section = container.closest('.section');\n    if (section) {\n      const countEl = section.querySelector('.section-count');\n      if (countEl) countEl.textContent = '(' + items.length + (items.length >= 25 ? '+' : '') + ')';\n    }\n\n    if (items.length === 0) {\n      container.innerHTML = '<div style=\"color:var(--text-dimmer);font-size:13px;padding:8px\">No inbound relations found</div>';\n      return;\n    }\n\n    let html = '';\n    for (const obj of items) {\n      const item = obj.item;\n      if (!item) continue;\n      const iRef = item.pubkey + '.' + item.id;\n      const iName = objectName(item);\n      const iType = item.type || '?';\n\n      // Find which relation points to our ref\n      let viaRel = '';\n      if (item.relations) {\n        for (const [rn, entries] of Object.entries(item.relations)) {\n          for (const e of entries) {\n            if (e.ref === ref) { viaRel = rn; break; }\n          }\n          if (viaRel) break;\n        }\n      }\n\n      html += '<div class=\"inbound-item\">';\n      html += '<a href=\"/' + escHtml(iRef) + '\" data-ref=\"' + escHtml(iRef) + '\" style=\"font-family:ui-monospace,\\'SF Mono\\',Menlo,monospace;font-size:12px\">' + escHtml(iName) + '</a>';\n      html += '<span class=\"inbound-type\">' + escHtml(iType) + '</span>';\n      if (viaRel) html += '<span class=\"inbound-via\">via ' + escHtml(viaRel) + '</span>';\n\n      // Show inbound counts if available\n      if (obj._inbound_counts) {\n        const counts = obj._inbound_counts;\n        const parts = Object.entries(counts).map(([k,v]) => k + ':' + v);\n        if (parts.length) html += '<span class=\"rel-hint\">[' + parts.join(' ') + ']</span>';\n      }\n      html += '</div>';\n    }\n\n    container.innerHTML = html;\n  } catch (err) {\n    container.innerHTML = '<div style=\"color:var(--text-dimmer);font-size:13px;padding:8px\">Could not load inbound relations: ' + escHtml(err.message) + '</div>';\n  }\n}\n\n// ── Inline editing ──\n\nfunction handleFieldEdit(el) {\n  const field = el.dataset.editField;\n  if (!currentObj || !currentObj.item) return;\n  const item = currentObj.item;\n  if (field === 'instruction') {\n    startTextareaEdit(el, item.instruction || '', field);\n  } else if (field === 'name') {\n    var cur = item.name || item.content?.name || item.content?.title || '';\n    startTextEdit(el, cur, field);\n  } else if (field === 'type') {\n    startTextEdit(el, item.type || '', field);\n  } else if (field.startsWith('content.')) {\n    var ck = field.slice(8);\n    var v = item.content ? item.content[ck] : '';\n    var val = v != null ? (typeof v === 'string' ? v : JSON.stringify(v)) : '';\n    if (val.length > 100 || val.includes('\\n')) {\n      startTextareaEdit(el, val, field);\n    } else {\n      startTextEdit(el, val, field);\n    }\n  }\n}\n\nfunction startTextEdit(el, currentValue, editKey) {\n  var input = document.createElement('input');\n  input.className = 'edit-inline';\n  input.value = pendingEdits[editKey] !== undefined ? pendingEdits[editKey] : currentValue;\n  input.style.width = Math.max(el.offsetWidth + 20, 120) + 'px';\n  var origHtml = el.innerHTML;\n  el.textContent = '';\n  el.appendChild(input);\n  input.focus();\n  input.select();\n  function commit() {\n    var newVal = input.value.trim();\n    if (newVal !== currentValue) {\n      recordEdit(editKey, newVal);\n      el.textContent = newVal || '(empty)';\n    } else {\n      el.innerHTML = origHtml;\n      delete pendingEdits[editKey];\n      updateSaveBar();\n    }\n  }\n  input.addEventListener('blur', commit);\n  input.addEventListener('keydown', function(ev) {\n    if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }\n    if (ev.key === 'Escape') { input.value = currentValue; input.blur(); }\n  });\n}\n\nfunction startTextareaEdit(el, currentValue, editKey) {\n  var ta = document.createElement('textarea');\n  ta.className = 'edit-inline-ta';\n  ta.value = pendingEdits[editKey] !== undefined ? pendingEdits[editKey] : currentValue;\n  ta.rows = Math.min(20, Math.max(4, (ta.value || '').split('\\n').length + 2));\n  var origHtml = el.innerHTML;\n  el.textContent = '';\n  el.appendChild(ta);\n  ta.focus();\n  function commit() {\n    var newVal = ta.value;\n    if (newVal !== currentValue) {\n      recordEdit(editKey, newVal);\n    } else {\n      delete pendingEdits[editKey];\n      updateSaveBar();\n    }\n    if (editKey === 'instruction' && typeof marked !== 'undefined' && marked.parse) {\n      el.innerHTML = marked.parse(newVal || '');\n    } else {\n      el.textContent = newVal || '(empty)';\n    }\n  }\n  ta.addEventListener('blur', commit);\n  ta.addEventListener('keydown', function(ev) {\n    if (ev.key === 'Escape') { ta.value = currentValue; ta.blur(); }\n  });\n}\n\nfunction recordEdit(key, value) {\n  pendingEdits[key] = value;\n  updateSaveBar();\n}\n\nfunction updateSaveBar() {\n  var count = Object.keys(pendingEdits).length;\n  var bar = document.getElementById('save-bar');\n  if (count === 0) { if (bar) bar.remove(); return; }\n  if (!bar) {\n    bar = document.createElement('div');\n    bar.id = 'save-bar';\n    bar.className = 'save-bar';\n    document.body.appendChild(bar);\n  }\n  bar.innerHTML = '<span>' + count + ' change' + (count > 1 ? 's' : '') + ' pending</span>' +\n    '<button id=\"sb-discard\">Discard</button><button class=\"sb-primary\" id=\"sb-save\">Save</button>';\n  document.getElementById('sb-discard').addEventListener('click', discardEdits);\n  document.getElementById('sb-save').addEventListener('click', confirmAndSave);\n}\n\nfunction discardEdits() {\n  for (var k in pendingEdits) delete pendingEdits[k];\n  updateSaveBar();\n  loadNode(getRef());\n}\n\nfunction confirmAndSave() {\n  var count = Object.keys(pendingEdits).length;\n  var fields = Object.keys(pendingEdits).join(', ');\n  if (!confirm('Save ' + count + ' change' + (count > 1 ? 's' : '') + '?\\n\\nFields: ' + fields)) return;\n  var ref = getRef();\n  var edits = Object.assign({}, pendingEdits);\n  var bar = document.getElementById('save-bar');\n  if (bar) bar.innerHTML = '<span>Saving...</span>';\n  DV.editObject(ref, function(item) {\n    for (var key in edits) {\n      var val = edits[key];\n      if (key === 'name') { if (val) item.name = val; else delete item.name; }\n      else if (key === 'type') { item.type = val || item.type; }\n      else if (key === 'instruction') { if (val.trim()) item.instruction = val; else delete item.instruction; }\n      else if (key.startsWith('content.')) {\n        var ck = key.slice(8);\n        if (!item.content) item.content = {};\n        item.content[ck] = val;\n      }\n    }\n    return item;\n  }).then(function(result) {\n    for (var k in pendingEdits) delete pendingEdits[k];\n    updateSaveBar();\n    alert('Saved! Revision ' + (result.signedObject ? result.signedObject.item.revision : '?'));\n    loadNode(ref);\n  }).catch(function(err) {\n    alert('Error: ' + err.message);\n  });\n}\n\n// ── New object creation ──\nvar _typeCache = null;\nvar _modalEl = null;\n\nfunction _modalEscHandler(e) { if (e.key === 'Escape') closeNewModal(); }\n\nfunction showNewModal() {\n  if (typeof DV === 'undefined' || !DV.isLoggedIn || !DV.isLoggedIn()) return;\n  _modalEl = document.createElement('div');\n  _modalEl.className = 'modal-backdrop';\n  _modalEl.addEventListener('click', function(e) { if (e.target === _modalEl) closeNewModal(); });\n  document.body.appendChild(_modalEl);\n\n  var card = document.createElement('div');\n  card.className = 'modal-card';\n  card.innerHTML = '<div class=\"modal-header\"><h2>Create New Object</h2><button class=\"modal-close\" title=\"Close\">&times;</button></div>' +\n    '<div class=\"modal-body\"><div class=\"loading\" style=\"min-height:120px\"><div class=\"spinner\"></div></div></div>';\n  _modalEl.appendChild(card);\n  card.querySelector('.modal-close').addEventListener('click', closeNewModal);\n  document.addEventListener('keydown', _modalEscHandler);\n\n  loadTypes().then(function(types) {\n    renderTypePicker(types, card.querySelector('.modal-body'));\n  }).catch(function(err) {\n    card.querySelector('.modal-body').innerHTML = '<div style=\"color:var(--red);padding:20px\">Failed to load types: ' + escHtml(err.message) + '</div>';\n  });\n}\n\nfunction closeNewModal() {\n  document.removeEventListener('keydown', _modalEscHandler);\n  if (_modalEl) { _modalEl.remove(); _modalEl = null; }\n}\n\nfunction loadTypes() {\n  if (_typeCache) return Promise.resolve(_typeCache);\n  return fetchJson(API + '/search?type=TYPE&limit=100').then(function(data) {\n    var items = (data.items || []).filter(function(obj) {\n      return obj.item && obj.item.content && obj.item.content.name;\n    }).sort(function(a, b) {\n      var na = (a.item.content.name || '').toLowerCase();\n      var nb = (b.item.content.name || '').toLowerCase();\n      return na < nb ? -1 : na > nb ? 1 : 0;\n    });\n    _typeCache = items;\n    return items;\n  });\n}\n\nfunction renderTypePicker(types, container) {\n  var html = '<input class=\"type-search\" placeholder=\"Filter types...\" autofocus>';\n  html += '<div class=\"type-list\">';\n  for (var i = 0; i < types.length; i++) {\n    var t = types[i].item;\n    var name = t.content.name || t.type || '?';\n    var desc = t.content.description || '';\n    html += '<div class=\"type-item\" data-type-idx=\"' + i + '\">';\n    html += '<div class=\"type-item-name\">' + escHtml(name) + '</div>';\n    if (desc) html += '<div class=\"type-item-desc\">' + escHtml(desc) + '</div>';\n    html += '</div>';\n  }\n  html += '</div>';\n  container.innerHTML = html;\n\n  var search = container.querySelector('.type-search');\n  search.focus();\n  search.addEventListener('input', function() {\n    var q = search.value.toLowerCase();\n    var items = container.querySelectorAll('.type-item');\n    for (var j = 0; j < items.length; j++) {\n      var idx = parseInt(items[j].dataset.typeIdx);\n      var t = types[idx].item;\n      var match = !q || (t.content.name || '').toLowerCase().indexOf(q) !== -1 ||\n        (t.content.description || '').toLowerCase().indexOf(q) !== -1;\n      items[j].style.display = match ? '' : 'none';\n    }\n  });\n\n  container.querySelector('.type-list').addEventListener('click', function(e) {\n    var item = e.target.closest('.type-item');\n    if (!item) return;\n    selectType(types[parseInt(item.dataset.typeIdx)]);\n  });\n}\n\nfunction selectType(typeObj) {\n  var card = _modalEl.querySelector('.modal-card');\n  var body = card.querySelector('.modal-body');\n  var schema = (typeObj.item.content && typeObj.item.content.schema) || {};\n  var typeName = typeObj.item.content.name || typeObj.item.type || '?';\n  var typeRef = typeObj.item.ref || (typeObj.item.pubkey + '.' + typeObj.item.id);\n  var parsed = parseSchemaFields(schema);\n\n  var html = '<button class=\"form-back\" id=\"form-back\">&larr; Back to types</button>';\n  html += '<h2 style=\"color:var(--text);font-size:16px;margin:12px 0 16px\">New ' + escHtml(typeName) + '</h2>';\n  html += '<form class=\"create-form\" onsubmit=\"return false\">';\n\n  // Standard fields\n  html += '<div class=\"form-section\"><div class=\"form-section-title\">Standard Fields</div>';\n  html += '<div class=\"form-group\"><label class=\"form-label\">Name</label>' +\n    '<input class=\"form-input\" data-field=\"name\" placeholder=\"Short label for this object\"></div>';\n  html += '<div class=\"form-group\"><label class=\"form-label\">Instruction</label>' +\n    '<textarea class=\"form-textarea\" data-field=\"instruction\" rows=\"3\" placeholder=\"How should agents interpret this object?\"></textarea></div>';\n  html += '</div>';\n\n  // Content fields from schema\n  if (parsed.contentFields.length > 0) {\n    html += '<div class=\"form-section\"><div class=\"form-section-title\">Content</div>';\n    for (var i = 0; i < parsed.contentFields.length; i++) {\n      var f = parsed.contentFields[i];\n      var req = parsed.contentRequired.indexOf(f.name) !== -1;\n      var isLong = ['body', 'text', 'description', 'reason', 'html', 'instruction'].indexOf(f.name) !== -1;\n      html += '<div class=\"form-group\"><label class=\"form-label\">' + escHtml(f.name);\n      if (req) html += '<span class=\"req\">*</span>';\n      html += '</label>';\n      if (f.enumVals) {\n        html += '<select class=\"form-input\" data-content-field=\"' + escHtml(f.name) + '\"' + (req ? ' data-required=\"true\"' : '') + '>';\n        html += '<option value=\"\">— select —</option>';\n        for (var ei = 0; ei < f.enumVals.length; ei++) {\n          html += '<option value=\"' + escHtml(f.enumVals[ei]) + '\">' + escHtml(f.enumVals[ei]) + '</option>';\n        }\n        html += '</select>';\n      } else if (isLong) {\n        html += '<textarea class=\"form-textarea\" data-content-field=\"' + escHtml(f.name) + '\" rows=\"4\"' + (req ? ' data-required=\"true\"' : '') + '></textarea>';\n      } else {\n        html += '<input class=\"form-input\" data-content-field=\"' + escHtml(f.name) + '\"' + (req ? ' data-required=\"true\"' : '') +\n          (f.type === 'integer' || f.type === 'number' ? ' type=\"number\"' : '') +\n          (f.description ? ' placeholder=\"' + escHtml(f.description) + '\"' : '') + '>';\n      }\n      if (f.description && !f.enumVals) html += '<div class=\"form-hint\">' + escHtml(f.description) + '</div>';\n      html += '</div>';\n    }\n    html += '</div>';\n  }\n\n  // Relations section\n  var autoRels = { type_def: typeRef, root: ROOT_REF };\n  if (DV.wallet.identityRef) autoRels.author = DV.wallet.identityRef;\n\n  html += '<div class=\"form-section\"><div class=\"form-section-title\">Relations</div>';\n\n  // Auto-filled relations\n  var autoKeys = Object.keys(autoRels);\n  for (var a = 0; a < autoKeys.length; a++) {\n    html += '<div class=\"form-group\"><label class=\"form-label\">' + escHtml(autoKeys[a]) + ' <span style=\"color:var(--text-dimmer)\">(auto)</span></label>' +\n      '<input class=\"form-input\" data-relation-field=\"' + escHtml(autoKeys[a]) + '\" value=\"' + escHtml(autoRels[autoKeys[a]]) + '\" readonly></div>';\n  }\n\n  // Schema-defined relations (excluding auto-filled ones)\n  for (var r = 0; r < parsed.relationFields.length; r++) {\n    var rf = parsed.relationFields[r];\n    if (autoRels[rf.name]) continue;\n    var rreq = parsed.relationRequired.indexOf(rf.name) !== -1;\n    html += '<div class=\"form-group\"><label class=\"form-label\">' + escHtml(rf.name);\n    if (rreq) html += '<span class=\"req\">*</span>';\n    html += '</label>';\n    html += '<input class=\"form-input\" data-relation-field=\"' + escHtml(rf.name) + '\"' + (rreq ? ' data-required=\"true\"' : '') +\n      ' placeholder=\"pubkey.uuid\">';\n    if (rf.description) html += '<div class=\"form-hint\">' + escHtml(rf.description) + '</div>';\n    html += '</div>';\n  }\n  html += '</div>';\n\n  // Fallback: raw JSON editor for complex schemas\n  if (parsed.hasComplex) {\n    html += '<div class=\"form-section\"><div class=\"form-section-title\">Advanced (raw JSON)</div>';\n    html += '<div class=\"form-hint\" style=\"margin-bottom:8px\">This type has a complex schema (oneOf/anyOf). You can edit the content as raw JSON below.</div>';\n    var skeleton = {};\n    for (var si = 0; si < parsed.contentFields.length; si++) skeleton[parsed.contentFields[si].name] = '';\n    html += '<textarea class=\"form-textarea\" data-field=\"raw-content\" rows=\"6\" style=\"font-size:12px\">' + escHtml(JSON.stringify(skeleton, null, 2)) + '</textarea>';\n    html += '</div>';\n  }\n\n  html += '</form>';\n\n  // Footer\n  html += '<div class=\"modal-footer\"><button class=\"btn\" id=\"form-cancel\">Cancel</button>' +\n    '<button class=\"btn btn-primary\" id=\"form-create\">Create ' + escHtml(typeName) + '</button></div>';\n\n  body.innerHTML = html;\n\n  body.querySelector('#form-back').addEventListener('click', function() {\n    loadTypes().then(function(types) { renderTypePicker(types, body); });\n  });\n  body.querySelector('#form-cancel').addEventListener('click', closeNewModal);\n  body.querySelector('#form-create').addEventListener('click', function() {\n    submitNewObject(typeObj);\n  });\n}\n\nfunction parseSchemaFields(schema) {\n  var result = { contentFields: [], relationFields: [], contentRequired: [], relationRequired: [], hasComplex: false };\n  if (!schema || !schema.properties) return result;\n\n  var cp = schema.properties.content;\n  if (cp) {\n    if (cp.oneOf || cp.anyOf) result.hasComplex = true;\n    result.contentRequired = cp.required || [];\n    var props = cp.properties || {};\n    var keys = Object.keys(props);\n    for (var i = 0; i < keys.length; i++) {\n      var k = keys[i];\n      var p = props[k];\n      result.contentFields.push({\n        name: k,\n        type: p.type || 'string',\n        description: p.description || '',\n        enumVals: p['enum'] || null,\n        format: p.format || null\n      });\n    }\n  }\n\n  var rp = schema.properties.relations;\n  if (rp && rp.properties) {\n    result.relationRequired = rp.required || [];\n    var rkeys = Object.keys(rp.properties);\n    for (var j = 0; j < rkeys.length; j++) {\n      var rk = rkeys[j];\n      var rpr = rp.properties[rk];\n      result.relationFields.push({\n        name: rk,\n        description: rpr.description || '',\n        minItems: rpr.minItems || 0,\n        maxItems: rpr.maxItems || null\n      });\n    }\n  }\n  return result;\n}\n\nfunction submitNewObject(typeObj) {\n  var form = _modalEl.querySelector('.create-form');\n  if (!form) return;\n\n  // Validate required fields\n  var missing = [];\n  var reqInputs = form.querySelectorAll('[data-required=\"true\"]');\n  for (var i = 0; i < reqInputs.length; i++) {\n    if (!reqInputs[i].value.trim()) {\n      missing.push(reqInputs[i].dataset.contentField || reqInputs[i].dataset.relationField);\n      reqInputs[i].style.borderColor = 'var(--red)';\n    } else {\n      reqInputs[i].style.borderColor = '';\n    }\n  }\n  if (missing.length > 0) {\n    alert('Required fields missing: ' + missing.join(', '));\n    return;\n  }\n\n  var typeName = typeObj.item.content.name || typeObj.item.type;\n  var typeRef = typeObj.item.ref || (typeObj.item.pubkey + '.' + typeObj.item.id);\n\n  // Gather content\n  var content = {};\n  var rawEl = form.querySelector('[data-field=\"raw-content\"]');\n  if (rawEl && rawEl.value.trim()) {\n    try { content = JSON.parse(rawEl.value); }\n    catch (e) { alert('Invalid JSON in raw content: ' + e.message); return; }\n  }\n  var cInputs = form.querySelectorAll('[data-content-field]');\n  for (var ci = 0; ci < cInputs.length; ci++) {\n    var val = cInputs[ci].value.trim();\n    if (val) {\n      var fn = cInputs[ci].dataset.contentField;\n      // Convert numeric fields\n      if (cInputs[ci].type === 'number' && val) val = Number(val);\n      content[fn] = val;\n    }\n  }\n\n  // Gather relations\n  var relations = {\n    type_def: [{ ref: typeRef }],\n    root: [{ ref: ROOT_REF, instruction: 'dataverse001 data-format. Read this first if you haven\\'t yet.', url: API + '/' + ROOT_REF }]\n  };\n  if (DV.wallet.identityRef) {\n    relations.author = [{ ref: DV.wallet.identityRef }];\n  }\n  var rInputs = form.querySelectorAll('[data-relation-field]');\n  for (var ri = 0; ri < rInputs.length; ri++) {\n    var rName = rInputs[ri].dataset.relationField;\n    if (rName === 'type_def' || rName === 'root' || rName === 'author') continue;\n    var rVal = rInputs[ri].value.trim();\n    if (rVal) {\n      if (!relations[rName]) relations[rName] = [];\n      relations[rName].push({ ref: rVal });\n    }\n  }\n\n  // Standard fields\n  var opts = { in: ['dataverse001'] };\n  var nameEl = form.querySelector('[data-field=\"name\"]');\n  var instrEl = form.querySelector('[data-field=\"instruction\"]');\n  if (nameEl && nameEl.value.trim()) opts.name = nameEl.value.trim();\n  if (instrEl && instrEl.value.trim()) opts.instruction = instrEl.value.trim();\n\n  var item = DV.buildItem(typeName, relations, content, opts);\n\n  // Disable button\n  var btn = _modalEl.querySelector('#form-create');\n  var origText = btn.textContent;\n  btn.textContent = 'Creating...';\n  btn.disabled = true;\n\n  DV.signAndPush(item).then(function(result) {\n    if (!result.ok) throw new Error('Push failed: ' + (result.error || 'HTTP ' + result.status));\n    closeNewModal();\n    var newRef = result.ref || (item.pubkey + '.' + item.id);\n    navigateTo(newRef);\n  }).catch(function(err) {\n    btn.textContent = origText;\n    btn.disabled = false;\n    alert('Error creating object: ' + err.message);\n  });\n}\n\n// Initial load\nloadNode(getRef());\n\n// ── Auth integration ──\nif (typeof DV !== 'undefined' && DV.init && DV.initWrite) {\n  DV.init({ api: window.location.origin });\n  function updateAuthButtons(w) {\n    var btn = document.getElementById('btn-login');\n    var btnNew = document.getElementById('btn-new');\n    var btnMyIdentity = document.getElementById('btn-my-identity');\n    if (w && w.identityRef && w.pubkey && !w.locked) {\n      btn.textContent = w.name || w.pubkey.slice(0,8) + '...';\n      btn.title = 'Logged in as ' + (w.name || w.pubkey);\n      btnNew.style.display = '';\n      btnMyIdentity.style.display = '';\n      btnMyIdentity.dataset.ref = w.identityRef;\n    } else {\n      btn.textContent = 'Login';\n      btn.title = 'Login to view private data';\n      btnNew.style.display = 'none';\n      btnMyIdentity.style.display = 'none';\n      delete btnMyIdentity.dataset.ref;\n    }\n  }\n  var authPanel = null;\n  DV.initWrite({\n    theme: 'dark',\n    onAuthChange: function(w) {\n      updateAuthButtons(w);\n      if (w.pubkey && !w.locked) {\n        loadNode(getRef()); // reload to show private data\n      }\n    }\n  });\n  authPanel = document.getElementById('dv-auth-panel');\n  if (authPanel) authPanel.style.display = 'none';\n  document.getElementById('btn-new').addEventListener('click', showNewModal);\n  document.getElementById('btn-login').addEventListener('click', function() {\n    if (!authPanel) return;\n    var visible = authPanel.style.display !== 'none';\n    authPanel.style.display = visible ? 'none' : 'block';\n    if (!visible) DV.expandWidget();\n  });\n  // Close panel on outside click\n  document.addEventListener('click', function(e) {\n    if (!authPanel || authPanel.style.display === 'none') return;\n    if (!authPanel.contains(e.target) && e.target.id !== 'btn-login') {\n      authPanel.style.display = 'none';\n    }\n  });\n}\n\n})();\n</script>\n</body>\n</html>","title":"Node Viewer"},"created_at":"2026-02-09T23:05:00+01:00","id":"b3f5a7c9-2d4e-4f60-9b8a-0c1d2e3f4a5b","in":["dataverse001"],"instruction":"Interactive web page for the Node Viewer application. Renders any dataverse001 object as a colorful, explorable tree with clickable refs, collapsible sections, markdown rendering, and inbound relation browsing. Loaded via URL parameter: ?ref=pubkey.uuid","pubkey":"AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ","ref":"AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ.b3f5a7c9-2d4e-4f60-9b8a-0c1d2e3f4a5b","relations":{"author":[{"ref":"AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ.346bef5e-94ff-4f7a-bcf6-d78ae1e1541c"}],"in_application":[{"ref":"AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ.a2e4f6b8-1c3d-4e5f-8a7b-9c0d1e2f3a4b","title":"Node Viewer"}],"root":[{"instruction":"dataverse001 data-format","ref":"AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ.00000000-0000-0000-0000-000000000000"}],"type_def":[{"ref":"AxyU5_5vWmP2tO_klN4UpbZzRsuJEvJTrdwdg_gODxZJ.2d851bf2-65e9-4f62-b646-2014613c964d"}]},"revision":15,"type":"PAGE","updated_at":"2026-03-26T12:00:00+01:00"},"signature":"MEUCIQDfDJlGwpH83SJdel47zaX+37Kc9IYiOvk8Sb6b2yN5HAIgLguo6iuT93F482AZMvpTvPbqTDa0g/WtRNhvjA4rr2I="}