diff --git a/plugins/fluencyloop/site/diagram-renderer.js b/plugins/fluencyloop/site/diagram-renderer.js index c2db0d9..a0459cf 100644 --- a/plugins/fluencyloop/site/diagram-renderer.js +++ b/plugins/fluencyloop/site/diagram-renderer.js @@ -96,14 +96,19 @@ const CARD_H = 104; // that is merely shorter looks like an unfinished white panel, even when its SVG is technically // valid. const HEIGHT = 528; +// Keep a small, stable reading key inside the fixed iframe. Layouts use the remaining canvas so +// the key never becomes an afterthought below a cropped or scrollable diagram. +const KEY_H = 52; +const CONTENT_BOTTOM = HEIGHT - KEY_H; function card(node, box, shared = false) { node.box = box; const klass = shared ? 'card shared' : 'card'; + const compact = box.w < 180 ? ' compact' : ''; const nameY = box.y + box.h / 2 - 8; return ` - ${escapeHtml(node.label)} - ${escapeHtml(node.detail)}`; + ${escapeHtml(node.label)} + ${escapeHtml(node.detail)}`; } function edgeLabel(edge, x, y) { @@ -113,6 +118,17 @@ function edgeLabel(edge, x, y) { ${escapeHtml(edge.label)}`; } +function readingKey() { + const ruleY = HEIGHT - KEY_H + 6; + const baseline = HEIGHT - 16; + return ` + READING KEY + + arrow: directed relationship + + accent border: focal boundary`; +} + function linearLayout() { const n = graph.nodes.length; const width = Math.min(CARD_W, Math.floor((WIDTH - 96 - (n - 1) * 24) / n)); @@ -141,6 +157,9 @@ function mergeLayout() { || graph.nodes.some((node) => node !== hub && outgoing.get(node.id).length !== 1)) { fail('merge layout needs one directed path from every participant into --hub.'); } + if (graph.edges.some((edge) => !edge.label)) { + fail('merge layout requires --edge-label after every --edge so each converging relationship is clear.'); + } const depth = new Map([[hub.id, 0]]); const resolving = new Set(); const resolveDepth = (node) => { @@ -157,6 +176,10 @@ function mergeLayout() { graph.nodes.forEach(resolveDepth); const maxDepth = Math.max(...depth.values()); if (maxDepth > 3) fail('merge layout supports paths up to four cards deep; use a concise overview.'); + // Four columns still need visible lanes for arrows and labels. Compact only as much as this + // bounded topology needs, rather than letting full-width cards consume every inter-card gap. + const mergeCardW = Math.min(CARD_W, Math.floor((WIDTH - 96 - maxDepth * 64) / (maxDepth + 1))); + if (mergeCardW < 150) fail('merge layout cannot give this graph readable lanes; use a concise overview.'); const leaves = graph.nodes.filter((node) => !incoming.get(node.id).length); const y = new Map(); // Leave a deliberate title band above and a quiet breathing band below the graph. The embedded @@ -170,10 +193,10 @@ function mergeLayout() { return value; }; graph.nodes.forEach(resolveY); - const colGap = (WIDTH - 96 - CARD_W) / maxDepth; + const colGap = (WIDTH - 96 - mergeCardW) / maxDepth; graph.nodes.forEach((node) => { const centerY = resolveY(node); - node.box = { x: Math.round(48 + (maxDepth - depth.get(node.id)) * colGap), y: centerY - CARD_H / 2, w: CARD_W, h: CARD_H }; + node.box = { x: Math.round(48 + (maxDepth - depth.get(node.id)) * colGap), y: centerY - CARD_H / 2, w: mergeCardW, h: CARD_H }; }); const inputPorts = new Map(); graph.nodes.forEach((node) => { @@ -208,9 +231,9 @@ function hubLayout() { // Every leaf gets its own horizontal port. No elbows means no crossing, shared attachment // point, or marker that can render inside a non-endpoint card. const LEAF_H = 80; - hub.box = { x: 380, y: 96, w: CARD_W, h: 380 }; + hub.box = { x: 380, y: 88, w: CARD_W, h: 360 }; const spaced = (items, x) => items.forEach((node, index) => { - const center = Math.round(136 + index * (300 / Math.max(1, items.length - 1))); + const center = Math.round(132 + index * (268 / Math.max(1, items.length - 1))); node.box = { x, y: center - LEAF_H / 2, w: CARD_W, h: LEAF_H }; }); spaced(left, 48); spaced(right, 712); @@ -248,7 +271,7 @@ function layeredLayout() { const groups = Array.from({ length: maxRank + 1 }, () => []); graph.nodes.forEach((node) => groups[ranks.get(node.id)].push(node)); groups.forEach((group, rank) => group.forEach((node, index) => { - const y = Math.round(126 + index * ((HEIGHT - 126 - CARD_H) / Math.max(1, group.length - 1))); + const y = Math.round(126 + index * ((CONTENT_BOTTOM - 126 - CARD_H) / Math.max(1, group.length - 1))); const x = Math.round(48 + rank * ((WIDTH - 96 - CARD_W) / Math.max(1, maxRank))); node.box = { x, y, w: CARD_W, h: CARD_H }; })); @@ -284,8 +307,8 @@ const html = ` -${escapeHtml(graph.title)}ARCHITECTURE${escapeHtml(graph.title)}${rendered.paths.join('')}${rendered.cards.join('')}`; +html,body{width:100%;height:${HEIGHT}px;margin:0;overflow:hidden;background:var(--diagram-canvas)}svg{display:block;width:100%;height:${HEIGHT}px;font-family:system-ui,-apple-system,"Segoe UI",sans-serif}.eyebrow{fill:var(--diagram-muted);font-size:12px;font-weight:700;letter-spacing:.12em}.title{fill:var(--diagram-ink);font-size:24px;font-weight:700}.card{fill:var(--diagram-surface);stroke:var(--diagram-rule);stroke-width:2}.shared{stroke:var(--diagram-accent);stroke-width:3}.name{fill:var(--diagram-ink);font-size:17px;font-weight:700}.name.compact{font-size:15px}.detail{fill:var(--diagram-muted);font-size:13px}.detail.compact{font-size:12px}.flow{fill:none;stroke:var(--diagram-accent);stroke-width:3;stroke-linejoin:round;stroke-linecap:round;marker-end:url(#arrow)}.edge-label-bg{fill:var(--diagram-canvas);stroke:var(--diagram-rule);stroke-width:1}.edge-label{fill:var(--diagram-muted);font-size:11px;font-weight:700;letter-spacing:.04em}.key-rule{stroke:var(--diagram-rule);stroke-width:1}.key-heading{fill:var(--diagram-muted);font-size:10px;font-weight:700;letter-spacing:.12em}.key-flow{fill:none;stroke:var(--diagram-accent);stroke-width:2;marker-end:url(#arrow)}.key-focus{fill:var(--diagram-surface);stroke:var(--diagram-accent);stroke-width:2}.key-text{fill:var(--diagram-muted);font-size:11px} +${escapeHtml(graph.title)}ARCHITECTURE${escapeHtml(graph.title)}${rendered.paths.join('')}${rendered.cards.join('')}${readingKey()}`; fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, html, 'utf8'); diff --git a/tests/diagram-renderer.bats b/tests/diagram-renderer.bats index c68b9ab..98a8ffd 100644 --- a/tests/diagram-renderer.bats +++ b/tests/diagram-renderer.bats @@ -20,6 +20,8 @@ load test_helper [ -s "$diagram" ] grep -q 'height:528px' "$diagram" grep -q 'overflow:hidden' "$diagram" + grep -q 'READING KEY' "$diagram" + grep -q 'accent border: focal boundary' "$diagram" [ "$(grep -o '/dev/null 2>&1 || skip "Node.js is required for the diagram renderer" + setup_initialized_repo + run bash "$DIST/fluencyloop" diagram \ + --output docs/fluencyloop/diagrams/product-overview.html --layout merge --title "Architecture drift check" --hub tools \ + --node reader --label "Store reader" --detail "Parses stored records" \ + --node resolver --label "Record resolver" --detail "Builds current state" \ + --node scanner --label "Reality scanner" --detail "Reads repository facts" \ + --node drift --label "Drift engine" --detail "Compares both views" \ + --node tools --label "MCP tools" --detail "Expose the findings" \ + --edge reader resolver --edge-label "records" \ + --edge resolver drift --edge-label "current state" \ + --edge scanner drift --edge-label "repository facts" \ + --edge drift tools --edge-label "drift findings" + [ "$status" -eq 0 ] + diagram="$TESTREPO/docs/fluencyloop/diagrams/product-overview.html" + grep -q 'width="168"' "$diagram" + grep -q 'class="name compact"' "$diagram" + grep -q 'drift findings' "$diagram" +} + +@test "rejects an unlabeled merge instead of producing an unexplained convergence" { + command -v node >/dev/null 2>&1 || skip "Node.js is required for the diagram renderer" + setup_initialized_repo + run bash "$DIST/fluencyloop" diagram \ + --output docs/fluencyloop/diagrams/product-overview.html --layout merge --title "Missing labels" --hub sink \ + --node source --label "Source" --detail "Provides input" \ + --node sink --label "Sink" --detail "Combines input" \ + --edge source sink + [ "$status" -ne 0 ] + [[ "$output" == *"requires --edge-label"* ]] +} + @test "rejects a layered fan-out instead of producing overlapping routes" { command -v node >/dev/null 2>&1 || skip "Node.js is required for the diagram renderer" setup_initialized_repo