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
HTML
<!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">×</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, '<').replace(/>/g, '>'); }
// ─── 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);