diff --git a/dashboard/views/render.ts b/dashboard/views/render.ts
index 461cb06..b19b9ed 100644
--- a/dashboard/views/render.ts
+++ b/dashboard/views/render.ts
@@ -278,6 +278,37 @@ function layout(
.v2-kv-row .v2-kv-v.na { color: #555; text-transform: none; }
.v2-kv-row .v2-kv-v.default { color: #c8c8c8; }
+ /* Expandable KV row — click to reveal a preview line. Toggled by JS
+ flipping .open on the row, which swaps caret glyph + shows preview. */
+ .v2-xkv { border-bottom: 1px solid #141414; font-size: 12px; }
+ .v2-xkv-head {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 6px 0; cursor: pointer; user-select: none;
+ }
+ .v2-xkv-head:hover { color: #c8c8c8; }
+ .v2-xkv-k { color: #888; display: flex; align-items: center; gap: 8px; }
+ .v2-xkv-caret {
+ color: #39ff14; display: inline-block; width: 8px;
+ font-family: var(--font-mono); font-size: 10px;
+ }
+ .v2-xkv-caret::before { content: "\u25B8"; }
+ .v2-xkv.open .v2-xkv-caret::before { content: "\u25BE"; }
+ .v2-xkv-v {
+ font-variant-numeric: tabular-nums; text-transform: uppercase;
+ color: #c8c8c8;
+ }
+ .v2-xkv-v.pass { color: #39ff14; }
+ .v2-xkv-v.partial { color: #ffff00; }
+ .v2-xkv-v.fail { color: #ff0040; }
+ .v2-xkv-v.na { color: #555; text-transform: none; }
+ .v2-xkv-preview {
+ display: none;
+ padding: 4px 0 10px 20px; font-size: 11px; color: #666;
+ line-height: 1.6; word-break: break-word;
+ }
+ .v2-xkv.open .v2-xkv-preview { display: block; }
+ .v2-xkv-empty { color: #444; font-style: italic; }
+
.v2-artifacts-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0 22px; }
.v2-empty-note {
@@ -1019,6 +1050,14 @@ export function renderDashboard(
});
});
})();
+ // Expandable KV rows: clicking the row toggles the .open class,
+ // which flips the ▸ to ▾ and reveals the preview line underneath.
+ document.addEventListener('click', function(e) {
+ var head = e.target.closest('.v2-xkv-head');
+ if (!head) return;
+ var row = head.parentElement;
+ if (row) row.classList.toggle('open');
+ });
`;
// v2 is full-bleed — it has its own topbar, so we skip layout()'s v1
@@ -1048,6 +1087,25 @@ function v2Kv(k: string, v: string, status?: string): string {
return `
${esc(k)}${v}
`;
}
+/**
+ * Expandable KV row — clicking toggles a preview line that shows the
+ * actual items behind the count (file paths, endpoint URLs, cookie
+ * names, etc.). Preview is already escaped; caller passes raw strings.
+ */
+function v2Xkv(k: string, v: string, previewItems: string[], status?: string): string {
+ const cls = status ? ` ${status}` : "";
+ const preview = previewItems.length === 0
+ ? `none`
+ : esc(previewItems.join(", "));
+ return `
+
+ ${esc(k)}
+ ${v}
+
+
${preview}
+
`;
+}
+
function v2Panel(title: string, body: string, opts: { count?: number; span3?: boolean } = {}): string {
const countHtml = opts.count !== undefined ? `· ${opts.count}` : "";
return `
@@ -1108,15 +1166,23 @@ export function renderRepoDetail(
// --- panels ---
+ const forms = dc.filter(d => d.source === "form" || d.source === "web-form");
+ const apis = dc.filter(d => d.source.startsWith("POST") || d.source.startsWith("GET") || d.source === "api-input");
+ const cookies = dc.filter(d => d.type === "cookie");
+ const trackers = dc.filter(d => d.type === "tracking");
+ const uniq = (xs: string[]) => Array.from(new Set(xs.filter(Boolean)));
const dataBody = dc.length === 0
? `No data collection detected.
`
: [
- v2Kv("Forms", String(dc.filter(d => d.source === "form" || d.source === "web-form").length)),
- v2Kv("API endpoints", String(dc.filter(d => d.source.startsWith("POST") || d.source === "api-input").length)),
- v2Kv("Cookies", String(dc.filter(d => d.type === "cookie").length)),
- v2Kv("Trackers", String(dc.filter(d => d.type === "tracking").length)),
+ v2Xkv("Forms", String(forms.length), uniq(forms.map(d => d.location))),
+ v2Xkv("API endpoints", String(apis.length), uniq(apis.map(d => d.source.startsWith("POST") || d.source.startsWith("GET") ? d.source : d.location))),
+ v2Xkv("Cookies", String(cookies.length), uniq(cookies.flatMap(d => d.fields))),
+ v2Xkv("Trackers", String(trackers.length), uniq(trackers.map(d => d.processor || d.fields.join("/")))),
].join("");
+ const headerItems = h
+ ? Object.entries(h).map(([k, v]) => `${k} ${v === "present" ? "\u2713" : v === "partial" ? "~" : "\u2717"}`)
+ : [];
const transportBody = !h && !manifest.https
? `No live site URL configured.
`
: [
@@ -1127,7 +1193,8 @@ export function renderRepoDetail(
? v2Kv("Cert expiry", manifest.https.certExpiry)
: "",
h
- ? v2Kv("Headers", `${summary.headersPresent}/${summary.headersTotal}`, summary.headersPresent === summary.headersTotal ? "pass" : summary.headersPresent >= 3 ? "partial" : "fail")
+ ? v2Xkv("Headers", `${summary.headersPresent}/${summary.headersTotal}`, headerItems,
+ summary.headersPresent === summary.headersTotal ? "pass" : summary.headersPresent >= 3 ? "partial" : "fail")
: v2Kv("Headers", "not checked", "na"),
].join("");
@@ -1155,12 +1222,20 @@ export function renderRepoDetail(
: ai.slice(0, 4).map(s => {
const tier = s.riskTier ?? "unknown";
const status = tier === "high" || tier === "prohibited" ? "fail" : tier === "limited" ? "partial" : "pass";
- return v2Kv(`${s.provider} · ${s.sdk}`, tier.toUpperCase(), status);
+ const locations = (s as any).usageLocations ?? [];
+ const preview = locations.length > 0 ? locations : [s.location];
+ return v2Xkv(`${s.provider} · ${s.sdk}`, tier.toUpperCase(), preview, status);
}).join("") + (aiCount > 4 ? `+${aiCount - 4} more — see AI tab
` : "");
const tpBody = tp.length === 0
? `No external services detected.
`
- : tp.slice(0, 6).map(s => v2Kv(s.name, s.dpaUrl ? "DPA \u2713" : "no DPA", s.dpaUrl ? "pass" : "partial")).join("");
+ : tp.slice(0, 6).map(s => {
+ const preview: string[] = [];
+ if (s.purpose) preview.push(`purpose: ${s.purpose}`);
+ if (s.dataShared?.length > 0) preview.push(`shares: ${s.dataShared.join(", ")}`);
+ if (s.dpaUrl) preview.push(`DPA: ${s.dpaUrl}`);
+ return v2Xkv(s.name, s.dpaUrl ? "DPA \u2713" : "NO DPA", preview, s.dpaUrl ? "pass" : "partial");
+ }).join("");
// Governance artifacts: IN REPO vs SERVED (v1 logic kept intact, rendered in v2 panel shell)
const artifactRows: string[] = [];