UNPKG

trellis

Version:

Agentic State Engine — event-sourced causal graph with branching, decision traces, and realtime sync for AI-native applications

1,617 lines (1,435 loc) 68.9 kB
<!DOCTYPE html> <html lang="en" data-trellis-band="L3"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Trellis — System Visualizer</title> <meta name="description" content="Interactive system dashboard for exploring the Trellis semantic kernel"> <link rel="stylesheet" href="/theme/runtime-theme.css"> <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script> <style> /* Page-local layout + non-entity kind tints (contract owns semantics) */ :root { --nav-w: 56px; --top-h: 48px; --radius: var(--radius-md); --cyan: #67e8f9; /* page-local ops tint */ --purple: var(--surface-info-strong); --orange: #fb923c; /* page-local kind tint */ --pink: #f472b6; /* page-local kind tint */ } * { margin: 0; padding: 0; box-sizing: border-box } body { font-family: var(--font); background: var(--bg); color: var(--text); overflow: hidden; height: 100vh; width: 100vw } ::-webkit-scrollbar { width: 4px; height: 4px } ::-webkit-scrollbar-track { background: transparent } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px } /* ─── Top Bar ─── */ #topbar { position: fixed; top: 0; left: 0; right: 0; height: var(--top-h); background: var(--glass); backdrop-filter: blur(16px); border-bottom: 1px solid var(--glass-border); display: flex; align-items: center; padding: 0 16px; gap: 16px; z-index: 100 } .logo { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 14px; color: var(--accent2); letter-spacing: -0.3px; white-space: nowrap } .logo svg { width: 18px; height: 18px } .logo span { color: var(--text3); font-weight: 400; font-size: 12px; margin-left: 2px } .tabs { display: flex; gap: 2px; margin-left: 24px } .tab { background: none; border: none; color: var(--text3); font: 500 12px var(--font); padding: 6px 14px; border-radius: 6px; cursor: pointer; transition: all .15s; letter-spacing: .2px } .tab:hover { color: var(--text2); background: var(--surface-2) } .tab.active { color: var(--accent); background: var(--accent-glow) } .tab:focus-visible, .graph-controls button:focus-visible, #cmd-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px } .top-right { margin-left: auto; display: flex; align-items: center; gap: 10px } .live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); box-shadow: 0 0 6px var(--green); animation: pulse 2s infinite } @keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: .5 } } @media (prefers-reduced-motion: reduce) { .live-dot { animation: none } #drawer, #cmd-overlay, #cmd-box { transition: none !important } .graph-tooltip { transition: none } } .top-stats { font: 400 11px var(--mono); color: var(--text3) } #cmd-trigger { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; color: var(--text3); font: 400 11px var(--font); cursor: pointer; display: flex; align-items: center; gap: 6px; transition: border-color .15s } #cmd-trigger:hover { border-color: var(--accent) } #cmd-trigger kbd { background: var(--bg); padding: 1px 5px; border-radius: 3px; font: 500 10px var(--mono); color: var(--text3) } /* ─── Main Layout ─── */ #app { position: fixed; top: var(--top-h); left: 0; right: 0; bottom: 0; display: flex } #main { flex: 1; overflow: hidden; position: relative } .panel { position: absolute; inset: 0; display: none; overflow: auto } .panel.active { display: flex; flex-direction: column } /* ─── Detail Drawer ─── */ #drawer { position: fixed; top: var(--top-h); right: 0; bottom: 0; width: 340px; background: var(--glass); backdrop-filter: blur(20px); border-left: 1px solid var(--glass-border); transform: translateX(100%); transition: transform .25s cubic-bezier(.4, 0, .2, 1); z-index: 80; overflow-y: auto; display: flex; flex-direction: column } #drawer.open { transform: translateX(0) } .drawer-head { position: sticky; top: 0; background: var(--surface); padding: 14px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; z-index: 1 } .drawer-head h3 { font: 600 13px var(--font); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px } .drawer-close { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 16px; padding: 2px 6px; border-radius: 4px } .drawer-close:hover { color: var(--text); background: var(--surface2) } .drawer-body { padding: 16px; flex: 1 } .d-section { margin-bottom: 16px } .d-section h4 { font: 500 10px var(--font); text-transform: uppercase; letter-spacing: .6px; color: var(--text3); margin-bottom: 6px } .d-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font: 500 10px var(--mono); margin-right: 4px } .d-meta { font: 400 12px var(--font); color: var(--text2); line-height: 1.8 } .d-meta strong { color: var(--text); font-weight: 500 } .d-kv { display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; font: 400 11px var(--mono); color: var(--text2) } .d-kv .k { color: var(--text3) } .d-kv .v { overflow: hidden; text-overflow: ellipsis; white-space: nowrap } /* ─── Command Palette ─── */ #cmd-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, .55); z-index: 200; display: none; align-items: flex-start; justify-content: center; padding-top: 15vh } #cmd-overlay.open { display: flex } #cmd-box { width: 520px; background: var(--surface-3); border: 1px solid var(--border2); border-radius: var(--radius-lg); box-shadow: 0 20px 60px rgba(0, 0, 0, .6); overflow: hidden } #cmd-input { width: 100%; background: transparent; border: none; border-bottom: 1px solid var(--border); padding: 14px 16px; color: var(--text); font: 400 14px var(--font); outline: none } #cmd-input::placeholder { color: var(--text3) } #cmd-results { max-height: 320px; overflow-y: auto } .cmd-item { padding: 10px 16px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: background .1s } .cmd-item:hover, .cmd-item.selected { background: var(--accent-glow) } .cmd-item .ci-type { font: 500 9px var(--mono); color: var(--text3); text-transform: uppercase; width: 48px; flex-shrink: 0 } .cmd-item .ci-label { font: 400 13px var(--font); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap } /* ─── Graph Panel (Studio GraphView parity) ─── */ #graph-panel.panel.active { display: flex; flex-direction: column; padding: 0; overflow: hidden } .graph-layout { display: flex; flex: 1; min-height: 0; overflow: hidden } .graph-sidebar { width: 200px; flex-shrink: 0; border-right: 1px solid var(--border); background: var(--surface); display: flex; flex-direction: column; overflow: hidden } .graph-sidebar-head { padding: 10px 12px; border-bottom: 1px solid var(--border); font: 600 10px var(--font); letter-spacing: .08em; text-transform: uppercase; color: var(--text3) } .graph-sidebar-meta { padding: 8px 12px; font: 400 11px var(--mono); color: var(--text3); border-bottom: 1px solid var(--border) } .graph-filter-list { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 4px } .graph-type-pill { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 8px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--text2); font: 500 11px var(--font); cursor: pointer; text-align: left; transition: background .12s, border-color .12s, opacity .12s } .graph-type-pill:hover { background: var(--surface2) } .graph-type-pill.active { background: var(--accent-glow); border-color: color-mix(in oklch, var(--text-interactive-base) 35%, transparent); color: var(--text) } .graph-type-pill.dimmed { opacity: .35 } .graph-type-pill .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0 } .graph-type-pill .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap } .graph-type-pill .count { font: 500 10px var(--mono); color: var(--text3) } .graph-canvas-wrap { position: relative; flex: 1; min-width: 0; min-height: 0; background: var(--bg2) } .graph-canvas-wrap svg { width: 100%; height: 100%; display: block; touch-action: none } .graph-tooltip { position: absolute; pointer-events: none; z-index: 20; display: none; padding: 8px 10px; border-radius: 8px; font: 400 11px/1.4 var(--font); background: color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter: blur(8px); border: 1px solid var(--border2); color: var(--text); box-shadow: 0 6px 20px rgba(0, 0, 0, .4); max-width: min(360px, 90vw); opacity: 0; transform: translateY(2px); transition: opacity 80ms ease-out, transform 80ms ease-out } .graph-tooltip.visible { display: block; opacity: 1; transform: translateY(0) } .graph-tooltip .gt-type { font: 600 9px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text3); margin-bottom: 2px } .graph-tooltip .gt-label { font-weight: 500 } .graph-minimap { position: absolute; left: 12px; bottom: 12px; z-index: 15; border: 1px solid var(--border); border-radius: 6px; background: color-mix(in srgb, var(--surface) 90%, transparent); box-shadow: 0 2px 10px rgba(0, 0, 0, .35); cursor: pointer; transition: opacity .18s } .graph-chrome { position: absolute; left: 12px; bottom: 144px; z-index: 16; width: 180px; border: 1px solid var(--border); border-radius: 8px; background: color-mix(in srgb, var(--surface) 90%, transparent); backdrop-filter: blur(10px); box-shadow: 0 4px 16px rgba(0, 0, 0, .35); overflow: hidden; transition: opacity .18s } .graph-chrome.dimmed, .graph-minimap.dimmed { opacity: .12; pointer-events: none } .graph-chrome-head { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; border-bottom: 1px solid var(--border); font: 600 10px var(--font); letter-spacing: .06em; text-transform: uppercase; color: var(--text3) } .graph-chrome-head button { background: none; border: none; color: var(--text3); font: 500 10px var(--font); cursor: pointer; padding: 2px 6px; border-radius: 4px } .graph-chrome-head button:hover { color: var(--text); background: var(--surface2) } .graph-chrome-body { padding: 6px 8px 8px; display: flex; flex-direction: column; gap: 6px } .graph-chrome-body button { width: 100%; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text2); font: 500 11px var(--font); cursor: pointer } .graph-chrome-body button:hover { border-color: var(--accent); color: var(--accent2) } .graph-beacon { position: absolute; z-index: 25; display: none; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border-radius: 999px; border: 1px solid var(--border2); background: color-mix(in srgb, var(--surface) 92%, transparent); color: var(--text); box-shadow: 0 4px 16px rgba(0, 0, 0, .4); cursor: pointer; transform: translate(-50%, -50%); transition: opacity .12s } .graph-beacon.visible { display: flex } .graph-controls { position: absolute; bottom: 12px; right: 12px; z-index: 14; display: flex; flex-direction: column; gap: 4px } .graph-controls button { width: 32px; height: 32px; background: color-mix(in srgb, var(--surface) 92%, transparent); backdrop-filter: blur(8px); border: 1px solid var(--border); border-radius: 6px; color: var(--text2); font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center } .graph-controls button:hover { background: var(--surface2); color: var(--text); border-color: var(--border2) } .graph-links line { stroke-linecap: round } .graph-edge-labels text { font-family: var(--font); pointer-events: none } @keyframes edgeFlow { to { stroke-dashoffset: -16 } } .graph-links line.edge-flow { animation: edgeFlow .5s linear infinite } /* ─── Timeline Panel ─── */ #timeline-panel { padding: 0 } .tl-header { padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; flex-shrink: 0 } .tl-header h2 { font: 600 14px var(--font); color: var(--text) } .tl-header .tl-stats { font: 400 11px var(--mono); color: var(--text3) } .tl-body { flex: 1; overflow: auto; position: relative; padding: 20px } .tl-body svg { min-width: 100% } .tl-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text3); font-size: 13px } /* ─── Store Panel ─── */ #store-panel { padding: 0 } .store-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); flex: 1; overflow: hidden } .store-col { background: var(--bg); overflow-y: auto; display: flex; flex-direction: column } .store-section { padding: 14px 16px } .store-section h3 { font: 600 12px var(--font); color: var(--text2); margin-bottom: 10px; display: flex; align-items: center; gap: 6px } .stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 16px; padding: 0 16px } .stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; text-align: center } .stat-card .sv { font: 700 20px var(--mono); color: var(--accent2) } .stat-card .sl { font: 400 10px var(--font); color: var(--text3); text-transform: uppercase; letter-spacing: .4px; margin-top: 2px } .entity-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 4px; cursor: pointer; transition: background .1s; font-size: 12px } .entity-row:hover { background: var(--surface) } .entity-row .et { font: 500 10px var(--mono); color: var(--purple); width: 70px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap } .entity-row .ei { color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 } .entity-row .ec { font: 400 10px var(--mono); color: var(--text3); flex-shrink: 0 } .type-pill { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 99px; font: 500 10px var(--mono); color: var(--text2); cursor: pointer; transition: all .1s } .type-pill:hover, .type-pill.active { border-color: var(--accent); color: var(--accent2) } .type-pill .tc { color: var(--text3) } .catalog-table { width: 100%; border-collapse: collapse; font: 400 11px var(--mono) } .catalog-table th { text-align: left; color: var(--text3); font-weight: 500; padding: 4px 8px; border-bottom: 1px solid var(--border) } .catalog-table td { padding: 4px 8px; color: var(--text2); border-bottom: 1px solid rgba(37, 38, 46, .5) } /* ─── System Panel ─── */ #system-panel { padding: 24px; gap: 20px } .sys-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px } .sys-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 20px; display: flex; flex-direction: column; gap: 12px } .sys-card h3 { font: 600 12px var(--font); color: var(--text); display: flex; align-items: center; gap: 8px } .sys-card h3 .icon { font-size: 14px } .sys-row { display: flex; justify-content: space-between; align-items: center; font: 400 12px var(--font) } .sys-row .label { color: var(--text3) } .sys-row .value { color: var(--text2); font-family: var(--mono); font-size: 11px } .sys-badge { display: inline-block; padding: 2px 8px; border-radius: 99px; font: 600 10px var(--mono) } .sys-badge.on { background: rgba(52, 211, 153, .12); color: var(--green) } .sys-badge.off { background: rgba(248, 113, 113, .1); color: var(--red) } .parser-tags { display: flex; flex-wrap: wrap; gap: 4px } .parser-tag { padding: 3px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; font: 400 10px var(--mono); color: var(--text2) } /* ─── Loading ─── */ #loading { position: fixed; inset: 0; background: var(--bg); display: flex; align-items: center; justify-content: center; z-index: 300; transition: opacity .3s } #loading.hidden { opacity: 0; pointer-events: none } .spinner { width: 28px; height: 28px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite } @keyframes spin { to { transform: rotate(360deg) } } /* ─── Tooltip ─── */ .tooltip { position: fixed; background: var(--surface2); border: 1px solid var(--border2); border-radius: 6px; padding: 5px 10px; font: 400 11px var(--font); color: var(--text); pointer-events: none; z-index: 150; max-width: 300px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: none; box-shadow: 0 4px 16px rgba(0, 0, 0, .5) } /* ─── Phase C vantage dock ─── */ #vantage-dock { position: absolute; left: 12px; bottom: 12px; z-index: 40; display: flex; flex-direction: column; gap: 10px; max-width: min(340px, calc(100% - 24px)); pointer-events: auto } #vantage-scrubber { background: var(--glass); backdrop-filter: blur(12px); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 10px } #vantage-scrubber .detents { display: flex; gap: 4px } #vantage-scrubber .detent { flex: 1; border: 1px solid var(--border); background: var(--surface-1); color: var(--text2); font: 500 11px var(--mono); padding: 6px 4px; border-radius: var(--radius-sm); cursor: pointer } #vantage-scrubber .detent:hover { color: var(--text); background: var(--surface-2) } #vantage-scrubber .detent[aria-checked="true"] { color: var(--accent); background: var(--accent-glow); border-color: color-mix(in oklch, var(--accent) 40%, transparent) } #vantage-scrubber .detent:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px } #vantage-territory { margin-top: 6px; font: 400 11px var(--font); color: var(--text2) } #vantage-territory strong { font: 500 11px var(--mono); color: var(--accent) } </style> </head> <body> <div id="loading"> <div class="spinner"></div> </div> <div id="topbar"> <div class="logo"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <polygon points="12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5" /> </svg> Trellis <span>System Visualizer</span> </div> <div class="tabs" id="view-tabs"> <button class="tab active" data-panel="graph">Graph</button> <button class="tab" data-panel="timeline">Timeline</button> <button class="tab" data-panel="store">Store</button> <button class="tab" data-panel="system">System</button> </div> <div class="top-right"> <div class="live-dot"></div> <div class="top-stats" id="top-stats"></div> <button id="cmd-trigger"><span>Search</span><kbd>⌘K</kbd></button> </div> </div> <div id="app"> <div id="main" data-ui-vantage="8"> <div id="graph-panel" class="panel active"></div> <div id="timeline-panel" class="panel"></div> <div id="store-panel" class="panel"></div> <div id="system-panel" class="panel"></div> <div id="vantage-dock"> <div id="vantage-scrubber" role="radiogroup" aria-label="UI vantage"> <div class="detents"> <button type="button" class="detent" role="radio" aria-checked="false" tabindex="-1" data-v="2" data-shell="node">Node</button> <button type="button" class="detent" role="radio" aria-checked="false" tabindex="-1" data-v="5" data-shell="row">Row</button> <button type="button" class="detent" role="radio" aria-checked="true" tabindex="0" data-v="8" data-shell="card">Card</button> </div> <div id="vantage-territory" aria-live="polite">Territory · <strong>Kanban card</strong> · --ui-vantage: 8 </div> </div> <div class="ui-thing" id="ui-thing" data-trellis-shell="card"> <div class="ui-thing-head"> <span class="ui-thing-pip" aria-hidden="true"></span> <span class="ui-thing-title">Focal entity</span> <span class="ui-thing-badge">queue</span> </div> <div class="ui-thing-meta">shell snap · Phase C</div> <div class="ui-thing-body">One live shell at a time — detents 2 / 5 / 8.</div> </div> </div> </div> </div> <div id="drawer"> <div class="drawer-head"> <h3 id="drawer-title"></h3><button class="drawer-close" id="drawer-close">&times;</button> </div> <div class="drawer-body" id="drawer-body"></div> </div> <div id="cmd-overlay"> <div id="cmd-box"> <input id="cmd-input" type="text" placeholder="Search entities, ops, files…" autocomplete="off"> <div id="cmd-results"></div> </div> </div> <div class="tooltip" id="tooltip"></div> <script> (async function () { // ─── State ─── const S = { panel: 'graph', graph: null, timeline: null, store: null, system: null, cmdOpen: false, drawerOpen: false }; const $ = id => document.getElementById(id); // Entity fills from runtime-theme.css (--entity-*) const cssVar = (name, fallback) => { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; }; const ENTITY_COLORS = { file: cssVar('--entity-file', '#00ceb9'), milestone: cssVar('--entity-milestone', '#2090f5'), issue: cssVar('--entity-issue', '#edb2f1'), branch: cssVar('--entity-branch', '#fcd53a'), default: cssVar('--entity-default', cssVar('--text-base', '#a1a1aa')), }; // keep in sync with src/ui/theme/color-tint.ts function withAlpha(color, alpha01) { const a = Math.min(1, Math.max(0, Number.isFinite(alpha01) ? alpha01 : 0)); const s = (color || '').trim(); if (!s) return 'transparent'; const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s); if (hex) { let h = hex[1]; if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; return 'rgba(' + parseInt(h.slice(0, 2), 16) + ', ' + parseInt(h.slice(2, 4), 16) + ', ' + parseInt(h.slice(4, 6), 16) + ', ' + a + ')'; } const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i.exec(s); if (rgb) return 'rgba(' + Number(rgb[1]) + ', ' + Number(rgb[2]) + ', ' + Number(rgb[3]) + ', ' + a + ')'; return 'transparent'; } const GRAPH_LABEL_MIN_ZOOM = 0.65; const GRAPH_MINIMAP_W = 180, GRAPH_MINIMAP_H = 120, GRAPH_MINIMAP_PAD = 6; const EDGE_LABELS = { milestone_file: 'includes', issue_branch: 'branch', wikilink: 'wikilink', causal: 'causal' }; function nodeRadius(d) { if (d.type === 'issue') return 12; if (d.type === 'milestone') return 11; if (d.type === 'branch') return 8; if (d.type === 'file') return 7; return 6; } function nodeColor(d) { return ENTITY_COLORS[d.type] || ENTITY_COLORS.default; } // ─── Panel Switching ─── const tabs = document.querySelectorAll('.tab'); const panels = document.querySelectorAll('.panel'); function switchPanel(name) { S.panel = name; tabs.forEach(t => t.classList.toggle('active', t.dataset.panel === name)); panels.forEach(p => p.classList.toggle('active', p.id === name + '-panel')); if (name === 'timeline' && !S.timeline) loadTimeline(); if (name === 'store' && !S.store) loadStore(); if (name === 'system' && !S.system) loadSystem(); } tabs.forEach(t => t.addEventListener('click', () => switchPanel(t.dataset.panel))); // ─── Phase C vantage scrubber (detents 2/5/8) ─── (function initVantage() { const labels = { 2: 'Labeled node', 5: 'Row', 8: 'Kanban card' }; const focal = $('main'); const thing = $('ui-thing'); const territory = $('vantage-territory'); const scrubber = $('vantage-scrubber'); if (!focal || !thing || !scrubber) return; const detents = [...scrubber.querySelectorAll('.detent')]; function setVantage(v, shell, { focus } = {}) { document.documentElement.style.setProperty('--ui-vantage', String(v)); focal.setAttribute('data-ui-vantage', String(v)); thing.setAttribute('data-trellis-shell', shell); detents.forEach((btn) => { const on = btn.dataset.v === String(v); btn.setAttribute('aria-checked', on ? 'true' : 'false'); btn.tabIndex = on ? 0 : -1; if (on && focus) btn.focus(); }); territory.innerHTML = 'Territory · <strong>' + labels[v] + '</strong> · --ui-vantage: ' + v; } setVantage(8, 'card'); detents.forEach((btn) => { btn.addEventListener('click', () => setVantage(Number(btn.dataset.v), btn.dataset.shell, { focus: true })); }); scrubber.addEventListener('keydown', (e) => { const order = [2, 5, 8]; const cur = Number(focal.getAttribute('data-ui-vantage')); let i = order.indexOf(cur); if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); i = Math.min(order.length - 1, i + 1); const btn = detents.find((b) => Number(b.dataset.v) === order[i]); if (btn) setVantage(order[i], btn.dataset.shell, { focus: true }); } if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); i = Math.max(0, i - 1); const btn = detents.find((b) => Number(b.dataset.v) === order[i]); if (btn) setVantage(order[i], btn.dataset.shell, { focus: true }); } }); })(); // ─── Drawer ─── const drawer = $('drawer'), drawerTitle = $('drawer-title'), drawerBody = $('drawer-body'); $('drawer-close').onclick = () => { drawer.classList.remove('open'); S.drawerOpen = false; }; function openDrawer(title, html) { drawerTitle.textContent = title; drawerBody.innerHTML = html; drawer.classList.add('open'); S.drawerOpen = true; } // ─── Command Palette ─── const cmdOverlay = $('cmd-overlay'), cmdInput = $('cmd-input'), cmdResults = $('cmd-results'); const cmdBox = $('cmd-box'); let cmdItems = []; let cmdPrevFocus = null; function openCmd() { cmdPrevFocus = document.activeElement; cmdOverlay.classList.add('open'); cmdInput.value = ''; cmdInput.focus(); renderCmdResults(''); S.cmdOpen = true; } function closeCmd() { cmdOverlay.classList.remove('open'); S.cmdOpen = false; if (cmdPrevFocus && typeof cmdPrevFocus.focus === 'function') cmdPrevFocus.focus(); cmdPrevFocus = null; } $('cmd-trigger').onclick = openCmd; cmdOverlay.addEventListener('click', e => { if (e.target === cmdOverlay) closeCmd(); }); cmdInput.addEventListener('input', () => renderCmdResults(cmdInput.value)); cmdInput.addEventListener('keydown', e => { if (e.key === 'Escape') closeCmd(); }); // Lightweight focus trap while palette is open cmdOverlay.addEventListener('keydown', e => { if (!S.cmdOpen || e.key !== 'Tab') return; const focusables = [cmdInput, ...cmdBox.querySelectorAll('.cmd-item')]; if (focusables.length < 2) return; const first = focusables[0], last = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus?.(); } else if (!e.shiftKey && (document.activeElement === last || document.activeElement === cmdInput && focusables.length === 1)) { e.preventDefault(); first.focus(); } }); function buildCmdItems() { cmdItems = []; if (S.graph) { S.graph.nodes.forEach(n => cmdItems.push({ type: n.type, label: n.label, id: n.id })); } ['graph', 'timeline', 'store', 'system'].forEach(p => cmdItems.push({ type: 'panel', label: 'Go to ' + p.charAt(0).toUpperCase() + p.slice(1), id: 'panel:' + p })); } function renderCmdResults(q) { const query = q.toLowerCase(); let items = cmdItems; if (query) items = items.filter(i => i.label.toLowerCase().includes(query) || i.type.includes(query)); items = items.slice(0, 20); cmdResults.innerHTML = items.map(i => `<div class="cmd-item" data-id="${i.id}" data-type="${i.type}"><span class="ci-type">${i.type}</span><span class="ci-label">${esc(i.label)}</span></div>` ).join(''); cmdResults.querySelectorAll('.cmd-item').forEach(el => el.addEventListener('click', () => { const id = el.dataset.id; if (id.startsWith('panel:')) { switchPanel(id.slice(6)); closeCmd(); return; } closeCmd(); if (S.panel === 'graph') focusGraphNode(id); else loadNodeDetail(id); })); } function esc(s) { return s.replace(/</g, '&lt;').replace(/>/g, '&gt;'); } // ─── Keyboard ─── document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openCmd(); } if (e.key === 'Escape') { if (S.cmdOpen) closeCmd(); else { drawer.classList.remove('open'); S.drawerOpen = false; } } }); // ─── Node Detail ─── async function loadNodeDetail(nodeId) { openDrawer(nodeId, '<div style="color:var(--text3)">Loading…</div>'); try { const res = await fetch('/api/node/' + encodeURIComponent(nodeId)); const d = await res.json(); const typeColor = ENTITY_COLORS[d.type] || ENTITY_COLORS.default; let h = `<div class="d-section"><span class="d-badge" style="background:${withAlpha(typeColor, 0.13)};color:${typeColor}">${d.type}</span></div>`; if (d.type === 'file') { h += `<div class="d-section"><h4>Path</h4><div class="d-meta"><strong>${esc(d.path)}</strong></div></div>`; if (d.contentHash) h += `<div class="d-section"><h4>Hash</h4><div style="font:11px var(--mono);color:var(--text3);word-break:break-all">${d.contentHash}</div></div>`; if (d.recentOps?.length) { h += `<div class="d-section"><h4>Recent Ops</h4>`; d.recentOps.forEach(o => h += `<div style="font:11px var(--mono);color:var(--cyan)">${o.kind} <span style="color:var(--text3)">${ago(o.timestamp)}</span></div>`); h += '</div>'; } } else if (d.type === 'milestone') { h += `<div class="d-section"><h4>Message</h4><div class="d-meta"><strong>${esc(d.message || '(none)')}</strong></div></div>`; if (d.affectedFiles?.length) h += `<div class="d-section"><h4>Files (${d.affectedFiles.length})</h4><div class="d-meta">${d.affectedFiles.map(f => '<div>' + esc(f) + '</div>').join('')}</div></div>`; } else if (d.type === 'issue') { h += `<div class="d-section"><h4>Details</h4><div class="d-kv">`; if (d.status) h += `<span class="k">status</span><span class="v">${d.status}</span>`; if (d.priority) h += `<span class="k">priority</span><span class="v">${d.priority}</span>`; if (d.assignee) h += `<span class="k">assignee</span><span class="v">${d.assignee}</span>`; h += '</div></div>'; } else if (d.type === 'branch') { h += `<div class="d-section"><h4>Branch</h4><div class="d-kv"><span class="k">name</span><span class="v">${esc(d.name || '')}</span></div></div>`; } drawerBody.innerHTML = h; } catch { drawerBody.innerHTML = '<div style="color:var(--red)">Failed to load</div>'; } } function ago(iso) { if (!iso) return ''; const d = Date.now() - new Date(iso).getTime(); if (d < 60000) return 'now'; if (d < 3600000) return Math.floor(d / 60000) + 'm'; if (d < 86400000) return Math.floor(d / 3600000) + 'h'; return Math.floor(d / 86400000) + 'd'; } // ─── Entity Detail (store) ─── async function loadEntityDetail(entityId) { openDrawer(entityId, '<div style="color:var(--text3)">Loading…</div>'); try { const res = await fetch('/api/store/entity/' + encodeURIComponent(entityId)); const d = await res.json(); let h = `<div class="d-section"><span class="d-badge" style="background:var(--accent-glow);color:var(--accent2)">${esc(String(d.type))}</span></div>`; h += '<div class="d-section"><h4>Facts</h4><div class="d-kv">'; d.facts.forEach(f => h += `<span class="k">${esc(f.a)}</span><span class="v">${esc(String(f.v))}</span>`); h += '</div></div>'; if (d.links?.length) { h += '<div class="d-section"><h4>Links (' + d.links.length + ')</h4>'; d.links.forEach(l => { h += `<div style="font:11px var(--mono);color:var(--text2)">${l.direction === 'outgoing' ? '→' : '←'} <span style="color:var(--accent2)">${esc(l.a)}</span> ${esc(l.direction === 'outgoing' ? l.target : l.source)}</div>`; }); h += '</div>'; } drawerBody.innerHTML = h; } catch { drawerBody.innerHTML = '<div style="color:var(--red)">Failed to load</div>'; } } // ═══════════════════════════════════════════════════════════════════════════ // GRAPH PANEL (Studio GraphView-style) // ═══════════════════════════════════════════════════════════════════════════ let graphSim, graphZoom, graphSvg, graphG, graphNodeGroups, graphLinks, graphLabels, graphEdgeLabels, graphNodeMap; let graphCanvasWrap, graphZoomState = { k: 1, x: 0, y: 0 }, graphActiveFilters = null, graphMinimap, graphMinimapCtx, graphBeacon; function buildGraphSidebar(nodes) { const counts = {}; nodes.forEach(n => { counts[n.type] = (counts[n.type] || 0) + 1; }); const types = Object.keys(counts).sort(); graphActiveFilters = new Set(types); const sidebar = document.createElement('aside'); sidebar.className = 'graph-sidebar'; sidebar.innerHTML = `<div class="graph-sidebar-head">Entity types</div><div class="graph-sidebar-meta" id="graph-sidebar-meta"></div><div class="graph-filter-list" id="graph-filter-list"></div>`; const list = sidebar.querySelector('#graph-filter-list'); const meta = sidebar.querySelector('#graph-sidebar-meta'); function refreshMeta(visible) { meta.textContent = visible + ' / ' + nodes.length + ' nodes'; } function applyFilters() { const on = graphActiveFilters; let visible = 0; graphNodeGroups?.attr('display', n => { const show = on.has(n.type); if (show) visible++; return show ? null : 'none'; }); graphLinks?.attr('stroke-opacity', l => { const sid = typeof l.source === 'object' ? l.source.id : l.source; const tid = typeof l.target === 'object' ? l.target.id : l.target; const st = graphNodeMap?.get(sid)?.type; const tt = graphNodeMap?.get(tid)?.type; return (st && tt && on.has(st) && on.has(tt)) ? 0.18 : 0.02; }); list.querySelectorAll('.graph-type-pill').forEach(p => p.classList.toggle('dimmed', !on.has(p.dataset.type))); refreshMeta(visible); } types.forEach(t => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'graph-type-pill active'; btn.dataset.type = t; btn.innerHTML = `<span class="dot" style="background:${ENTITY_COLORS[t] || ENTITY_COLORS.default}"></span><span class="name">${t}</span><span class="count">${counts[t]}</span>`; btn.onclick = () => { if (on.has(t) && on.size > 1) on.delete(t); else on.add(t); btn.classList.toggle('active', on.has(t)); applyFilters(); }; list.append(btn); }); refreshMeta(nodes.length); return { sidebar, applyFilters }; } function paintGraphMinimap(simNodes) { if (!graphMinimapCtx) return; const MAP_W = GRAPH_MINIMAP_W, MAP_H = GRAPH_MINIMAP_H, PAD = GRAPH_MINIMAP_PAD; const mctx = graphMinimapCtx; const wrap = graphCanvasWrap; const w = wrap.clientWidth, h = wrap.clientHeight; const zoom = graphZoomState; mctx.clearRect(0, 0, MAP_W, MAP_H); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; simNodes.forEach(n => { const x = n.x ?? 0, y = n.y ?? 0; if (x < minX) minX = x; if (y < minY) minY = y; if (x > maxX) maxX = x; if (y > maxY) maxY = y; }); const vx0 = -zoom.x / zoom.k, vy0 = -zoom.y / zoom.k, vx1 = (w - zoom.x) / zoom.k, vy1 = (h - zoom.y) / zoom.k; minX = Math.min(minX, vx0); minY = Math.min(minY, vy0); maxX = Math.max(maxX, vx1); maxY = Math.max(maxY, vy1); if (!Number.isFinite(minX)) { minX = 0; minY = 0; maxX = w; maxY = h; } const dx = maxX - minX || 1, dy = maxY - minY || 1; const scale = Math.min((MAP_W - PAD * 2) / dx, (MAP_H - PAD * 2) / dy); const toMap = (x, y) => [PAD + (x - minX) * scale, PAD + (y - minY) * scale]; mctx.globalAlpha = .75; simNodes.forEach(n => { if (graphActiveFilters && !graphActiveFilters.has(n.type)) return; const [mx, my] = toMap(n.x ?? 0, n.y ?? 0); mctx.fillStyle = nodeColor(n); mctx.beginPath(); mctx.arc(mx, my, 1.2, 0, Math.PI * 2); mctx.fill(); }); mctx.globalAlpha = 1; const [rx, ry] = toMap(vx0, vy0), rw = (vx1 - vx0) * scale, rh = (vy1 - vy0) * scale; mctx.fillStyle = 'rgba(0,0,0,.45)'; const mask = new Path2D(); mask.rect(0, 0, MAP_W, MAP_H); mask.rect(rx, ry, rw, rh); mctx.fill(mask, 'evenodd'); mctx.strokeStyle = 'rgba(255,255,255,.85)'; mctx.lineWidth = 1; mctx.strokeRect(rx + .5, ry + .5, rw - 1, rh - 1); updateGraphBeacon(simNodes, w, h); } function updateGraphBeacon(simNodes, w, h) { if (!graphBeacon) return; const zoom = graphZoomState; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity, cx = 0, cy = 0, n = 0; simNodes.forEach(nd => { if (graphActiveFilters && !graphActiveFilters.has(nd.type)) return; const x = nd.x ?? 0, y = nd.y ?? 0; cx += x; cy += y; n++; if (x < minX) minX = x; if (y < minY) minY = y; if (x > maxX) maxX = x; if (y > maxY) maxY = y; }); if (!n) { graphBeacon.classList.remove('visible'); return; } cx /= n; cy /= n; const vx0 = -zoom.x / zoom.k, vy0 = -zoom.y / zoom.k, vx1 = (w - zoom.x) / zoom.k, vy1 = (h - zoom.y) / zoom.k; const offscreen = maxX < vx0 || minX > vx1 || maxY < vy0 || minY > vy1; if (!offscreen) { graphBeacon.classList.remove('visible'); return; } const sx = cx * zoom.k + zoom.x, sy = cy * zoom.k + zoom.y; const pad = 56, hw = Math.max(24, w / 2 - pad), hh = Math.max(24, h / 2 - pad); const dx = sx - w / 2, dy = sy - h / 2; const sc = Math.min(Math.abs(hw / (dx || 1e-6)), Math.abs(hh / (dy || 1e-6))); graphBeacon.style.left = (w / 2 + dx * sc) + 'px'; graphBeacon.style.top = (h / 2 + dy * sc) + 'px'; graphBeacon.classList.add('visible'); } function updateLabelVisibility() { const show = graphZoomState.k >= GRAPH_LABEL_MIN_ZOOM; graphLabels?.attr('opacity', show ? 0.85 : 0); graphEdgeLabels?.attr('opacity', show ? 0.35 : 0); } async function initGraph() { try { const r = await fetch('/api/graph'); S.graph = await r.json(); } catch { $('graph-panel').innerHTML = '<div style="padding:40px;color:var(--red)">Failed to load graph</div>'; return; } const { nodes, edges } = S.graph; $('top-stats').textContent = nodes.length + ' nodes · ' + edges.length + ' edges'; buildCmdItems(); const container = $('graph-panel'); container.innerHTML = ''; const layout = document.createElement('div'); layout.className = 'graph-layout'; const { sidebar, applyFilters } = buildGraphSidebar(nodes); graphCanvasWrap = document.createElement('div'); graphCanvasWrap.className = 'graph-canvas-wrap'; graphCanvasWrap.id = 'graph-canvas-wrap'; layout.append(sidebar, graphCanvasWrap); container.append(layout); const w = graphCanvasWrap.clientWidth || 800, h = graphCanvasWrap.clientHeight || 600; const svg = d3.select(graphCanvasWrap).append('svg').attr('width', w).attr('height', h); graphSvg = svg; const g = svg.append('g'); graphG = g; graphZoom = d3.zoom().scaleExtent([0.05, 4]).on('zoom', e => { g.attr('transform', e.transform); graphZoomState = { k: e.transform.k, x: e.transform.x, y: e.transform.y }; updateLabelVisibility(); paintGraphMinimap(simNodes); }); svg.call(graphZoom); svg.on('click', () => { clearGraphHighlight(); drawer.classList.remove('open'); }); const simLinks = edges.map(e => ({ ...e })); const simNodes = nodes.map(n => ({ ...n })); graphNodeMap = new Map(); simNodes.forEach(n => graphNodeMap.set(n.id, n)); const size = simNodes.length; const dist = size > 800 ? 45 : size > 300 ? 60 : 90; const charge = size > 800 ? -90 : size > 300 ? -130 : -220; graphSim = d3.forceSimulation(simNodes) .force('link', d3.forceLink(simLinks).id(d => d.id).distance(dist).strength(0.35)) .force('charge', d3.forceManyBody().strength(charge).distanceMax(400)) .force('center', d3.forceCenter(w / 2, h / 2)) .force('collision', d3.forceCollide().radius(d => nodeRadius(d) + 4)) .alphaDecay(0.04).alphaMin(0.02); graphLinks = g.append('g').attr('class', 'graph-links').selectAll('line').data(simLinks).join('line') .attr('stroke', 'var(--text3)') .attr('stroke-width', 1) .attr('stroke-opacity', 0.18) .attr('stroke-dasharray', d => d.type === 'wikilink' ? '3,3' : null); graphEdgeLabels = g.append('g').attr('class', 'graph-edge-labels').selectAll('text').data(simLinks).join('text') .text(d => EDGE_LABELS[d.type] || d.type) .attr('font-size', 9).attr('font-weight', 500).attr('fill', 'var(--text2)') .attr('stroke', 'var(--bg)').attr('stroke-width', 3).attr('paint-order', 'stroke') .attr('text-anchor', 'middle').attr('dy', '0.35em').attr('opacity', 0); graphNodeGroups = g.append('g').attr('class', 'graph-nodes').selectAll('g').data(simNodes).join('g') .attr('cursor', 'pointer') .call(d3.drag().on('start', (e, d) => { if (!e.active) graphSim.alphaTarget(.25).restart(); d.fx = d.x; d.fy = d.y }) .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y }) .on('end', (e, d) => { if (!e.active) graphSim.alphaTarget(0); d.fx = null; d.fy = null })); graphNodeGroups.append('circle') .attr('r', d => nodeRadius(d)) .attr('fill', d => nodeColor(d)) .attr('stroke', 'transparent').attr('stroke-width', 2); graphLabels = graphNodeGroups.append('text') .text(d => d.label.length > 24 ? d.label.slice(0, 24) + '…' : d.label) .attr('font-size', 10).attr('fill', 'var(--text2)') .attr('x', d => nodeRadius(d) + 5).attr('dy', 4) .attr('pointer-events', 'none').attr('opacity', 0) .attr('font-family', 'var(--font),sans-serif'); const graphTip = document.createElement('div'); graphTip.className = 'graph-tooltip'; graphCanvasWrap.appendChild(graphTip); graphNodeGroups.on('mouseenter', (e, d) => { graphTip.innerHTML = `<div class="gt-type">${d.type}</div><div class="gt-label">${esc(d.label)}</div>`; graphTip.classList.add('visible'); graphTip.style.left = (e.offsetX + 14) + 'px'; graphTip.style.top = (e.offsetY + 10) + 'px'; $('tooltip').style.display = 'none'; }).on('mousemove', (e) => { graphTip.style.left = (e.offsetX + 14) + 'px'; graphTip.style.top = (e.offsetY + 10) + 'px'; }).on('mouseleave', () => graphTip.classList.remove('visible')) .on('click', (e, d) => { e.stopPropagation(); highlightGraphNode(d); loadNodeDetail(d.id); }); graphSim.on('tick', () => { graphLinks .attr('x1', d => d.source.x).attr('y1', d => d.source.y) .attr('x2', d => d.target.x).attr('y2', d => d.target.y); graphNodeGroups.attr('transform', d => `translate(${d.x},${d.y})`); graphEdgeLabels .attr('x', d => (d.source.x + d.target.x) / 2) .attr('y', d => (d.source.y + d.target.y) / 2); paintGraphMinimap(simNodes);