From 9218129c32dfc5f1d52d9ae37053f20c9196b6f1 Mon Sep 17 00:00:00 2001 From: dsmcewan Date: Tue, 30 Jun 2026 11:51:18 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20forensic=20engine=20console=20?= =?UTF-8?q?=E2=80=94=20archival-exhibit=20visual=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the plain engine view as a polished read-only console that renders live corpus data in an archival-exhibit style (paper/ink/pen palette, mono typeface, stamp badges, box-shadow cards). Elevated findings get a data-driven convergence trace — a vertical spine with layer nodes and an anchor diamond, generated from f.signals in real time; no hardcoded content. Low-confidence findings render in muted chip/badge style. Body scrolls (no overflow:hidden). engine.css is fully self-contained; style.css is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S6sBnLRAKeX1vEQXecJB7g --- tests/test_engine_page_contract.py | 14 + web/site/engine.css | 429 +++++++++++++++++++++++++++++ web/site/engine.html | 57 +++- web/site/engine.js | 167 +++++++++-- 4 files changed, 634 insertions(+), 33 deletions(-) create mode 100644 web/site/engine.css diff --git a/tests/test_engine_page_contract.py b/tests/test_engine_page_contract.py index 571b4e3..3af7cd4 100644 --- a/tests/test_engine_page_contract.py +++ b/tests/test_engine_page_contract.py @@ -30,3 +30,17 @@ def test_lecture_links_to_engine_page(): def test_engine_js_ignores_curated_section(): js = read("web/site/engine.js") assert "curated" not in js # the plain view must not read the curated section + + +def test_engine_page_links_own_stylesheet(): + html = read("web/site/engine.html") + # engine console must link its own stylesheet, not the lecture's style.css + assert "engine.css" in html + + +def test_engine_js_has_badge_and_convergence_trace(): + js = read("web/site/engine.js") + # confidence badge is rendered for every finding + assert "badge" in js + # convergence trace is generated from signal data for elevated findings + assert "trace" in js diff --git a/web/site/engine.css b/web/site/engine.css new file mode 100644 index 0000000..e50c3c3 --- /dev/null +++ b/web/site/engine.css @@ -0,0 +1,429 @@ +/* engine.css — archival-exhibit forensic console, fully self-contained */ +:root { + --paper: #e6e8e1; + --paper-2: #dfe1d9; + --edge: #cfd3c8; + --ink: #18202c; + --ink-2: #3a4456; + --faint: #717a6b; + --pen: #b0392b; + --pen-dim: #8f3024; + --mono: "SF Mono", ui-monospace, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { scroll-behavior: smooth; } + +body { + background: var(--paper); + color: var(--ink); + font-family: var(--sans); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + background-image: radial-gradient(rgba(24, 32, 44, .035) 1px, transparent 1px); + background-size: 4px 4px; + /* scrolling console — no overflow:hidden */ +} + +:focus-visible { outline: 2px solid var(--pen); outline-offset: 3px; } + +.wrap { max-width: 1040px; margin: 0 auto; padding: 0 28px; } + +/* ---- masthead ---- */ +.masthead { + border-bottom: 1.5px solid var(--ink); + background: var(--paper); + position: sticky; + top: 0; + z-index: 10; +} +.masthead .wrap { + display: flex; + justify-content: space-between; + align-items: flex-end; + padding-top: 16px; + padding-bottom: 12px; + gap: 20px; + flex-wrap: wrap; +} +.brand { + font-family: var(--mono); + font-size: 13px; + letter-spacing: .04em; + display: flex; + align-items: baseline; + gap: 10px; +} +.brand b { font-weight: 600; } +.brand-sub { + color: var(--faint); + font-size: 11px; + letter-spacing: .12em; + text-transform: uppercase; +} +.masthead-nav { font-family: var(--mono); font-size: 12px; } +.nav-link { color: var(--faint); text-decoration: none; letter-spacing: .06em; } +.nav-link:hover { color: var(--pen); } + +/* ---- corpus picker ---- */ +.picker-section { + padding: 28px 0 0; + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} +.picker-label { + font-family: var(--mono); + font-size: 11px; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--faint); +} +#corpus-select { + font-family: var(--mono); + font-size: 13px; + color: var(--ink); + background: var(--paper-2); + border: 1.5px solid var(--ink); + padding: 6px 12px; + border-radius: 2px; + cursor: pointer; + appearance: auto; +} +#corpus-select:focus-visible { outline: 2px solid var(--pen); outline-offset: 3px; } + +/* ---- case stamp (summary stats) ---- */ +.case-stamp { + margin-top: 20px; + padding: 16px 0; + border-top: 1.5px solid var(--ink); + border-bottom: 1.5px solid var(--ink); +} +.stamp-corpus-label { + font-family: var(--mono); + font-size: 12px; + color: var(--ink-2); + margin-bottom: 14px; + letter-spacing: .04em; +} +.case-stamp-grid { + display: flex; + gap: 0; + flex-wrap: wrap; +} +.stat-cell { + padding: 8px 28px 8px 0; + margin-right: 28px; + border-right: 1px solid var(--edge); +} +.stat-cell:last-child { border-right: none; padding-right: 0; margin-right: 0; } +.stat-n { + font-family: var(--mono); + font-size: 28px; + font-weight: 500; + color: var(--ink); + line-height: 1; +} +.stat-n.elevated { color: var(--pen); } +.stat-label { + font-family: var(--mono); + font-size: 10px; + color: var(--faint); + letter-spacing: .1em; + text-transform: uppercase; + margin-top: 5px; +} + +/* ---- section heads ---- */ +.findings-section { margin-top: 32px; } +.section-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} +.eyebrow { + font-family: var(--mono); + font-size: 11px; + letter-spacing: .2em; + text-transform: uppercase; + color: var(--faint); +} + +/* ---- findings list ---- */ +.findings-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 22px; +} + +/* ---- exhibit card ---- */ +.exhibit-card { + background: var(--paper-2); + border: 1.5px solid var(--ink); + box-shadow: 6px 6px 0 rgba(24, 32, 44, .09); +} + +/* card header: badge + layer chips + seqs */ +.card-head { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 10px 16px; + border-bottom: 1.5px solid var(--ink); + font-family: var(--mono); + font-size: 11px; +} + +/* confidence badge */ +.badge { + font-size: 10px; + font-weight: 600; + letter-spacing: .1em; + text-transform: uppercase; + padding: 3px 9px; + border-radius: 2px; + border: 1.5px solid var(--pen); + white-space: nowrap; +} +.badge.elevated { + background: var(--pen); + color: #fff; + transform: rotate(-2deg); +} +.badge.low { + background: transparent; + color: var(--faint); + border-color: var(--edge); +} + +/* layer chips */ +.layer-chips { + display: flex; + gap: 5px; + flex-wrap: wrap; +} +.layer-chip { + font-size: 10px; + font-weight: 600; + color: var(--pen); + border: 1px solid var(--pen); + padding: 1px 7px; + border-radius: 2px; + letter-spacing: .06em; +} +.layer-chip.low-conf { + color: var(--faint); + border-color: var(--edge); +} + +.card-seqs { + color: var(--faint); + font-size: 11px; + letter-spacing: .04em; + margin-left: auto; +} + +/* ---- card body: signal rows ---- */ +.card-body { + position: relative; + padding: 8px 0; +} + +/* convergence trace overlay — positioned inside card-body */ +.trace-rail { + position: absolute; + left: 21px; + width: 2px; + background: var(--pen); + top: 0; + height: 0; + opacity: 0; + transition: opacity .3s, height .35s, top .35s; + z-index: 2; +} +.layer-node { + position: absolute; + left: 14px; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--pen); + background: var(--paper-2); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--mono); + font-size: 7px; + font-weight: 600; + color: var(--pen); + opacity: 0; + transform: scale(.4); + transition: opacity .3s, transform .3s; + z-index: 3; + pointer-events: none; + user-select: none; +} +.layer-node.anchor { + border-radius: 2px; + transform: rotate(45deg) scale(.4); + background: var(--pen); + color: #fff; +} + +/* tracing state on the card */ +.exhibit-card.tracing .trace-rail { + opacity: 1; +} +.exhibit-card.tracing .layer-node { + opacity: 1; + transform: scale(1); +} +.exhibit-card.tracing .layer-node.anchor { + transform: rotate(45deg) scale(1); +} +.exhibit-card.tracing .signal-row:not(.on) { + opacity: .3; +} +.exhibit-card.tracing .signal-row.on { + box-shadow: inset 3px 0 0 var(--pen); +} + +/* signal row layout */ +.signal-row { + display: grid; + grid-template-columns: 30px 1fr; + gap: 8px; + padding: 7px 16px 7px 46px; + font-size: 12.5px; + transition: opacity .25s, box-shadow .15s; +} +.signal-layer-id { + font-family: var(--mono); + font-size: 11px; + color: var(--pen); + font-weight: 600; + padding-top: 1px; +} +.signal-layer-id.low-conf { color: var(--faint); } +.signal-detail { + color: var(--ink-2); + font-family: var(--mono); + font-size: 12px; + line-height: 1.55; +} +.signal-meta { + display: block; + font-size: 11px; + color: var(--faint); + margin-top: 2px; +} + +/* convergence legend (revealed on trace) */ +.conv-legend { + overflow: hidden; + max-height: 0; + opacity: 0; + transition: max-height .35s, opacity .35s; + border-top: 0 solid var(--ink); + font-family: var(--mono); + font-size: 11.5px; + color: var(--ink-2); +} +.conv-legend.show { + max-height: 120px; + opacity: 1; + border-top: 1.5px solid var(--ink); +} +.conv-legend .in { + padding: 10px 16px; + line-height: 1.85; +} +.conv-legend .hl { color: var(--pen); font-weight: 600; } + +/* card summary text */ +.card-summary { + padding: 10px 16px; + border-top: 1px solid var(--edge); + font-size: 13px; + color: var(--ink-2); + line-height: 1.6; +} + +/* card footer */ +.card-foot { + border-top: 1.5px solid var(--ink); + padding: 8px 14px; + display: flex; + justify-content: space-between; + align-items: center; + font-family: var(--mono); + font-size: 11px; + color: var(--faint); +} +.trace-btn { + font-family: var(--mono); + font-size: 11px; + font-weight: 600; + letter-spacing: .07em; + color: var(--pen); + background: transparent; + border: 1.5px solid var(--pen); + padding: 4px 11px; + border-radius: 2px; + cursor: pointer; + transition: background .16s, color .16s; +} +.trace-btn:hover { background: var(--pen); color: #fff; } +.trace-btn[aria-pressed="true"] { background: var(--pen); color: #fff; } + +/* ---- narration panel ---- */ +.narration-section { margin-top: 40px; margin-bottom: 60px; } +.narration-block { + background: var(--paper-2); + border: 1.5px solid var(--ink); + padding: 20px; + font-family: var(--mono); + font-size: 12.5px; + color: var(--ink-2); + line-height: 1.75; + white-space: pre-wrap; + word-break: break-word; +} + +/* ---- site footer ---- */ +.site-footer { + border-top: 1.5px solid var(--ink); + padding: 24px 0 48px; + font-family: var(--mono); + font-size: 11.5px; + color: var(--faint); + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; +} +.site-footer a { color: var(--faint); text-decoration: none; } +.site-footer a:hover { color: var(--pen); } + +/* ---- responsive ---- */ +@media (max-width: 640px) { + .brand-sub { display: none; } + .card-head { gap: 8px; } + .card-seqs { margin-left: 0; width: 100%; } + .stat-cell { border-right: none; padding-right: 0; margin-right: 16px; } +} + +/* ---- reduced motion ---- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + transition: none !important; + animation: none !important; + scroll-behavior: auto !important; + } +} diff --git a/web/site/engine.html b/web/site/engine.html index 054b43d..8657615 100644 --- a/web/site/engine.html +++ b/web/site/engine.html @@ -3,23 +3,50 @@ - convergence — engine view - + convergence — engine console + - -
- convergence · engine view - lecture → -
-
- - -
-

