tangled vouch map with historical data
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add sidebar

+485 -16
+1
web/index.html
··· 30 30 <div id="graph-container"></div> 31 31 <div id="loading">Loading graph data...</div> 32 32 <div id="tooltip"></div> 33 + <div id="sidebar"></div> 33 34 34 35 <div id="controls"> 35 36 <div id="controls-row">
+306 -16
web/main.js
··· 1 1 import { Cosmograph } from '@cosmograph/cosmograph'; 2 + import { getInternalApi } from '@cosmograph/cosmograph/cosmograph/internal.js'; 2 3 import { prepareCosmographData } from '@cosmograph/cosmograph/data-kit'; 3 4 import './style.css'; 4 5 ··· 8 9 const headerStats = document.getElementById('header-stats'); 9 10 const searchInput = document.getElementById('search-input'); 10 11 const searchDropdown = document.getElementById('search-dropdown'); 12 + const sidebar = document.getElementById('sidebar'); 11 13 12 14 let graphData = null; 13 15 let profileMap = {}; 14 16 let cosmograph = null; 15 - let popup = null; 16 17 let pointIdToIndex = {}; 17 18 let resolvingDIDs = new Set(); 18 19 let currentRawPoints = []; 19 20 let currentLinks = []; 20 21 let currentNodes = []; 22 + 23 + let selectedDID = null; 24 + let highlightState = null; 21 25 22 26 let nodeDegrees = {}; 23 27 let nodeInVouch = {}; ··· 76 80 const nodeSet = new Set(); 77 81 const links = []; 78 82 79 - // deduplicate mutual edges between same pair 80 83 const pairIndex = new Map(); 81 84 for (const edge of graphData.edges) { 82 85 if (!edgeFilters[edge.kind]) continue; ··· 123 126 return { id, label: p.handle || id, handle: p.handle || '', avatar: p.avatar || '' }; 124 127 }); 125 128 126 - // pre-compute degrees and vouch/denounce counts 127 129 nodeDegrees = {}; 128 130 nodeInVouch = {}; 129 131 nodeInDenounce = {}; ··· 158 160 } 159 161 } 160 162 161 - // pre-compute node colors 162 163 const isDark = isDarkMode(); 163 164 nodeColors = {}; 164 165 for (const n of nodes) { ··· 198 199 return mutual ? base + 1 : base; 199 200 } 200 201 202 + // --- Highlight --- 203 + 204 + function parseRgba(str) { 205 + const m = str.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/); 206 + if (m) return [parseFloat(m[1])/255, parseFloat(m[2])/255, parseFloat(m[3])/255, m[4] !== undefined ? parseFloat(m[4]) : 1]; 207 + const hex = str.replace('#', ''); 208 + if (hex.length >= 6) { 209 + const r = parseInt(hex.slice(0,2), 16); 210 + const g = parseInt(hex.slice(2,4), 16); 211 + const b = parseInt(hex.slice(4,6), 16); 212 + return [r/255, g/255, b/255, 1]; 213 + } 214 + return [0.5, 0.5, 0.5, 1]; 215 + } 216 + 217 + function computeHighlight(did) { 218 + const connectedNodes = new Set(); 219 + connectedNodes.add(did); 220 + 221 + const connectedLinks = new Set(); 222 + for (let i = 0; i < currentLinks.length; i++) { 223 + const l = currentLinks[i]; 224 + if (l.source === did || l.target === did) { 225 + connectedNodes.add(l.source); 226 + connectedNodes.add(l.target); 227 + connectedLinks.add(i); 228 + } 229 + } 230 + 231 + return { did, connectedNodes, connectedLinks }; 232 + } 233 + 234 + function applyHighlight() { 235 + if (!cosmograph) return; 236 + const internal = getInternalApi(cosmograph); 237 + const cosmos = internal?.cosmos; 238 + if (!cosmos) return; 239 + 240 + if (!highlightState) { 241 + cosmos.setConfigPartial({ 242 + pointGreyoutColor: [-1, -1, -1, -1], 243 + isDarkenGreyout: false, 244 + }); 245 + cosmos.unselectPoints(); 246 + 247 + // Restore labels and link colors/widths 248 + const labels = internal.labels; 249 + if (labels?.labelsContainer) labels.labelsContainer.style.display = ''; 250 + 251 + const graph = cosmos.graph; 252 + const lines = cosmos.lines; 253 + if (!graph || !lines) return; 254 + for (let i = 0; i < currentLinks.length; i++) { 255 + const c = parseRgba(edgeColor(currentLinks[i].kind)); 256 + graph.linkColors[i*4] = c[0]; graph.linkColors[i*4+1] = c[1]; graph.linkColors[i*4+2] = c[2]; graph.linkColors[i*4+3] = c[3]; 257 + graph.linkWidths[i] = edgeWidth(currentLinks[i].kind, currentLinks[i].mutual); 258 + } 259 + lines.updateColor(); 260 + lines.updateWidth(); 261 + } else { 262 + const dimColor = isDarkMode() ? [0.43, 0.45, 0.55, 0.12] : [0.61, 0.63, 0.69, 0.15]; 263 + cosmos.setConfigPartial({ 264 + pointGreyoutColor: dimColor, 265 + isDarkenGreyout: true, 266 + }); 267 + 268 + // Hide all labels for greyed-out nodes 269 + const labels = internal.labels; 270 + if (labels?.labelsContainer) labels.labelsContainer.style.display = 'none'; 271 + 272 + const indices = []; 273 + for (const id of highlightState.connectedNodes) { 274 + const idx = pointIdToIndex[id]; 275 + if (idx !== undefined) indices.push(idx); 276 + } 277 + cosmos.selectPointsByIndices(indices); 278 + 279 + // Dim non-connected links by writing to linkColors/linkWidths directly 280 + const graph = cosmos.graph; 281 + const lines = cosmos.lines; 282 + if (!graph || !lines) return; 283 + 284 + for (let i = 0; i < currentLinks.length; i++) { 285 + if (highlightState.connectedLinks.has(i)) { 286 + const c = parseRgba(edgeColor(currentLinks[i].kind)); 287 + graph.linkColors[i*4] = c[0]; graph.linkColors[i*4+1] = c[1]; graph.linkColors[i*4+2] = c[2]; graph.linkColors[i*4+3] = c[3]; 288 + graph.linkWidths[i] = edgeWidth(currentLinks[i].kind, currentLinks[i].mutual) + 1; 289 + } else { 290 + const dim = parseRgba('rgba(107,114,128,0.05)'); 291 + graph.linkColors[i*4] = dim[0]; graph.linkColors[i*4+1] = dim[1]; graph.linkColors[i*4+2] = dim[2]; graph.linkColors[i*4+3] = dim[3]; 292 + graph.linkWidths[i] = 0.3; 293 + } 294 + } 295 + lines.updateColor(); 296 + lines.updateWidth(); 297 + } 298 + } 299 + 300 + function selectNode(did) { 301 + selectedDID = did; 302 + highlightState = computeHighlight(did); 303 + renderSidebar(did); 304 + sidebar.classList.add('open'); 305 + requestAnimationFrame(() => applyHighlight()); 306 + } 307 + 308 + function deselectNode() { 309 + selectedDID = null; 310 + highlightState = null; 311 + sidebar.classList.remove('open'); 312 + applyHighlight(); 313 + } 314 + 315 + // --- Sidebar --- 316 + 317 + function avatarHtml(avatar, name, size = 40) { 318 + if (avatar) { 319 + return `<img class="sidebar-avatar" src="/api/proxy/avatar?url=${encodeURIComponent(avatar)}" width="${size}" height="${size}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><div class="sidebar-avatar-fallback" style="display:none;width:${size}px;height:${size}px;font-size:${Math.round(size*0.4)}px">${name.charAt(0).toUpperCase()}</div>`; 320 + } 321 + return `<div class="sidebar-avatar-fallback" style="width:${size}px;height:${size}px;font-size:${Math.round(size*0.4)}px">${name.charAt(0).toUpperCase()}</div>`; 322 + } 323 + 324 + let sidebarTab = 'outbound'; 325 + 326 + function renderSidebar(did) { 327 + const p = profileMap[did] || {}; 328 + const handle = p.handle || ''; 329 + const avatar = p.avatar || ''; 330 + const name = handle || shortenDID(did); 331 + 332 + const inV = nodeInVouch[did] || 0; 333 + const inD = nodeInDenounce[did] || 0; 334 + const outV = nodeOutVouch[did] || 0; 335 + const outD = nodeOutDenounce[did] || 0; 336 + const inF = nodeInFollow[did] || 0; 337 + const outF = nodeOutFollow[did] || 0; 338 + const total = inV + inD; 339 + 340 + let trustHtml = ''; 341 + if (total > 0 || outV + outD > 0) { 342 + const pct = total > 0 ? Math.round((inV / total) * 100) : null; 343 + let pctStr; 344 + if (pct !== null) { 345 + const c = pct > 90 ? 'kv' : pct > 50 ? 'ks' : 'kd'; 346 + pctStr = `<span class="${c}">${pct}%</span>`; 347 + } else { 348 + pctStr = '<span style="color:var(--text-muted)">?</span>'; 349 + } 350 + const inPart = total > 0 ? `<span class="kv">${inV}v</span> <span class="kd">${inD}d</span> in` : ''; 351 + const outPart = outV + outD > 0 ? `<span class="kv">${outV}v</span> <span class="kd">${outD}d</span> out` : ''; 352 + const sep = inPart && outPart ? ' · ' : ''; 353 + trustHtml = `<div class="sidebar-stat">${pctStr} trusted · ${inPart}${sep}${outPart}</div>`; 354 + } 355 + let followHtml = ''; 356 + if (inF > 0 || outF > 0) { 357 + const inPart = inF > 0 ? `<span class="kf">${inF}f</span> in` : ''; 358 + const outPart = outF > 0 ? `<span class="kf">${outF}f</span> out` : ''; 359 + const sep = inPart && outPart ? ' · ' : ''; 360 + followHtml = `<div class="sidebar-stat">${inPart}${sep}${outPart}</div>`; 361 + } 362 + 363 + const profileLink = handle && handle !== '!' 364 + ? `<a class="sidebar-link" href="https://tangled.org/@${handle}" target="_blank" rel="noopener">${handle} ↗</a>` 365 + : ''; 366 + 367 + // Classify links into outbound/inbound, then by vouch/denounce/follow 368 + const outVouched = []; 369 + const outDenounced = []; 370 + const outFollows = []; 371 + const inVouched = []; 372 + const inDenounced = []; 373 + const inFollows = []; 374 + 375 + for (const link of currentLinks) { 376 + if (link.source === did) { 377 + if (link.kind === 'vouch/denounce') outDenounced.push(link); 378 + else if (link.kind === 'vouch/mixed') { outVouched.push(link); outDenounced.push(link); } 379 + else if (link.kind === 'vouch/vouch') outVouched.push(link); 380 + else outFollows.push(link); 381 + } else if (link.target === did) { 382 + if (link.kind === 'vouch/denounce') inDenounced.push(link); 383 + else if (link.kind === 'vouch/mixed') { inVouched.push(link); inDenounced.push(link); } 384 + else if (link.kind === 'vouch/vouch') inVouched.push(link); 385 + else inFollows.push(link); 386 + } 387 + } 388 + 389 + const renderConnections = (links, direction) => { 390 + if (links.length === 0) return '<div class="sidebar-empty">None</div>'; 391 + return links.map(l => { 392 + const otherId = direction === 'out' ? l.target : l.source; 393 + const otherP = profileMap[otherId] || {}; 394 + const otherName = otherP.handle || shortenDID(otherId); 395 + const otherAvatar = otherP.avatar || ''; 396 + const kindClass = l.kind === 'vouch/denounce' ? 'kd' : l.kind === 'vouch/vouch' ? 'kv' : l.kind === 'vouch/mixed' ? 'ks' : 'kf'; 397 + const kindLabel = l.kind === 'vouch/denounce' ? 'denounces' : l.kind === 'vouch/vouch' ? 'vouches' : l.kind === 'vouch/mixed' ? 'mixed' : 'follows'; 398 + const mutualTag = l.mutual ? ' <span class="sidebar-mutual">mutual</span>' : ''; 399 + return `<div class="sidebar-conn" data-did="${otherId}"> 400 + ${avatarHtml(otherAvatar, otherName, 28)} 401 + <div class="sidebar-conn-info"> 402 + <span class="sidebar-conn-name">${otherName}</span> 403 + <span class="sidebar-conn-kind ${kindClass}">${kindLabel}${mutualTag}</span> 404 + </div> 405 + </div>`; 406 + }).join(''); 407 + }; 408 + 409 + const outTotal = outVouched.length + outDenounced.length + outFollows.length; 410 + const inTotal = inVouched.length + inDenounced.length + inFollows.length; 411 + const activeTab = sidebarTab; 412 + 413 + const tabContent = activeTab === 'outbound' 414 + ? `<div class="sidebar-subsection"> 415 + <div class="sidebar-subsection-title"><span class="kv">Vouched</span> <span class="sidebar-count">${outVouched.length}</span></div> 416 + ${renderConnections(outVouched, 'out')} 417 + </div> 418 + <div class="sidebar-subsection"> 419 + <div class="sidebar-subsection-title"><span class="kd">Denounced</span> <span class="sidebar-count">${outDenounced.length}</span></div> 420 + ${renderConnections(outDenounced, 'out')} 421 + </div> 422 + ${outFollows.length > 0 ? `<div class="sidebar-subsection"> 423 + <div class="sidebar-subsection-title"><span class="kf">Follows</span> <span class="sidebar-count">${outFollows.length}</span></div> 424 + ${renderConnections(outFollows, 'out')} 425 + </div>` : ''}` 426 + : `<div class="sidebar-subsection"> 427 + <div class="sidebar-subsection-title"><span class="kv">Vouched by</span> <span class="sidebar-count">${inVouched.length}</span></div> 428 + ${renderConnections(inVouched, 'in')} 429 + </div> 430 + <div class="sidebar-subsection"> 431 + <div class="sidebar-subsection-title"><span class="kd">Denounced by</span> <span class="sidebar-count">${inDenounced.length}</span></div> 432 + ${renderConnections(inDenounced, 'in')} 433 + </div> 434 + ${inFollows.length > 0 ? `<div class="sidebar-subsection"> 435 + <div class="sidebar-subsection-title"><span class="kf">Followed by</span> <span class="sidebar-count">${inFollows.length}</span></div> 436 + ${renderConnections(inFollows, 'in')} 437 + </div>` : ''}`; 438 + 439 + sidebar.innerHTML = ` 440 + <div class="sidebar-header"> 441 + <div class="sidebar-profile"> 442 + ${avatarHtml(avatar, name)} 443 + <div> 444 + <div class="sidebar-name">${name}</div> 445 + ${profileLink} 446 + ${handle ? `<div class="sidebar-did">${shortenDID(did)}</div>` : ''} 447 + </div> 448 + </div> 449 + ${trustHtml} 450 + ${followHtml} 451 + <button class="sidebar-close" id="sidebar-close">✕</button> 452 + </div> 453 + <div class="sidebar-tabs"> 454 + <button class="sidebar-tab ${activeTab === 'outbound' ? 'active' : ''}" data-tab="outbound">Outbound <span class="sidebar-count">${outTotal}</span></button> 455 + <button class="sidebar-tab ${activeTab === 'inbound' ? 'active' : ''}" data-tab="inbound">Inbound <span class="sidebar-count">${inTotal}</span></button> 456 + </div> 457 + <div class="sidebar-sections"> 458 + ${tabContent} 459 + </div> 460 + `; 461 + 462 + document.getElementById('sidebar-close')?.addEventListener('click', deselectNode); 463 + 464 + sidebar.querySelectorAll('.sidebar-tab').forEach(tab => { 465 + tab.addEventListener('click', () => { 466 + sidebarTab = tab.dataset.tab; 467 + renderSidebar(did); 468 + }); 469 + }); 470 + 471 + sidebar.querySelectorAll('.sidebar-conn').forEach(el => { 472 + el.addEventListener('click', () => { 473 + const connDid = el.dataset.did; 474 + if (connDid) selectNode(connDid); 475 + }); 476 + }); 477 + } 478 + 479 + // --- Data loading --- 480 + 201 481 async function loadData() { 202 482 const resp = await fetch('/api/graph'); 203 483 graphData = await resp.json(); ··· 225 505 currentNodes = nodes; 226 506 currentLinks = links; 227 507 228 - // Build Cosmograph data format 229 508 const rawPoints = nodes.map(n => ({ 230 509 id: n.id, 231 510 color: nodeColors[n.id] || '#9ca0b0', 232 - size: Math.max(5, Math.min(20, 4 * Math.log2((nodeDegrees[n.id] || 0) + 2))), 511 + size: Math.max(20, Math.min(50, 10 * Math.log2((nodeDegrees[n.id] || 0) + 2))), 233 512 label: n.handle || '', 234 513 imageUrl: n.avatar ? `/api/proxy/avatar?url=${encodeURIComponent(n.avatar)}` : '', 235 514 })); 236 515 237 - const rawLinks = links.map(l => ({ 516 + const rawLinks = links.map((l, i) => ({ 238 517 source: l.source, 239 518 target: l.target, 240 519 color: edgeColor(l.kind), ··· 252 531 pointSizeStrategy: 'direct', 253 532 pointLabelBy: 'label', 254 533 pointImageUrlBy: 'imageUrl', 255 - pointImageSize: 20, 534 + pointImageSize: 50, 256 535 hidePointShapesForLoadedImages: true, 257 536 }, 258 537 links: { ··· 271 550 272 551 const { points, links: prepLinks, cosmographConfig } = result; 273 552 274 - // Store for callback access after filter toggles 275 553 currentRawPoints = rawPoints; 276 554 277 - // Build index map for lookup 278 555 pointIdToIndex = {}; 279 556 for (let i = 0; i < rawPoints.length; i++) { 280 557 pointIdToIndex[rawPoints[i].id] = i; ··· 295 572 showHoveredPointLabel: true, 296 573 hoveredPointLabelClassName: 'hovered-label', 297 574 focusPointOnClick: true, 298 - selectPointOnClick: 'single', 299 575 pointDefaultColor: isDark ? '#6e738d' : '#9ca0b0', 300 576 pointColorStrategy: 'direct', 301 - pointSizeRange: [5, 20], 577 + pointSizeRange: [20, 50], 302 578 linkDefaultColor: isDark ? 'rgba(107,114,128,0.2)' : 'rgba(107,114,128,0.2)', 303 579 linkDefaultWidth: 1, 304 580 linkWidthStrategy: 'direct', 305 - linkWidthRange: [1, 2], 581 + linkWidthRange: [1, 3], 306 582 enableSimulation: true, 307 583 onPointMouseOver: (pointIndex) => { 308 584 const id = currentRawPoints[pointIndex]?.id; 309 585 if (!id) return; 586 + if (highlightState && !highlightState.connectedNodes.has(id)) return; 310 587 showNodeTooltip(id, pointIndex); 311 588 }, 312 589 onPointMouseOut: () => { ··· 315 592 onPointClick: (pointIndex) => { 316 593 const id = currentRawPoints[pointIndex]?.id; 317 594 if (!id) return; 318 - const p = profileMap[id]; 319 - if (p?.handle && p.handle !== '!') { 320 - window.open(`https://tangled.org/@${p.handle}`, '_blank'); 595 + if (selectedDID === id) { 596 + deselectNode(); 597 + } else { 598 + selectNode(id); 321 599 } 322 600 }, 601 + onBackgroundClick: () => { 602 + if (selectedDID) deselectNode(); 603 + }, 323 604 onLinkMouseOver: (linkIndex) => { 324 605 const link = currentLinks[linkIndex]; 325 606 if (!link) return; 607 + if (highlightState && !highlightState.connectedLinks.has(linkIndex)) return; 326 608 showLinkTooltip(link); 327 609 }, 328 610 onLinkMouseOut: () => { ··· 339 621 e.preventDefault(); 340 622 cosmograph?.fitView(400, 30); 341 623 } 624 + if (e.code === 'Escape' && selectedDID) { 625 + deselectNode(); 626 + } 342 627 }); 343 628 344 629 updateStats(currentNodes, currentLinks); 630 + 631 + if (highlightState) { 632 + applyHighlight(); 633 + } 345 634 } 346 635 347 636 function showNodeTooltip(did, pointIndex) { ··· 505 794 if (cosmograph) { 506 795 const idx = pointIdToIndex[did]; 507 796 if (idx !== undefined) { 797 + selectNode(did); 508 798 cosmograph.zoomToPoint(idx, 800, 6); 509 799 } 510 800 }
+178
web/style.css
··· 282 282 font-family: 'IBM Plex Mono', monospace; 283 283 font-size: 11px; 284 284 } 285 + 286 + #sidebar { 287 + position: fixed; 288 + top: 0; 289 + right: 0; 290 + width: 320px; 291 + height: 100%; 292 + background: var(--bg-card); 293 + border-left: 1px solid var(--border-default); 294 + z-index: 25; 295 + overflow-y: auto; 296 + transform: translateX(100%); 297 + transition: transform 0.2s ease; 298 + display: flex; 299 + flex-direction: column; 300 + } 301 + #sidebar.open { 302 + transform: translateX(0); 303 + } 304 + 305 + .sidebar-header { 306 + padding: 1rem; 307 + border-bottom: 1px solid var(--border-default); 308 + position: relative; 309 + flex-shrink: 0; 310 + } 311 + 312 + .sidebar-profile { 313 + display: flex; 314 + align-items: center; 315 + gap: 0.75rem; 316 + margin-bottom: 0.5rem; 317 + padding-right: 1.5rem; 318 + } 319 + 320 + .sidebar-avatar { 321 + width: 40px; height: 40px; border-radius: 50%; 322 + border: 2px solid var(--border-default); 323 + object-fit: cover; flex-shrink: 0; 324 + } 325 + .sidebar-avatar-fallback { 326 + width: 40px; height: 40px; border-radius: 50%; 327 + background: var(--bg-input); border: 2px solid var(--border-default); 328 + display: flex; align-items: center; justify-content: center; 329 + font-size: 1rem; color: var(--text-muted); flex-shrink: 0; 330 + font-weight: 600; 331 + } 332 + 333 + .sidebar-name { 334 + font-weight: 700; 335 + font-size: 1rem; 336 + color: var(--text-primary); 337 + line-height: 1.2; 338 + } 339 + .sidebar-link { 340 + font-size: 0.8125rem; 341 + color: var(--follow); 342 + text-decoration: none; 343 + display: block; 344 + line-height: 1.3; 345 + } 346 + .sidebar-link:hover { text-decoration: underline; } 347 + .sidebar-did { 348 + font-size: 0.6875rem; 349 + color: var(--text-muted); 350 + font-family: 'IBM Plex Mono', ui-monospace, monospace; 351 + line-height: 1.3; 352 + } 353 + .sidebar-stat { 354 + font-size: 0.75rem; 355 + font-family: 'IBM Plex Mono', ui-monospace, monospace; 356 + color: var(--text-secondary); 357 + margin-top: 0.25rem; 358 + } 359 + 360 + .sidebar-close { 361 + position: absolute; 362 + top: 0.75rem; 363 + right: 0.75rem; 364 + background: none; 365 + border: none; 366 + color: var(--text-muted); 367 + cursor: pointer; 368 + font-size: 1rem; 369 + padding: 0.25rem; 370 + line-height: 1; 371 + border-radius: 0.25rem; 372 + } 373 + .sidebar-close:hover { color: var(--text-primary); background: var(--bg-input); } 374 + 375 + .sidebar-tabs { 376 + display: flex; 377 + border-bottom: 1px solid var(--border-default); 378 + flex-shrink: 0; 379 + } 380 + .sidebar-tab { 381 + flex: 1; 382 + padding: 0.5rem; 383 + font-size: 0.8125rem; 384 + font-weight: 500; 385 + font-family: inherit; 386 + color: var(--text-muted); 387 + background: none; 388 + border: none; 389 + border-bottom: 2px solid transparent; 390 + cursor: pointer; 391 + transition: color 0.15s, border-color 0.15s; 392 + } 393 + .sidebar-tab:hover { color: var(--text-secondary); } 394 + .sidebar-tab.active { 395 + color: var(--text-primary); 396 + border-bottom-color: var(--text-primary); 397 + } 398 + 399 + .sidebar-sections { 400 + flex: 1; 401 + overflow-y: auto; 402 + } 403 + 404 + .sidebar-subsection { 405 + border-bottom: 1px solid var(--border-default); 406 + } 407 + .sidebar-subsection:last-child { border-bottom: none; } 408 + 409 + .sidebar-subsection-title { 410 + font-size: 0.75rem; 411 + font-weight: 600; 412 + color: var(--text-muted); 413 + padding: 0.375rem 1rem; 414 + background: var(--bg-input); 415 + border-bottom: 1px solid var(--border-default); 416 + } 417 + .sidebar-count { 418 + color: var(--text-secondary); 419 + font-weight: 400; 420 + } 421 + 422 + .sidebar-conn { 423 + display: flex; 424 + align-items: center; 425 + gap: 0.5rem; 426 + padding: 0.375rem 1rem; 427 + cursor: pointer; 428 + transition: background 0.1s; 429 + } 430 + .sidebar-conn:hover { background: var(--bg-input); } 431 + .sidebar-conn .sidebar-avatar { width: 28px; height: 28px; border-width: 1px; } 432 + .sidebar-conn .sidebar-avatar-fallback { width: 28px; height: 28px; border-width: 1px; } 433 + 434 + .sidebar-conn-info { 435 + display: flex; 436 + flex-direction: column; 437 + min-width: 0; 438 + flex: 1; 439 + } 440 + .sidebar-conn-name { 441 + font-size: 0.8125rem; 442 + font-weight: 500; 443 + color: var(--text-primary); 444 + white-space: nowrap; 445 + overflow: hidden; 446 + text-overflow: ellipsis; 447 + } 448 + .sidebar-conn-kind { 449 + font-size: 0.6875rem; 450 + font-family: 'IBM Plex Mono', ui-monospace, monospace; 451 + } 452 + .sidebar-mutual { 453 + font-size: 0.625rem; 454 + color: var(--text-muted); 455 + font-style: italic; 456 + } 457 + 458 + .sidebar-empty { 459 + font-size: 0.75rem; 460 + color: var(--text-muted); 461 + padding: 0.5rem 1rem; 462 + }