From 2f68c1dd185dd59747c3fe9ab8e492d1fa87f6bd Mon Sep 17 00:00:00 2001 From: Andrey Zhukov Date: Wed, 10 Jun 2026 20:53:10 +0300 Subject: [PATCH 1/8] =?UTF-8?q?feat(viewer):=20orient=20UX=20pass=20?= =?UTF-8?q?=E2=80=94=20cards,=20quick=20filters,=20readable=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-column layout, human kind labels, path shortening, stats row, quick presets (where to start / code pain / config), collapsible folder browse, cleaner detail with hidden provenance; smoke IDs preserved. Co-authored-by: Cursor --- viewer/src/app.js | 324 ++++++++++++++++++----- viewer/src/index.html | 39 ++- viewer/src/styles.css | 600 +++++++++++++++++++++++++++++++++++------- 3 files changed, 787 insertions(+), 176 deletions(-) diff --git a/viewer/src/app.js b/viewer/src/app.js index e9fe7c09..8dcd7018 100644 --- a/viewer/src/app.js +++ b/viewer/src/app.js @@ -4,6 +4,21 @@ const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; +const KIND_LABELS = { + duplication: 'Duplication', + 'static-finding': 'Static smell', + config: 'Config surface', + 'debt-candidate': 'Symbol density', + 'dep-hub': 'Dependency hub', +}; + +const QUICK_PRESETS = [ + { id: 'start', label: 'Where to start', kinds: null, maxRank: 15 }, + { id: 'pain', label: 'Code pain', kinds: ['duplication', 'static-finding', 'debt-candidate'] }, + { id: 'config', label: 'Config & deploy', kinds: ['config'] }, + { id: 'deps', label: 'Dependencies', kinds: ['dep-hub'] }, +]; + let allHotspots = []; let repos = []; let manifest = null; @@ -12,6 +27,7 @@ let filters = { kinds: new Set(), severities: new Set(), repoIds: new Set() }; let searchQuery = ''; let selectedId = null; let expandedDirs = new Set(); +let activeQuickPreset = null; async function loadJSONL(url) { const res = await fetch(url); @@ -49,8 +65,8 @@ function sevClass(s) { return `sev-${s || 'info'}`; } -function kindClass(kind) { - return kind || 'debt-candidate'; +function kindLabel(kind) { + return KIND_LABELS[kind] || kind; } function normalizeDisplayPath(p) { @@ -58,6 +74,34 @@ function normalizeDisplayPath(p) { return p.replace(/\\/g, '/'); } +/** Show repo-relative or tail of path for list UI. */ +function shortPath(p) { + const norm = normalizeDisplayPath(p); + if (!norm || norm === '(dependency-hub)') return norm; + if (norm.startsWith('/')) { + for (const r of repos) { + const root = r.path.replace(/\\/g, '/'); + if (norm === root || norm.startsWith(root + '/')) { + const rel = norm.slice(root.length).replace(/^\//, ''); + return rel || r.name || r.id; + } + } + const parts = norm.split('/').filter(Boolean); + if (parts.length <= 2) return parts.join('/'); + return '…/' + parts.slice(-2).join('/'); + } + const parts = norm.split('/').filter(Boolean); + if (parts.length <= 3) return norm; + return '…/' + parts.slice(-2).join('/'); +} + +function primaryPath(h) { + const paths = h.paths || []; + if (!paths.length) return null; + const filePath = paths.find((p) => p && p !== '(dependency-hub)') || paths[0]; + return filePath; +} + function repoForPath(p) { const norm = normalizeDisplayPath(p); if (!norm) return null; @@ -86,25 +130,26 @@ function hotspotRepo(h) { function matchesSearch(h, q) { if (!q) return true; - const hay = [ - h.summary, - h.id, - h.kind, - h.severity, - ...(h.paths || []), - ] + const hay = [h.summary, h.id, h.kind, h.severity, kindLabel(h.kind), ...(h.paths || [])] .join(' ') .toLowerCase(); return hay.includes(q); } +function matchesQuickPreset(h) { + if (!activeQuickPreset) return true; + const preset = QUICK_PRESETS.find((p) => p.id === activeQuickPreset); + if (!preset) return true; + if (preset.kinds && !preset.kinds.includes(h.kind)) return false; + if (preset.maxRank != null && h.rank > preset.maxRank) return false; + return true; +} + function matchesFilters(h) { if (filters.kinds.size && !filters.kinds.has(h.kind)) return false; if (filters.severities.size && !filters.severities.has(h.severity)) return false; if (filters.repoIds.size) { - if (h.kind === 'dep-hub') { - // Dependency hubs are landscape-wide; repo chips should not hide them. - } else { + if (h.kind !== 'dep-hub') { const rid = hotspotRepo(h); if (!rid || !filters.repoIds.has(rid)) return false; } @@ -115,10 +160,73 @@ function matchesFilters(h) { function filteredHotspots() { const q = searchQuery.trim().toLowerCase(); return allHotspots - .filter((h) => matchesSearch(h, q) && matchesFilters(h)) + .filter((h) => matchesSearch(h, q) && matchesFilters(h) && matchesQuickPreset(h)) .sort((a, b) => a.rank - b.rank); } +function kindCounts(hotspots) { + const counts = {}; + for (const h of hotspots) { + counts[h.kind] = (counts[h.kind] || 0) + 1; + } + return counts; +} + +function renderStatsRow() { + const el = document.getElementById('stats-row'); + el.innerHTML = ''; + const counts = manifest?.kind_counts || kindCounts(allHotspots); + const total = manifest?.hotspot_count ?? allHotspots.length; + const totalPill = document.createElement('span'); + totalPill.className = 'stat-pill'; + totalPill.innerHTML = `${total} hotspots`; + el.appendChild(totalPill); + const order = ['debt-candidate', 'config', 'static-finding', 'duplication', 'dep-hub']; + const kinds = [...new Set([...order, ...Object.keys(counts)])]; + for (const k of kinds) { + const n = counts[k]; + if (!n) continue; + const pill = document.createElement('span'); + pill.className = 'stat-pill'; + pill.innerHTML = `${n} ${escapeHtml(kindLabel(k))}`; + el.appendChild(pill); + } +} + +function renderQuickFilters() { + const el = document.getElementById('quick-filters'); + el.innerHTML = ''; + const clear = document.createElement('button'); + clear.type = 'button'; + clear.className = 'quick-btn' + (!activeQuickPreset && !filters.kinds.size ? ' active' : ''); + clear.textContent = 'All'; + clear.addEventListener('click', () => { + activeQuickPreset = null; + filters.kinds.clear(); + filters.severities.clear(); + filters.repoIds.clear(); + renderFilters(); + render(); + }); + el.appendChild(clear); + for (const preset of QUICK_PRESETS) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'quick-btn' + (activeQuickPreset === preset.id ? ' active' : ''); + btn.textContent = preset.label; + btn.addEventListener('click', () => { + activeQuickPreset = preset.id === activeQuickPreset ? null : preset.id; + if (activeQuickPreset) { + filters.kinds.clear(); + if (preset.kinds) preset.kinds.forEach((k) => filters.kinds.add(k)); + } + renderFilters(); + render(); + }); + el.appendChild(btn); + } +} + function buildTree(hotspots) { const root = { name: '', @@ -143,12 +251,17 @@ function buildTree(hotspots) { function addPath(pathStr, h) { const norm = normalizeDisplayPath(pathStr); - if (!norm || norm === '(dependency-hubs)') { + if (!norm || norm === '(dependency-hub)') { const node = ensureChild(root, '(dependency-hubs)', '(dependency-hubs)', false); node.hotspotIds.add(h.id); return; } - const parts = norm.split('/').filter(Boolean); + let rel = norm; + if (norm.startsWith('/')) { + rel = shortPath(norm); + if (rel.startsWith('…/')) rel = norm.split('/').pop() || norm; + } + const parts = rel.split('/').filter(Boolean); let node = root; let built = ''; for (let i = 0; i < parts.length; i++) { @@ -162,7 +275,7 @@ function buildTree(hotspots) { for (const h of hotspots) { if (!h.paths || h.paths.length === 0) { - addPath('(dependency-hubs)', h); + addPath('(dependency-hub)', h); } else { for (const p of h.paths) addPath(p, h); } @@ -179,14 +292,21 @@ function treeStats(node, hotspotById) { function renderFilters() { const bar = document.getElementById('filter-bar'); bar.innerHTML = ''; - + const counts = kindCounts(allHotspots); const kinds = [...new Set(allHotspots.map((h) => h.kind))].sort(); const severities = [...new Set(allHotspots.map((h) => h.severity))].sort( (a, b) => sevRank(a) - sevRank(b) ); - - bar.appendChild(makeChipGroup('Kind', kinds, filters.kinds, () => render())); - bar.appendChild(makeChipGroup('Severity', severities, filters.severities, () => render())); + bar.appendChild( + makeChipGroup('Kind', kinds, filters.kinds, () => { + activeQuickPreset = null; + renderQuickFilters(); + render(); + }, (v) => kindLabel(v), counts) + ); + bar.appendChild( + makeChipGroup('Severity', severities, filters.severities, () => render(), (v) => v, null) + ); if (repos.length > 1) { const repoLabels = repos.map((r) => ({ id: r.id, label: r.name || r.id })); bar.appendChild( @@ -195,13 +315,14 @@ function renderFilters() { repoLabels.map((r) => r.id), filters.repoIds, () => render(), - (id) => repoLabels.find((r) => r.id === id)?.label || id + (id) => repoLabels.find((r) => r.id === id)?.label || id, + null ) ); } } -function makeChipGroup(label, values, activeSet, onChange, labelFn = (v) => v) { +function makeChipGroup(label, values, activeSet, onChange, labelFn = (v) => v, counts = null) { const wrap = document.createElement('div'); wrap.className = 'filter-group'; const lbl = document.createElement('span'); @@ -212,7 +333,9 @@ function makeChipGroup(label, values, activeSet, onChange, labelFn = (v) => v) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'chip' + (activeSet.has(v) ? ' active' : ''); - btn.textContent = labelFn(v); + const count = counts?.[v]; + btn.innerHTML = + escapeHtml(labelFn(v)) + (count != null ? `${count}` : ''); btn.addEventListener('click', () => { if (activeSet.has(v)) activeSet.delete(v); else activeSet.add(v); @@ -228,12 +351,16 @@ function renderBanner() { const parts = []; if (manifest?.hotspots_truncated) { parts.push( - `Showing ${manifest.hotspot_count} of ${manifest.hotspots_total} hotspots (budget ${manifest.hotspot_budget} applied).` + `Showing top ${manifest.hotspot_count} of ${manifest.hotspots_total} hotspots (budget ${manifest.hotspot_budget}). Use hotspots-full.jsonl for the full list.` ); } if (gaps.length) { - const summary = gaps.map((g) => `${g.surface}: ${g.status}`).join(' · '); - parts.push(`Gaps: ${escapeHtml(summary)}`); + const items = gaps + .slice(0, 4) + .map((g) => `${escapeHtml(g.surface)}: ${escapeHtml(g.status)}`) + .join(' · '); + const more = gaps.length > 4 ? ` (+${gaps.length - 4} more)` : ''; + parts.push(`Not assessed: ${items}${more}`); } if (parts.length) { el.innerHTML = parts.join(' '); @@ -244,15 +371,44 @@ function renderBanner() { } } -function renderTour(hotspots, hotspotById) { +function renderTour(hotspots) { const list = document.getElementById('hotspot-list'); list.innerHTML = ''; - document.getElementById('tour-count').textContent = `(${hotspots.length})`; + document.getElementById('tour-count').textContent = + hotspots.length === allHotspots.length + ? `${hotspots.length} shown` + : `${hotspots.length} of ${allHotspots.length}`; + + if (!hotspots.length) { + const empty = document.createElement('li'); + empty.className = 'detail-empty'; + empty.innerHTML = + '

No matches

Try clearing filters or search.

'; + list.appendChild(empty); + return; + } + for (const h of hotspots) { const li = document.createElement('li'); - li.className = h.id === selectedId ? 'active' : ''; - li.innerHTML = `${escapeHtml(h.severity)}${escapeHtml(h.summary)}`; - li.addEventListener('click', () => selectHotspot(h)); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'hotspot-card' + (h.id === selectedId ? ' active' : ''); + btn.dataset.id = h.id; + const path = primaryPath(h); + const pathHtml = path + ? `
${escapeHtml(shortPath(path))}
` + : ''; + btn.innerHTML = ` +
+ #${h.rank} + ${escapeHtml(kindLabel(h.kind))} + ${escapeHtml(h.severity)} +
+

${escapeHtml(h.summary)}

+ ${pathHtml} + `; + btn.addEventListener('click', () => selectHotspot(h)); + li.appendChild(btn); list.appendChild(li); } } @@ -266,7 +422,7 @@ function renderTreeNode(node, hotspotById, parentEl, depth = 0) { for (const [, child] of entries) { const stats = treeStats(child, hotspotById); const hasChildren = child.children.size > 0; - const isExpanded = expandedDirs.has(child.pathKey) || depth < 2; + const isExpanded = expandedDirs.has(child.pathKey) || depth < 1; const row = document.createElement('div'); row.className = 'tree-node'; @@ -277,7 +433,7 @@ function renderTreeNode(node, hotspotById, parentEl, depth = 0) { toggle.className = 'tree-toggle' + (hasChildren ? '' : ' empty'); toggle.textContent = hasChildren ? (isExpanded ? '▾' : '▸') : ''; const bar = document.createElement('span'); - bar.className = `sev-bar ${child.isFile ? sevClass(stats.maxSeverity) : sevClass(stats.maxSeverity)}`; + bar.className = `sev-bar ${sevClass(stats.maxSeverity)}`; const name = document.createElement('span'); name.className = 'tree-name'; name.textContent = child.name; @@ -308,7 +464,8 @@ function renderTreeNode(node, hotspotById, parentEl, depth = 0) { for (const h of fileHotspots) { const hs = document.createElement('div'); hs.className = 'tree-hotspot' + (h.id === selectedId ? ' active' : ''); - hs.innerHTML = `${escapeHtml(h.kind)}#${h.rank} ${escapeHtml(h.summary)}`; + hs.textContent = `#${h.rank} ${kindLabel(h.kind)}`; + hs.title = h.summary; hs.addEventListener('click', () => selectHotspot(h)); row.appendChild(hs); } @@ -320,7 +477,6 @@ function renderTreeNode(node, hotspotById, parentEl, depth = 0) { row.appendChild(childWrap); parentEl.appendChild(row); } - } function renderTree(hotspots) { @@ -334,14 +490,15 @@ function renderTree(hotspots) { const stats = treeStats(depNode, hotspotById); const row = document.createElement('div'); row.className = 'tree-row'; - row.innerHTML = `(dependency-hubs)${stats.count}`; + row.innerHTML = `(dependency-hubs)${stats.count}`; treeEl.appendChild(row); for (const id of depNode.hotspotIds) { const h = hotspotById.get(id); if (!h) continue; const hs = document.createElement('div'); hs.className = 'tree-hotspot' + (h.id === selectedId ? ' active' : ''); - hs.innerHTML = `${escapeHtml(h.kind)}#${h.rank} ${escapeHtml(h.summary)}`; + hs.textContent = `#${h.rank} ${kindLabel(h.kind)}`; + hs.title = h.summary; hs.addEventListener('click', () => selectHotspot(h)); treeEl.appendChild(hs); } @@ -354,20 +511,23 @@ function renderTree(hotspots) { } } -async function loadSourcePreview(h) { +async function loadSourcePreview(h, pathOverride) { const preview = document.getElementById('source-preview'); const code = document.getElementById('source-code'); - const path = (h.paths || [])[0]; - if (!path) { + const pathLabel = document.getElementById('source-path'); + const path = pathOverride || primaryPath(h); + if (!path || path === '(dependency-hub)') { preview.classList.add('hidden'); return; } + pathLabel.textContent = shortPath(path); + pathLabel.title = normalizeDisplayPath(path); try { const params = new URLSearchParams({ path, line: '1' }); const res = await fetch(`/source?${params}`); if (!res.ok) { preview.classList.remove('hidden'); - code.textContent = `Source unavailable (${res.status}) for ${path}`; + code.textContent = `Source unavailable (${res.status})`; return; } const data = await res.json(); @@ -392,32 +552,59 @@ function symbolCountFromSummary(summary) { function renderDetail(h) { const el = document.getElementById('detail-body'); if (!h) { - el.innerHTML = '

Select a hotspot.

'; + el.className = 'detail-empty'; + el.innerHTML = ` +

Pick a hotspot

+

Use quick filters above or search. Each item links to local source when a file path exists.

+ `; document.getElementById('source-preview').classList.add('hidden'); return; } - const paths = (h.paths || []) - .map((p) => `
${escapeHtml(p)}
`) - .join(''); + el.className = ''; const rid = hotspotRepo(h); const repoLabel = rid ? repos.find((r) => r.id === rid)?.name || rid : ''; - const symCount = - h.kind === 'debt-candidate' ? symbolCountFromSummary(h.summary) : null; + const symCount = h.kind === 'debt-candidate' ? symbolCountFromSummary(h.summary) : null; + const paths = (h.paths || []).filter((p) => p && p !== '(dependency-hub)'); + const pathHtml = paths.length + ? paths + .map( + (p, i) => + `` + ) + .join('') + : '

No file paths (landscape-level hotspot).

'; + el.innerHTML = ` -

- ${escapeHtml(h.severity)} - ${escapeHtml(h.kind)} - ${escapeHtml(h.evidence_state)} - ${escapeHtml(h.producer)} - #${h.rank} - ${repoLabel ? `${escapeHtml(repoLabel)}` : ''} -

-

${escapeHtml(h.summary)}

- ${symCount ? `

Symbol count: ${escapeHtml(symCount)}

` : ''} -

id: ${escapeHtml(h.id)}

- ${paths || '

(no file paths)

'} -

producer_ref: ${escapeHtml(h.producer_ref || '')}

+
+
+ ${escapeHtml(kindLabel(h.kind))} + ${escapeHtml(h.severity)} + ${repoLabel ? `${escapeHtml(repoLabel)}` : ''} + #${h.rank} +
+

${escapeHtml(h.summary)}

+ ${symCount ? `

Symbol count: ${escapeHtml(symCount)}

` : ''} +
+
+

Paths

+ ${pathHtml} +
+
+ Evidence & provenance +
+

State: ${escapeHtml(h.evidence_state)} · Producer: ${escapeHtml(h.producer)}

+

id: ${escapeHtml(h.id)}

+

producer_ref: ${escapeHtml(h.producer_ref || '')}

+
+
`; + + el.querySelectorAll('.path-chip').forEach((chip) => { + chip.addEventListener('click', () => { + loadSourcePreview(h, chip.dataset.path); + }); + }); + loadSourcePreview(h); } @@ -425,6 +612,10 @@ function selectHotspot(h) { selectedId = h?.id ?? null; renderDetail(h); render(); + requestAnimationFrame(() => { + const active = document.querySelector('.hotspot-card.active'); + active?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }); } function render() { @@ -435,7 +626,7 @@ function render() { renderDetail(null); } renderBanner(); - renderTour(hotspots, hotspotById); + renderTour(hotspots); renderTree(hotspots); } @@ -445,10 +636,15 @@ async function main() { gaps = await loadJSONL('/bundle/gaps.jsonl'); repos = (await loadJSON('/bundle/repos.json')) || []; + const targetShort = manifest?.target_root + ? shortPath(manifest.target_root) || manifest.target_root + : ''; document.getElementById('manifest-info').textContent = manifest - ? `target: ${manifest.target_root} · hotspots: ${manifest.hotspot_count} · gaps: ${manifest.gap_count}` + ? `${targetShort} · ${manifest.hotspot_count} hotspots · ${manifest.gap_count} gaps` : 'no manifest'; + renderStatsRow(); + renderQuickFilters(); renderFilters(); renderBanner(); @@ -458,10 +654,6 @@ async function main() { }); render(); - if (allHotspots.length) { - const first = filteredHotspots()[0] || allHotspots[0]; - selectHotspot(first); - } } main().catch((e) => { diff --git a/viewer/src/index.html b/viewer/src/index.html index 5ce53823..6cf59273 100644 --- a/viewer/src/index.html +++ b/viewer/src/index.html @@ -9,32 +9,43 @@
-
+

Portolan Orient

-

Evidence-backed hotspots — not LLM architecture truth

+

Where to look first — evidence-backed, not LLM guesses

- +
+
+
-
-

Hotspot tour

-
    -
    -
    -

    Directory heat map

    -
    +
    +
    +

    Hotspots

    + +
    +
      +
      + Browse by folder +
      +
      -
      -

      Detail

      -

      Select a hotspot.

      +
      +

      Detail

      +
      +

      Pick a hotspot

      +

      Use quick filters above or search. Each item links to local source when a file path exists.

      +
      diff --git a/viewer/src/styles.css b/viewer/src/styles.css index bb9102f3..9315b691 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -1,29 +1,37 @@ :root { - --bg: #0f1419; - --panel: #1a2332; - --text: #e7ecf3; + --bg: #0c0f14; + --panel: #151b24; + --panel-elevated: #1c2430; + --border: #2a3548; + --text: #eef2f8; --muted: #8b9bb4; - --dup: #e85d5d; - --static: #e8a35d; - --dep: #5d9ae8; - --debt: #b85de8; - --gap: #6b7280; - --sev-critical: #ff4d4d; + --accent: #5d9ae8; + --sev-critical: #ff5c5c; --sev-high: #ff8c42; --sev-medium: #e8a35d; --sev-low: #5d9ae8; --sev-info: #8b9bb4; - --banner-warn: #3d2e14; - --banner-gap: #1e2a3d; + --kind-duplication: #e85d5d; + --kind-static: #e8a35d; + --kind-config: #4ec9b0; + --kind-debt: #b885ff; + --kind-dep: #5d9ae8; + --banner-warn: #2a2210; + --banner-gap: #141c28; + --radius: 10px; + --shadow: 0 2px 12px rgba(0, 0, 0, 0.35); } * { box-sizing: border-box; } + body { margin: 0; - font-family: system-ui, sans-serif; + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); + line-height: 1.5; } + .sr-only { position: absolute; width: 1px; @@ -34,11 +42,20 @@ body { clip: rect(0, 0, 0, 0); border: 0; } -header, footer { - padding: 1rem 1.5rem; - border-bottom: 1px solid #2a3548; + +header { + padding: 1.25rem 1.5rem 1rem; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, #121820 0%, var(--bg) 100%); +} + +footer { + padding: 0.75rem 1.5rem; + border-top: 1px solid var(--border); + font-size: 0.8rem; + color: var(--muted); } -footer { border-top: 1px solid #2a3548; border-bottom: none; font-size: 0.85rem; color: var(--muted); } + .header-top { display: flex; flex-wrap: wrap; @@ -46,174 +63,565 @@ footer { border-top: 1px solid #2a3548; border-bottom: none; font-size: 0.85rem; justify-content: space-between; gap: 1rem; } -h1 { margin: 0; font-size: 1.25rem; } -#subtitle { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.9rem; } -.search-wrap { flex: 1; min-width: 200px; max-width: 420px; } + +.brand h1 { + margin: 0; + font-size: 1.35rem; + font-weight: 650; + letter-spacing: -0.02em; +} + +#subtitle { + margin: 0.35rem 0 0; + color: var(--muted); + font-size: 0.9rem; + max-width: 36rem; +} + +.search-wrap { + flex: 1; + min-width: 220px; + max-width: 440px; +} + #search-input { width: 100%; - padding: 0.5rem 0.75rem; - border-radius: 6px; - border: 1px solid #2a3548; + padding: 0.65rem 0.9rem; + border-radius: var(--radius); + border: 1px solid var(--border); background: var(--panel); color: var(--text); font-size: 0.95rem; + transition: border-color 0.15s, box-shadow 0.15s; +} + +#search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(93, 154, 232, 0.2); +} + +.stats-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; +} + +.stat-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.65rem; + border-radius: 999px; + background: var(--panel); + border: 1px solid var(--border); + font-size: 0.8rem; + color: var(--muted); +} + +.stat-pill strong { + color: var(--text); + font-weight: 600; +} + +.stat-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.stat-dot.duplication { background: var(--kind-duplication); } +.stat-dot.static-finding { background: var(--kind-static); } +.stat-dot.config { background: var(--kind-config); } +.stat-dot.debt-candidate { background: var(--kind-debt); } +.stat-dot.dep-hub { background: var(--kind-dep); } + +.quick-filters { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.75rem; +} + +.quick-btn { + font-size: 0.8rem; + padding: 0.35rem 0.75rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.quick-btn:hover { + background: var(--panel-elevated); + border-color: #3d4d66; +} + +.quick-btn.active { + background: #243044; + border-color: var(--accent); + color: #fff; } + .status-banner { margin-top: 0.75rem; - padding: 0.5rem 0.75rem; - border-radius: 6px; + padding: 0.6rem 0.85rem; + border-radius: var(--radius); font-size: 0.85rem; + line-height: 1.4; } + .status-banner.hidden { display: none; } -.status-banner.truncation { background: var(--banner-warn); color: #f5d9a8; } -.status-banner.gaps { background: var(--banner-gap); color: var(--muted); } +.status-banner.truncation { + background: var(--banner-warn); + color: #f0d9a8; + border: 1px solid #4a3a18; +} +.status-banner.gaps { + background: var(--banner-gap); + color: var(--muted); + border: 1px solid var(--border); +} + .filter-bar { display: flex; flex-wrap: wrap; - gap: 0.35rem; + gap: 0.5rem; margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); } + .filter-group { display: flex; flex-wrap: wrap; align-items: center; - gap: 0.25rem; - margin-right: 0.75rem; + gap: 0.3rem; } + .filter-label { - font-size: 0.7rem; + font-size: 0.68rem; text-transform: uppercase; + letter-spacing: 0.04em; color: var(--muted); - margin-right: 0.25rem; + margin-right: 0.15rem; } + .chip { font-size: 0.75rem; - padding: 0.2rem 0.5rem; + padding: 0.25rem 0.55rem; border-radius: 999px; - border: 1px solid #2a3548; + border: 1px solid var(--border); background: transparent; color: var(--muted); cursor: pointer; + transition: all 0.12s; } -.chip:hover { border-color: var(--muted); color: var(--text); } + +.chip:hover { + border-color: #4a5568; + color: var(--text); +} + .chip.active { - background: #2a3548; + background: #243044; color: var(--text); - border-color: #4a5568; + border-color: var(--accent); } + +.chip .chip-count { + opacity: 0.7; + margin-left: 0.15rem; +} + .layout { display: grid; - grid-template-columns: minmax(220px, 1fr) minmax(280px, 1.2fr) minmax(260px, 1fr); + grid-template-columns: minmax(300px, 1fr) minmax(340px, 1.15fr); gap: 1rem; - padding: 1rem; + padding: 1rem 1.5rem 1.5rem; + max-width: 1400px; + margin: 0 auto; } -@media (max-width: 960px) { + +@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } } + .panel { background: var(--panel); - border-radius: 8px; - padding: 1rem; - min-height: 200px; - max-height: 70vh; - overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + min-height: 280px; + max-height: calc(100vh - 220px); + overflow: hidden; + display: flex; + flex-direction: column; } -.panel h2 { margin: 0 0 0.75rem; font-size: 1rem; } + +.panel-list { padding: 0; } +.panel-detail { padding: 1.25rem; overflow: auto; } + +.panel-head { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 1rem 1rem 0.5rem; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.panel-head h2 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; +} + .count-badge { font-size: 0.75rem; color: var(--muted); - font-weight: normal; } -.badge { - display: inline-block; + +#hotspot-list { + list-style: none; + margin: 0; + padding: 0.5rem; + overflow-y: auto; + flex: 1; +} + +.hotspot-card { + display: block; + width: 100%; + text-align: left; + margin-bottom: 0.4rem; + padding: 0.75rem 0.85rem; + border-radius: 8px; + border: 1px solid transparent; + background: var(--panel-elevated); + color: inherit; + cursor: pointer; + transition: border-color 0.12s, background 0.12s; + font: inherit; +} + +.hotspot-card:hover { + border-color: #3d4d66; + background: #1f2836; +} + +.hotspot-card.active { + border-color: var(--accent); + background: #1a2a40; + box-shadow: inset 3px 0 0 var(--accent); +} + +.card-top { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.35rem; +} + +.card-rank { font-size: 0.7rem; - padding: 0.1rem 0.35rem; + color: var(--muted); + font-variant-numeric: tabular-nums; + min-width: 1.5rem; +} + +.kind-pill { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.12rem 0.4rem; border-radius: 4px; - background: #2a3548; +} + +.kind-pill.duplication { background: rgba(232, 93, 93, 0.2); color: #ffb4b4; } +.kind-pill.static-finding { background: rgba(232, 163, 93, 0.2); color: #ffd4a8; } +.kind-pill.config { background: rgba(78, 201, 176, 0.2); color: #9ee8d8; } +.kind-pill.debt-candidate { background: rgba(184, 133, 255, 0.2); color: #d4b8ff; } +.kind-pill.dep-hub { background: rgba(93, 154, 232, 0.2); color: #b8d4ff; } + +.card-summary { + font-size: 0.9rem; + line-height: 1.35; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.card-path { + margin-top: 0.35rem; + font-family: ui-monospace, 'Cascadia Code', monospace; + font-size: 0.72rem; color: var(--muted); - margin-right: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.badge.sev-critical { background: color-mix(in srgb, var(--sev-critical) 40%, #2a3548); color: #ffc9c9; } -.badge.sev-high { background: color-mix(in srgb, var(--sev-high) 40%, #2a3548); color: #ffd4b8; } -.badge.sev-medium { background: color-mix(in srgb, var(--sev-medium) 40%, #2a3548); color: #ffe4c4; } -.badge.sev-low { background: color-mix(in srgb, var(--sev-low) 40%, #2a3548); color: #c9e0ff; } -.badge.sev-info { background: #2a3548; color: var(--muted); } -#hotspot-list { padding-left: 1.2rem; margin: 0; list-style: decimal; } -#hotspot-list li { - margin-bottom: 0.5rem; + +.folder-browse { + border-top: 1px solid var(--border); + padding: 0.5rem 1rem 1rem; + flex-shrink: 0; +} + +.folder-browse summary { cursor: pointer; - padding: 0.25rem 0; - border-radius: 4px; + font-size: 0.8rem; + color: var(--muted); + user-select: none; + padding: 0.35rem 0; +} + +.folder-browse[open] summary { + margin-bottom: 0.5rem; + color: var(--text); } -#hotspot-list li:hover { color: #fff; } -#hotspot-list li.active { background: #2a3548; padding-left: 0.25rem; } -.heat-tree { font-size: 0.85rem; } + +.heat-tree { + font-size: 0.8rem; + max-height: 200px; + overflow: auto; +} + .tree-node { margin-left: 0; } + .tree-row { display: flex; align-items: center; gap: 0.35rem; - padding: 0.2rem 0.25rem; + padding: 0.15rem 0.25rem; border-radius: 4px; - cursor: pointer; } -.tree-row:hover { background: #243044; } -.tree-row.active { background: #2d3f5c; } + +.tree-row:hover { background: #1f2836; } + .tree-toggle { width: 1rem; text-align: center; color: var(--muted); flex-shrink: 0; user-select: none; + cursor: pointer; } + .tree-toggle.empty { visibility: hidden; } -.tree-name { flex: 1; font-family: monospace; font-size: 0.8rem; } + +.tree-name { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .tree-meta { - font-size: 0.7rem; + font-size: 0.68rem; color: var(--muted); flex-shrink: 0; } -.tree-children { margin-left: 1rem; border-left: 1px solid #2a3548; } + +.tree-children { + margin-left: 0.75rem; + border-left: 1px solid var(--border); +} + .tree-children.collapsed { display: none; } + .tree-hotspot { - margin-left: 1.25rem; - padding: 0.2rem 0.35rem; + margin-left: 1rem; + padding: 0.15rem 0.35rem; cursor: pointer; border-radius: 4px; - font-size: 0.8rem; + font-size: 0.72rem; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.tree-hotspot:hover { background: #243044; } -.tree-hotspot.active { background: #2d3f5c; } + +.tree-hotspot:hover { background: #1f2836; color: var(--text); } +.tree-hotspot.active { background: #1a2a40; color: var(--text); } + .sev-bar { - width: 4px; - height: 1rem; + width: 3px; + height: 0.9rem; border-radius: 2px; flex-shrink: 0; } -.sev-bar.critical { background: var(--sev-critical); } -.sev-bar.high { background: var(--sev-high); } -.sev-bar.medium { background: var(--sev-medium); } -.sev-bar.low { background: var(--sev-low); } -.sev-bar.info { background: var(--sev-info); } -.path { font-family: monospace; font-size: 0.8rem; color: var(--muted); word-break: break-all; } -.source-preview { margin-top: 1rem; border-top: 1px solid #2a3548; padding-top: 0.75rem; } -.source-preview.hidden { display: none; } -.source-preview h3 { margin: 0 0 0.5rem; font-size: 0.9rem; color: var(--muted); } -#source-code { + +.sev-bar.sev-critical { background: var(--sev-critical); } +.sev-bar.sev-high { background: var(--sev-high); } +.sev-bar.sev-medium { background: var(--sev-medium); } +.sev-bar.sev-low { background: var(--sev-low); } +.sev-bar.sev-info { background: var(--sev-info); } + +.detail-empty { + color: var(--muted); + padding: 2rem 0.5rem; + text-align: center; +} + +.empty-title { + margin: 0 0 0.5rem; + font-size: 1.1rem; + color: var(--text); +} + +.empty-hint { margin: 0; - padding: 0.75rem; - background: #0a0e14; + font-size: 0.9rem; + max-width: 28rem; + margin-inline: auto; +} + +.detail-header { + margin-bottom: 1rem; +} + +.detail-title { + margin: 0 0 0.75rem; + font-size: 1.15rem; + font-weight: 600; + line-height: 1.35; +} + +.detail-meta { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.badge { + display: inline-block; + font-size: 0.72rem; + padding: 0.2rem 0.45rem; + border-radius: 6px; + background: #243044; + color: var(--muted); +} + +.badge.sev-critical { background: rgba(255, 92, 92, 0.25); color: #ffc9c9; } +.badge.sev-high { background: rgba(255, 140, 66, 0.25); color: #ffd4b8; } +.badge.sev-medium { background: rgba(232, 163, 93, 0.25); color: #ffe4c4; } +.badge.sev-low { background: rgba(93, 154, 232, 0.25); color: #c9e0ff; } +.badge.sev-info { background: #243044; color: var(--muted); } + +.detail-section { + margin-top: 1.25rem; +} + +.detail-section h3 { + margin: 0 0 0.5rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + font-weight: 600; +} + +.path-chip { + display: block; + font-family: ui-monospace, monospace; + font-size: 0.78rem; + padding: 0.45rem 0.6rem; + margin-bottom: 0.35rem; border-radius: 6px; + background: #0f1419; + border: 1px solid var(--border); + color: #c5d0e0; + word-break: break-all; + cursor: pointer; +} + +.path-chip:hover { + border-color: var(--accent); + color: #fff; +} + +.path-chip.primary { border-color: #3d5a80; } + +.evidence-toggle { + font-size: 0.8rem; + color: var(--muted); + cursor: pointer; + margin-top: 1rem; +} + +.evidence-toggle:hover { color: var(--text); } + +.evidence-body { + margin-top: 0.5rem; font-size: 0.75rem; - line-height: 1.45; - overflow-x: auto; - max-height: 320px; + color: var(--muted); + word-break: break-all; } + +.evidence-body.hidden { display: none; } + +.source-preview { + margin-top: 1.5rem; + border-top: 1px solid var(--border); + padding-top: 1rem; +} + +.source-preview.hidden { display: none; } + +.source-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.source-head h3 { + margin: 0; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} + +.source-path { + font-family: ui-monospace, monospace; + font-size: 0.72rem; + color: var(--muted); +} + +#source-code { + margin: 0; + padding: 0.85rem; + background: #080b10; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 0.78rem; + line-height: 1.5; + overflow: auto; + max-height: 360px; +} + #source-code .line { display: block; } -#source-code .line.highlight { background: #2d3f5c; } +#source-code .line.highlight { background: #1a2a40; } #source-code .lineno { display: inline-block; - width: 3.5rem; - color: var(--muted); + width: 3rem; + color: #5a6a82; user-select: none; + text-align: right; + padding-right: 0.75rem; } + +.path { font-family: ui-monospace, monospace; font-size: 0.8rem; color: var(--muted); word-break: break-all; } From 4badb1c68bac16b6d6241f7abe425b122b1df41f Mon Sep 17 00:00:00 2001 From: Andrey Zhukov Date: Wed, 10 Jun 2026 21:04:51 +0300 Subject: [PATCH 2/8] feat(viewer): clarify views, kind filters, and why each hotspot exists Replace ambiguous "Where to start" with named views and a live explainer; fix kind chip state (locked under views vs custom); add in-app guide for tools/limits and per-hotspot "Why is this here?" copy. Co-authored-by: Cursor --- viewer/src/app.js | 300 ++++++++++++++++++++++++++++++------------ viewer/src/index.html | 21 ++- viewer/src/styles.css | 147 +++++++++++++++------ 3 files changed, 341 insertions(+), 127 deletions(-) diff --git a/viewer/src/app.js b/viewer/src/app.js index 8dcd7018..76755c67 100644 --- a/viewer/src/app.js +++ b/viewer/src/app.js @@ -12,12 +12,62 @@ const KIND_LABELS = { 'dep-hub': 'Dependency hub', }; -const QUICK_PRESETS = [ - { id: 'start', label: 'Where to start', kinds: null, maxRank: 15 }, - { id: 'pain', label: 'Code pain', kinds: ['duplication', 'static-finding', 'debt-candidate'] }, - { id: 'config', label: 'Config & deploy', kinds: ['config'] }, - { id: 'deps', label: 'Dependencies', kinds: ['dep-hub'] }, -]; +/** viewMode: preset id or 'custom' when user toggles kind chips */ +const VIEWS = { + all: { + label: 'All', + description: 'Every hotspot in this bundle (subject to search & severity filters).', + }, + top15: { + label: 'Tour · top 15', + description: + 'Only ranks #1–#15. Rank is bundle sort order (severity first, then kind quotas) — a guided slice, not the full landscape.', + maxRank: 15, + }, + 'code-pain': { + label: 'Code pain', + description: 'Duplication, semgrep smells, and symbol-dense files — typical refactor/review targets.', + kinds: ['duplication', 'static-finding', 'debt-candidate'], + }, + config: { + label: 'Config & deploy', + description: 'Docker, CI, compose, env, terraform inventory — where ops/config risk lives (informational).', + kinds: ['config'], + }, + deps: { + label: 'Dependencies', + description: 'Packages with many dependents in the SBOM (Syft) — upgrade blast-radius hubs.', + kinds: ['dep-hub'], + }, +}; + +const KIND_HELP = { + duplication: { + why: 'jscpd found a repeated code block. Copies drift apart when you change one copy and forget the other.', + tool: 'jscpd', + limit: 'Only near-identical fragments; not design-level duplication.', + }, + 'static-finding': { + why: 'Semgrep matched a local rule (TODO/FIXME, secret-like patterns, etc.).', + tool: 'semgrep', + limit: 'Rule-based; not a full security audit.', + }, + config: { + why: 'File is part of deploy/config surface (Dockerfile, workflow, .env, terraform…).', + tool: 'config scan', + limit: 'Inventory only — severity is info, not a vulnerability claim.', + }, + 'debt-candidate': { + why: 'File has many symbols (ctags). Dense files are harder to navigate — candidate for split or review.', + tool: 'universal-ctags', + limit: 'Symbol count ≠ complexity score; no AI judgment.', + }, + 'dep-hub': { + why: 'Package has many dependency edges in the CycloneDX SBOM.', + tool: 'syft', + limit: 'Landscape-level; no file path attached.', + }, +}; let allHotspots = []; let repos = []; @@ -27,7 +77,8 @@ let filters = { kinds: new Set(), severities: new Set(), repoIds: new Set() }; let searchQuery = ''; let selectedId = null; let expandedDirs = new Set(); -let activeQuickPreset = null; +/** @type {keyof VIEWS | 'custom'} */ +let viewMode = 'top15'; async function loadJSONL(url) { const res = await fetch(url); @@ -74,7 +125,6 @@ function normalizeDisplayPath(p) { return p.replace(/\\/g, '/'); } -/** Show repo-relative or tail of path for list UI. */ function shortPath(p) { const norm = normalizeDisplayPath(p); if (!norm || norm === '(dependency-hub)') return norm; @@ -98,8 +148,7 @@ function shortPath(p) { function primaryPath(h) { const paths = h.paths || []; if (!paths.length) return null; - const filePath = paths.find((p) => p && p !== '(dependency-hub)') || paths[0]; - return filePath; + return paths.find((p) => p && p !== '(dependency-hub)') || paths[0]; } function repoForPath(p) { @@ -136,17 +185,19 @@ function matchesSearch(h, q) { return hay.includes(q); } -function matchesQuickPreset(h) { - if (!activeQuickPreset) return true; - const preset = QUICK_PRESETS.find((p) => p.id === activeQuickPreset); - if (!preset) return true; - if (preset.kinds && !preset.kinds.includes(h.kind)) return false; - if (preset.maxRank != null && h.rank > preset.maxRank) return false; +function matchesView(h) { + if (viewMode === 'custom') { + if (filters.kinds.size && !filters.kinds.has(h.kind)) return false; + return true; + } + const view = VIEWS[viewMode]; + if (!view) return true; + if (view.kinds && !view.kinds.includes(h.kind)) return false; + if (view.maxRank != null && h.rank > view.maxRank) return false; return true; } function matchesFilters(h) { - if (filters.kinds.size && !filters.kinds.has(h.kind)) return false; if (filters.severities.size && !filters.severities.has(h.severity)) return false; if (filters.repoIds.size) { if (h.kind !== 'dep-hub') { @@ -160,7 +211,7 @@ function matchesFilters(h) { function filteredHotspots() { const q = searchQuery.trim().toLowerCase(); return allHotspots - .filter((h) => matchesSearch(h, q) && matchesFilters(h) && matchesQuickPreset(h)) + .filter((h) => matchesSearch(h, q) && matchesView(h) && matchesFilters(h)) .sort((a, b) => a.rank - b.rank); } @@ -172,59 +223,104 @@ function kindCounts(hotspots) { return counts; } -function renderStatsRow() { - const el = document.getElementById('stats-row'); - el.innerHTML = ''; - const counts = manifest?.kind_counts || kindCounts(allHotspots); - const total = manifest?.hotspot_count ?? allHotspots.length; - const totalPill = document.createElement('span'); - totalPill.className = 'stat-pill'; - totalPill.innerHTML = `${total} hotspots`; - el.appendChild(totalPill); - const order = ['debt-candidate', 'config', 'static-finding', 'duplication', 'dep-hub']; - const kinds = [...new Set([...order, ...Object.keys(counts)])]; - for (const k of kinds) { - const n = counts[k]; - if (!n) continue; - const pill = document.createElement('span'); - pill.className = 'stat-pill'; - pill.innerHTML = `${n} ${escapeHtml(kindLabel(k))}`; - el.appendChild(pill); +function setView(mode) { + viewMode = mode; + filters.kinds.clear(); + renderViewBar(); + renderFilterExplainer(); + renderFilters(); + render(); +} + +function enterCustomKindFilter(kind) { + viewMode = 'custom'; + filters.kinds.clear(); + filters.kinds.add(kind); + renderViewBar(); + renderFilterExplainer(); + renderFilters(); + render(); +} + +function toggleCustomKind(kind) { + if (filters.kinds.has(kind)) filters.kinds.delete(kind); + else filters.kinds.add(kind); + renderFilterExplainer(); + renderFilters(); + render(); +} + +function kindChipState(kind) { + if (viewMode === 'custom') { + if (filters.kinds.size === 0) return 'inactive'; + return filters.kinds.has(kind) ? 'active' : 'inactive'; + } + const view = VIEWS[viewMode]; + if (view?.kinds) { + return view.kinds.includes(kind) ? 'locked' : 'inactive'; } + return 'inactive'; } -function renderQuickFilters() { - const el = document.getElementById('quick-filters'); +function renderViewBar() { + const el = document.getElementById('view-bar'); el.innerHTML = ''; - const clear = document.createElement('button'); - clear.type = 'button'; - clear.className = 'quick-btn' + (!activeQuickPreset && !filters.kinds.size ? ' active' : ''); - clear.textContent = 'All'; - clear.addEventListener('click', () => { - activeQuickPreset = null; - filters.kinds.clear(); - filters.severities.clear(); - filters.repoIds.clear(); - renderFilters(); - render(); - }); - el.appendChild(clear); - for (const preset of QUICK_PRESETS) { + for (const [id, view] of Object.entries(VIEWS)) { const btn = document.createElement('button'); btn.type = 'button'; - btn.className = 'quick-btn' + (activeQuickPreset === preset.id ? ' active' : ''); - btn.textContent = preset.label; - btn.addEventListener('click', () => { - activeQuickPreset = preset.id === activeQuickPreset ? null : preset.id; - if (activeQuickPreset) { - filters.kinds.clear(); - if (preset.kinds) preset.kinds.forEach((k) => filters.kinds.add(k)); - } - renderFilters(); - render(); - }); + btn.className = 'view-btn' + (viewMode === id ? ' active' : ''); + btn.textContent = view.label; + btn.title = view.description; + btn.addEventListener('click', () => setView(id)); el.appendChild(btn); } + if (viewMode === 'custom') { + const custom = document.createElement('span'); + custom.className = 'view-btn active custom-tag'; + custom.textContent = 'Custom kind filter'; + el.appendChild(custom); + } +} + +function renderFilterExplainer() { + const el = document.getElementById('filter-explainer'); + const shown = filteredHotspots().length; + const total = allHotspots.length; + let main = ''; + if (viewMode === 'custom') { + const kinds = + filters.kinds.size === 0 + ? 'all kinds' + : [...filters.kinds].map(kindLabel).join(', '); + main = `Custom filter — kinds: ${escapeHtml(kinds)}. Click kind chips below to add/remove. Pick a view above to reset.`; + } else { + const view = VIEWS[viewMode]; + main = view + ? `${escapeHtml(view.label)} — ${escapeHtml(view.description)}` + : ''; + } + el.innerHTML = ` +

      ${main}

      +

      Showing ${shown} of ${total} hotspots in bundle. + Rank #n = sort position (severity + per-kind budget). + Only tools that ran in this orient run appear; missing layers are gaps, not hidden hotspots.

      + `; +} + +function renderKindGuide() { + const el = document.getElementById('kind-guide-body'); + const order = ['duplication', 'static-finding', 'debt-candidate', 'config', 'dep-hub']; + el.innerHTML = order + .filter((k) => KIND_HELP[k]) + .map((k) => { + const h = KIND_HELP[k]; + return `
      +

      ${escapeHtml(kindLabel(k))}

      +

      ${escapeHtml(h.why)}

      +

      Tool: ${escapeHtml(h.tool)} · ${escapeHtml(h.limit)}

      +
      `; + }) + .join(''); } function buildTree(hotspots) { @@ -297,13 +393,36 @@ function renderFilters() { const severities = [...new Set(allHotspots.map((h) => h.severity))].sort( (a, b) => sevRank(a) - sevRank(b) ); - bar.appendChild( - makeChipGroup('Kind', kinds, filters.kinds, () => { - activeQuickPreset = null; - renderQuickFilters(); - render(); - }, (v) => kindLabel(v), counts) - ); + + const kindWrap = document.createElement('div'); + kindWrap.className = 'filter-group'; + const kindLbl = document.createElement('span'); + kindLbl.className = 'filter-label'; + kindLbl.textContent = viewMode === 'custom' ? 'Kind (custom)' : 'Kind (click to customize)'; + kindWrap.appendChild(kindLbl); + for (const v of kinds) { + const state = kindChipState(v); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'chip ' + state; + btn.innerHTML = + escapeHtml(kindLabel(v)) + `${counts[v] ?? 0}`; + if (state === 'locked') { + btn.title = 'Set by current view. Click to switch to custom filter for this kind only.'; + } else if (state === 'inactive') { + btn.title = 'Click to filter by this kind only (switches to custom filter).'; + } + btn.addEventListener('click', () => { + if (viewMode !== 'custom') { + enterCustomKindFilter(v); + } else { + toggleCustomKind(v); + } + }); + kindWrap.appendChild(btn); + } + bar.appendChild(kindWrap); + bar.appendChild( makeChipGroup('Severity', severities, filters.severities, () => render(), (v) => v, null) ); @@ -351,7 +470,7 @@ function renderBanner() { const parts = []; if (manifest?.hotspots_truncated) { parts.push( - `Showing top ${manifest.hotspot_count} of ${manifest.hotspots_total} hotspots (budget ${manifest.hotspot_budget}). Use hotspots-full.jsonl for the full list.` + `Bundle truncated: ${manifest.hotspot_count} of ${manifest.hotspots_total} hotspots (budget ${manifest.hotspot_budget}). Full list: hotspots-full.jsonl.` ); } if (gaps.length) { @@ -360,7 +479,9 @@ function renderBanner() { .map((g) => `${escapeHtml(g.surface)}: ${escapeHtml(g.status)}`) .join(' · '); const more = gaps.length > 4 ? ` (+${gaps.length - 4} more)` : ''; - parts.push(`Not assessed: ${items}${more}`); + parts.push( + `Layers not run or empty: ${items}${more}. These are not hidden hotspots — they were not assessed in this run.` + ); } if (parts.length) { el.innerHTML = parts.join(' '); @@ -371,6 +492,11 @@ function renderBanner() { } } +function kindWhyLine(kind) { + const h = KIND_HELP[kind]; + return h ? h.why : 'Flagged by a local producer in this orient bundle.'; +} + function renderTour(hotspots) { const list = document.getElementById('hotspot-list'); list.innerHTML = ''; @@ -383,7 +509,7 @@ function renderTour(hotspots) { const empty = document.createElement('li'); empty.className = 'detail-empty'; empty.innerHTML = - '

      No matches

      Try clearing filters or search.

      '; + '

      No matches

      Try view All or clear severity filters.

      '; list.appendChild(empty); return; } @@ -393,18 +519,18 @@ function renderTour(hotspots) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'hotspot-card' + (h.id === selectedId ? ' active' : ''); - btn.dataset.id = h.id; const path = primaryPath(h); const pathHtml = path ? `
      ${escapeHtml(shortPath(path))}
      ` : ''; btn.innerHTML = `
      - #${h.rank} + #${h.rank} ${escapeHtml(kindLabel(h.kind))} ${escapeHtml(h.severity)}

      ${escapeHtml(h.summary)}

      +

      ${escapeHtml(kindWhyLine(h.kind))}

      ${pathHtml} `; btn.addEventListener('click', () => selectHotspot(h)); @@ -554,13 +680,14 @@ function renderDetail(h) { if (!h) { el.className = 'detail-empty'; el.innerHTML = ` -

      Pick a hotspot

      -

      Use quick filters above or search. Each item links to local source when a file path exists.

      +

      Select a hotspot

      +

      Choose a view above, then click a row. This panel explains why the tool flagged it.

      `; document.getElementById('source-preview').classList.add('hidden'); return; } el.className = ''; + const help = KIND_HELP[h.kind] || {}; const rid = hotspotRepo(h); const repoLabel = rid ? repos.find((r) => r.id === rid)?.name || rid : ''; const symCount = h.kind === 'debt-candidate' ? symbolCountFromSummary(h.summary) : null; @@ -580,19 +707,24 @@ function renderDetail(h) { ${escapeHtml(kindLabel(h.kind))} ${escapeHtml(h.severity)} ${repoLabel ? `${escapeHtml(repoLabel)}` : ''} - #${h.rank} + rank #${h.rank}

      ${escapeHtml(h.summary)}

      - ${symCount ? `

      Symbol count: ${escapeHtml(symCount)}

      ` : ''} + +
      +

      Why is this here?

      +

      ${escapeHtml(help.why || kindWhyLine(h.kind))}

      + ${help.limit ? `

      ${escapeHtml(help.limit)}

      ` : ''} + ${symCount ? `

      Symbol count in file: ${escapeHtml(symCount)} (ctags).

      ` : ''} +

      Tool: ${escapeHtml(h.producer)} · Evidence: ${escapeHtml(h.evidence_state)}

      Paths

      ${pathHtml}
      - Evidence & provenance + Raw provenance
      -

      State: ${escapeHtml(h.evidence_state)} · Producer: ${escapeHtml(h.producer)}

      id: ${escapeHtml(h.id)}

      producer_ref: ${escapeHtml(h.producer_ref || '')}

      @@ -625,6 +757,7 @@ function render() { selectedId = null; renderDetail(null); } + renderFilterExplainer(); renderBanner(); renderTour(hotspots); renderTree(hotspots); @@ -643,8 +776,9 @@ async function main() { ? `${targetShort} · ${manifest.hotspot_count} hotspots · ${manifest.gap_count} gaps` : 'no manifest'; - renderStatsRow(); - renderQuickFilters(); + renderKindGuide(); + renderViewBar(); + renderFilterExplainer(); renderFilters(); renderBanner(); diff --git a/viewer/src/index.html b/viewer/src/index.html index 6cf59273..8036dace 100644 --- a/viewer/src/index.html +++ b/viewer/src/index.html @@ -11,15 +11,26 @@

      Portolan Orient

      -

      Where to look first — evidence-backed, not LLM guesses

      +

      Local tool findings — not LLM architecture truth

      -
      -
      +

      + Each row is one hotspot: a file or pattern a local scanner flagged. + Pick a view below, then a row to see why it was included. +

      +
      + View +
      +
      +
      +
      + What counts as a hotspot? Tools & limits +
      +
      @@ -38,8 +49,8 @@

      Hotspots

      Detail

      -

      Pick a hotspot

      -

      Use quick filters above or search. Each item links to local source when a file path exists.

      +

      Select a hotspot

      +

      Choose a view (e.g. Tour · top 15), then click a row. The right panel explains why that tool flagged it and shows source when available.