Findings

-
    -

    Narration

    -
    
    +
    +  
    +
    +
    + convergence + engine console · forensic findings +
    + +
    +
    + +
    +
    + + +
    + +
    + +
    +
    + Findings +
    +
      +
      + +
      +
      + Engine narration +
      +
      
      +    
      + +
      + convergence · deterministic communication-forensics engine + lecture → +
      + diff --git a/web/site/engine.js b/web/site/engine.js index bcc4817..5692713 100644 --- a/web/site/engine.js +++ b/web/site/engine.js @@ -1,6 +1,6 @@ // Plain, read-only generic engine view. Loads the generic core of each corpus -// payload and renders findings with their layers, confidence, seqs, and evidence. -// It reads only the generic core fields; the lecture section is not used here. +// payload and renders findings as exhibit cards with convergence traces. +// Reads only generic core fields — the lecture section is not accessed here. const $ = (id) => document.getElementById(id); function escapeHtml(s) { @@ -14,30 +14,161 @@ async function loadJson(url) { return r.json(); } +// Build the convergence trace for an elevated finding card. +// Positions a vertical rail and layer nodes over signal rows, driven entirely +// by f.signals — no hardcoded content. +function buildTrace(card) { + const body = card.querySelector(".card-body"); + if (!body) return; + body.querySelectorAll(".trace-rail,.layer-node").forEach((el) => el.remove()); + + const rows = [...body.querySelectorAll(".signal-row[data-layer]")]; + if (rows.length < 2) return; + + const rail = document.createElement("span"); + rail.className = "trace-rail"; + rail.setAttribute("aria-hidden", "true"); + body.appendChild(rail); + + const sorted = rows.slice().sort((a, b) => a.offsetTop - b.offsetTop); + const centers = sorted.map((el) => el.offsetTop + el.offsetHeight / 2); + const top = centers[0]; + const bottom = centers[centers.length - 1]; + rail.style.top = top + "px"; + rail.style.height = bottom - top + "px"; + + sorted.forEach((row, i) => { + const node = document.createElement("span"); + const isAnchor = row.dataset.anchor === "true"; + node.className = "layer-node" + (isAnchor ? " anchor" : ""); + node.textContent = isAnchor ? "" : (row.dataset.layer || ""); + node.style.top = centers[i] - 8 + "px"; + node.setAttribute("aria-hidden", "true"); + body.appendChild(node); + }); +} + +function toggleTrace(card, btn, legend) { + const on = btn.getAttribute("aria-pressed") === "true"; + const next = !on; + btn.setAttribute("aria-pressed", String(next)); + btn.textContent = next ? "▾ hide trace" : "▸ trace convergence"; + card.classList.toggle("tracing", next); + if (legend) legend.classList.toggle("show", next); + card.querySelectorAll(".signal-row").forEach((r) => r.classList.toggle("on", next)); + if (next) buildTrace(card); +} + +// Determine the convergence anchor: the signal anchor seq most cited across signals. +// Driven entirely by real data — never hardcoded. +function resolveAnchorSeq(signals) { + if (!signals.length) return null; + const counts = {}; + for (const s of signals) { + counts[s.anchor] = (counts[s.anchor] || 0) + 1; + } + return Object.keys(counts).reduce( + (a, b) => (counts[b] > counts[a] ? b : a), + Object.keys(counts)[0] + ); +} + +function renderFindingCard(f, idx) { + const isElevated = f.confidence === "elevated"; + const anchorSeq = resolveAnchorSeq(f.signals); + const uniqueLayers = [...new Set(f.signals.map((s) => s.layer))]; + + // signal rows + const sigRows = f.signals + .map((s) => { + const isAnchorSig = isElevated && String(s.anchor) === String(anchorSeq); + return ( + `
      ` + + `${escapeHtml(s.layer)}` + + `` + + `${escapeHtml(s.kind)}` + + (s.detail ? ` — ${escapeHtml(s.detail)}` : "") + + `` + + `anchor seq ${escapeHtml(String(s.anchor))}` + + (s.thread ? ` · ${escapeHtml(s.thread)}` : "") + + (s.actor ? ` · ${escapeHtml(s.actor)}` : "") + + `
      ` + ); + }) + .join(""); + + // convergence legend (elevated only): lists the converging layers + const legendHtml = isElevated + ? `
      ` + + uniqueLayers.map((l) => `${escapeHtml(l)}`).join(" + ") + + (anchorSeq !== null + ? ` converge on anchor seq ${escapeHtml(String(anchorSeq))} —` + : " —") + + ` ${uniqueLayers.length} distinct layer${uniqueLayers.length !== 1 ? "s" : ""} → elevated` + + `
      ` + : ""; + + const seqStr = f.seqs.map((s) => escapeHtml(String(s))).join(", "); + + const footerContent = isElevated + ? `anchor seq ${escapeHtml(String(anchorSeq))} · ${uniqueLayers.length} layers` + + `` + : `seqs ${seqStr}`; + + const li = document.createElement("li"); + li.className = `exhibit-card finding ${escapeHtml(f.confidence)}`; + li.innerHTML = + `
      ` + + `${escapeHtml(f.confidence)}` + + `` + + f.layers.map((l) => `${escapeHtml(l)}`).join("") + + `` + + `seqs ${seqStr}` + + `
      ` + + `
      ${sigRows}
      ` + + legendHtml + + `

      ${escapeHtml(f.summary)}

      ` + + `
      ${footerContent}
      `; + + if (isElevated) { + const btn = li.querySelector(".trace-btn"); + const legend = li.querySelector(".conv-legend"); + if (btn) btn.addEventListener("click", () => toggleTrace(li, btn, legend)); + let resizeTimer; + window.addEventListener("resize", () => { + if (!li.classList.contains("tracing")) return; + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => buildTrace(li), 120); + }); + } + + return li; +} + function renderFindings(data) { const ol = $("findings"); ol.innerHTML = ""; - for (const f of data.findings) { - const li = document.createElement("li"); - li.className = `finding ${f.confidence}`; - const sigs = f.signals - .map((s) => `${s.layer} ${escapeHtml(s.kind)} — ${escapeHtml(s.detail)} (seq ${s.anchor}, ${escapeHtml(s.thread)})`) - .join("
      "); - li.innerHTML = - `
      ${f.confidence}` + - `${f.layers.join(" · ")}` + - `seqs ${f.seqs.join(", ")}
      ` + - `

      ${escapeHtml(f.summary)}

      ` + - `
      ${sigs}
      `; - ol.appendChild(li); + for (let i = 0; i < data.findings.length; i++) { + ol.appendChild(renderFindingCard(data.findings[i], i)); } } function renderCorpus(data) { const c = data.corpus; - $("corpus-summary").textContent = - `${c.label} · ${c.message_count} messages · ${c.finding_count} findings ` + - `(${c.elevated_count} elevated, ${c.low_count} low)`; + $("corpus-summary").innerHTML = + `

      ${escapeHtml(c.label)}

      ` + + `
      ` + + `
      ${escapeHtml(String(c.message_count))}
      ` + + `
      messages
      ` + + `
      ${escapeHtml(String(c.finding_count))}
      ` + + `
      findings
      ` + + `
      ${escapeHtml(String(c.elevated_count))}
      ` + + `
      elevated
      ` + + `
      ${escapeHtml(String(c.low_count))}
      ` + + `
      low
      ` + + `
      `; renderFindings(data); $("narration").textContent = data.narration.plain || ""; } From c7f964cac66c139c12cc20f28f1264bfb4224074 Mon Sep 17 00:00:00 2001 From: dsmcewan Date: Tue, 30 Jun 2026 11:58:17 -0400 Subject: [PATCH 2/2] fix: replace per-card resize listeners with single module-level handler; drop escapeHtml on className Fix 1: window resize listeners were registered once per elevated finding card and never removed, so switching corpora accumulated detached-DOM listeners. Replace with a single debounced (120 ms) module-level handler that queries all .finding.elevated.tracing cards from the live DOM on each resize. Fix 2: escapeHtml() was applied to f.confidence before assigning it to li.className. The browser never HTML-parses a className, so this was a cosmetic misuse. Assign the raw (controlled) value directly. All innerHTML usages of f.confidence retain escapeHtml. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S6sBnLRAKeX1vEQXecJB7g --- web/site/engine.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/web/site/engine.js b/web/site/engine.js index 5692713..3c57f88 100644 --- a/web/site/engine.js +++ b/web/site/engine.js @@ -118,7 +118,7 @@ function renderFindingCard(f, idx) { : `seqs ${seqStr}`; const li = document.createElement("li"); - li.className = `exhibit-card finding ${escapeHtml(f.confidence)}`; + li.className = `exhibit-card finding ${f.confidence}`; li.innerHTML = `
      ` + `${escapeHtml(f.confidence)}` + @@ -136,12 +136,6 @@ function renderFindingCard(f, idx) { const btn = li.querySelector(".trace-btn"); const legend = li.querySelector(".conv-legend"); if (btn) btn.addEventListener("click", () => toggleTrace(li, btn, legend)); - let resizeTimer; - window.addEventListener("resize", () => { - if (!li.classList.contains("tracing")) return; - clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => buildTrace(li), 120); - }); } return li; @@ -191,4 +185,15 @@ async function init() { await selectCorpus(index.default_corpus); } +// Single module-level resize handler: recomputes convergence-trace geometry for +// all currently-rendered elevated cards that are in tracing state. Registered +// once — no per-card listeners that accumulate across corpus switches. +let _resizeTimer; +window.addEventListener("resize", () => { + clearTimeout(_resizeTimer); + _resizeTimer = setTimeout(() => { + document.querySelectorAll(".finding.elevated.tracing").forEach((card) => buildTrace(card)); + }, 120); +}); + init();