Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions plugins/fluencyloop/site/diagram-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<rect x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" rx="10" class="${klass}"/>
<text x="${box.x + 20}" y="${nameY}" class="name">${escapeHtml(node.label)}</text>
<text x="${box.x + 20}" y="${nameY + 28}" class="detail">${escapeHtml(node.detail)}</text>`;
<text x="${box.x + 20}" y="${nameY}" class="name${compact}">${escapeHtml(node.label)}</text>
<text x="${box.x + 20}" y="${nameY + 28}" class="detail${compact}">${escapeHtml(node.detail)}</text>`;
}

function edgeLabel(edge, x, y) {
Expand All @@ -113,6 +118,17 @@ function edgeLabel(edge, x, y) {
<text x="${Math.round(x)}" y="${Math.round(y - 3)}" class="edge-label" text-anchor="middle">${escapeHtml(edge.label)}</text>`;
}

function readingKey() {
const ruleY = HEIGHT - KEY_H + 6;
const baseline = HEIGHT - 16;
return `<line x1="48" y1="${ruleY}" x2="912" y2="${ruleY}" class="key-rule"/>
<text x="48" y="${baseline}" class="key-heading">READING KEY</text>
<line x1="154" y1="${baseline - 5}" x2="186" y2="${baseline - 5}" class="key-flow"/>
<text x="198" y="${baseline}" class="key-text">arrow: directed relationship</text>
<rect x="454" y="${baseline - 15}" width="14" height="14" rx="3" class="key-focus"/>
<text x="480" y="${baseline}" class="key-text">accent border: focal boundary</text>`;
}

function linearLayout() {
const n = graph.nodes.length;
const width = Math.min(CARD_W, Math.floor((WIDTH - 96 - (n - 1) * 24) / n));
Expand Down Expand Up @@ -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) => {
Expand All @@ -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
Expand All @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 };
}));
Expand Down Expand Up @@ -284,8 +307,8 @@ const html = `<!doctype html>
<style>
:root{color-scheme:light;--diagram-canvas:#f7f4ee;--diagram-surface:#fffdfa;--diagram-ink:#202630;--diagram-muted:#66717d;--diagram-rule:#d7d1c7;--diagram-accent:#d85e40}
:root[data-fluencyloop-theme="dark"]{color-scheme:dark;--diagram-canvas:#171c23;--diagram-surface:#222934;--diagram-ink:#edf1f5;--diagram-muted:#aab4c0;--diagram-rule:#4a5663;--diagram-accent:#ff8d6d}
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}.detail{fill:var(--diagram-muted);font-size:13px}.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}</style>
</head><body><svg viewBox="0 0 ${WIDTH} ${HEIGHT}" role="img" aria-labelledby="diagram-title"><title id="diagram-title">${escapeHtml(graph.title)}</title><defs><marker id="arrow" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="var(--diagram-accent)"/></marker></defs><text x="48" y="48" class="eyebrow">ARCHITECTURE</text><text x="48" y="84" class="title">${escapeHtml(graph.title)}</text>${rendered.paths.join('')}${rendered.cards.join('')}</svg></body></html>`;
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}</style>
</head><body><svg viewBox="0 0 ${WIDTH} ${HEIGHT}" role="img" aria-labelledby="diagram-title"><title id="diagram-title">${escapeHtml(graph.title)}</title><defs><marker id="arrow" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="var(--diagram-accent)"/></marker></defs><text x="48" y="48" class="eyebrow">ARCHITECTURE</text><text x="48" y="84" class="title">${escapeHtml(graph.title)}</text>${rendered.paths.join('')}${rendered.cards.join('')}${readingKey()}</svg></body></html>`;

fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, html, 'utf8');
Expand Down
35 changes: 35 additions & 0 deletions tests/diagram-renderer.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<path d="M' "$diagram" | wc -l | tr -d ' ')" -eq 6 ]
! grep -q '<iframe\|<script\|https://' "$diagram"
}
Expand All @@ -45,6 +47,39 @@ load test_helper
[ "$(grep -o '<path d="M' "$diagram" | wc -l | tr -d ' ')" -eq 4 ]
}

@test "gives a five-node merge readable lanes without full-width cards" {
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 "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
Expand Down
Loading