💄 Console UI polish round

Merge the load form and files tree into one "Load graph from Penpot" box (tree first, uuid + Load/Unload in a row); Loaded session carries HH:MM in its legend; the Live changes box stays hidden until the first change arrives; query fieldset reads "Query graph (LadybugDB Cypher)" with the link covering both terms. Drop the hover tooltips (distracting, useless zoomed out) and the resident-size estimate (per-table counts stay on hover); every toggle gets a "When set/checked ..." title. Depth fold: 0 now expands every container (no more hunting for max depth). Node inspector: two-column flow, structured or long values folded behind the file-tree disclosure triangle.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
This commit is contained in:
Álvaro Tejero Cantero 2026-07-16 23:36:14 +02:00
parent 53420388d5
commit e011dedccf
No known key found for this signature in database

View File

@ -21,21 +21,21 @@ Graph Console
<div style="flex: 0 0 440px; min-width: 360px;">
<fieldset>
<legend>Load graph in memory</legend>
<legend>Load graph from Penpot</legend>
<desc>
Projects the Penpot file into an in-memory Ladybug database for this
admin session. Loading a new file replaces the previous one.
Click file or paste UUID to load Penpot file into an in-memory
Ladybug database. Loading a new file replaces the previous one.
</desc>
<div id="graph-files-tree" style="font-size: 13px; margin-bottom: 6px;">Loading&hellip;</div>
<form id="graph-load-form" method="post" action="/dbg/actions/graph-load">
<div class="row">
<input type="text" style="width:420px" name="file-id"
<div class="row" style="display: flex; gap: 8px;">
<input type="text" style="width:320px" name="file-id"
placeholder="file-id"
value="{% if session %}{{session.file-id}}{% endif %}" />
</div>
<div class="row" style="display: flex; gap: 8px;">
<input type="submit" value="Load" />
{% if session %}
<input type="submit" value="Unload" form="graph-unload-form" />
<input type="submit" value="Unload" form="graph-unload-form"
title="Drop the in-memory session and free its memory" />
{% endif %}
</div>
</form>
@ -44,15 +44,9 @@ Graph Console
{% endif %}
</fieldset>
<fieldset>
<legend>Files</legend>
<desc>Teams &rarr; projects &rarr; files. Click a file to load it.</desc>
<div id="graph-files-tree" style="font-size: 13px;">Loading&hellip;</div>
</fieldset>
{% if session %}
<fieldset>
<legend>Loaded session</legend>
<legend>Loaded session (<span id="graph-loaded-at">{{session.loaded-at}}</span>)</legend>
<desc>
<p>
File: <b><a id="graph-penpot-link" data-file-id="{{session.file-id}}"
@ -60,8 +54,7 @@ Graph Console
Loaded at revision: <b>{{session.revn}}</b><br />
Graph revision: <b id="graph-sync-revn">{% if session.graph-revn %}{{session.graph-revn}}{% else %}{{session.revn}}{% endif %}</b><br />
Graph size: <b id="graph-size">…</b><br />
Schema: <b>{{session.schema-version}}</b><br />
Loaded at: <b id="graph-loaded-at">{{session.loaded-at}}</b>
Schema: <b>{{session.schema-version}}</b>
</p>
<p id="graph-sync-status">
Feed: <b id="graph-ws-status">connecting…</b>
@ -73,16 +66,11 @@ Graph Console
</desc>
</fieldset>
<fieldset>
<legend>File changes (live)</legend>
<desc>
Subscribes to the workspace WebSocket feed for visibility. The backend
applies supported changes incrementally to the in-memory Ladybug graph
via msgbus (<code>:file-change</code>).
</desc>
<div id="graph-changelog-empty" style="color: #666;">Waiting for changes…</div>
<fieldset id="graph-changelog-box" style="display: none;">
<legend>Live changes</legend>
<desc>Latest changes applied to the backend graph.</desc>
<table id="graph-changelog" border="1" cellpadding="4" cellspacing="0"
style="border-collapse: collapse; width: 100%; display: none;">
style="border-collapse: collapse; width: 100%;">
<thead>
<tr>
<th>revn</th>
@ -94,8 +82,8 @@ Graph Console
</fieldset>
<fieldset>
<legend>LadybugDB <a href="https://docs.ladybugdb.com/cypher/"
target="_blank">Cypher</a></legend>
<legend>Query graph (<a href="https://docs.ladybugdb.com/cypher/"
target="_blank">LadybugDB Cypher</a>)</legend>
<form id="graph-query-form" method="post" action="/dbg/actions/graph-query">
<div class="row">
<textarea name="query" rows="8" style="width:100%; font-family: monospace;"
@ -150,23 +138,25 @@ Graph Console
style="flex: 1 1 auto; min-width: 0;">
<fieldset id="graph-view-panel">
<legend>Graph view
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;">
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;"
title="When checked, containers render as foldable boxes (combos); double-click folds/unfolds. When unchecked, plain nodes only — the fold controls to the right go dormant.">
<input type="checkbox" id="graph-fold-toggle" /> fold containers
</label>
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;">
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;"
title="When checked, graphs up to 100 nodes render with entrance animation.">
<input type="checkbox" id="graph-animate-toggle" /> animate
</label>
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;"
title="added/removed marks fade out over this many display steps (0 = off)">
title="When set, added/removed marks fade out over this many display steps (0 disables diff marks).">
fade: <input type="number" id="graph-diff-steps" min="0" max="20"
style="width: 3.5em;" /> steps
</label>
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;"
title="collapse every container that holds no changed elements">
title="When checked, every container without changed elements collapses, so changes stand out (overrides manual folds).">
<input type="checkbox" id="graph-diff-fold" /> fold unchanged
</label>
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;"
title="overview mode: collapse every container at or beyond this depth from the root (root = 0; empty = off)">
title="When set, overview mode: 0 expands every container, n ≥ 1 collapses every container at depth ≥ n from the root (overrides manual folds; empty = manual folding).">
fold ≥ depth: <input type="number" id="graph-depth-fold" min="0" max="99"
style="width: 3em;" />
</label>
@ -175,9 +165,8 @@ Graph Console
</label>
</legend>
<desc>
Live view of the in-memory Ladybug graph (AntV G6); the legend
shows what is displayed. Double-click folds containers when
folding is on.
Live view of the in-memory Ladybug graph (AntV G6). Double-click
folds containers when folding is on.
</desc>
<div id="graph-legend" style="font-size: 12px; margin: 4px 0;"></div>
<div id="graph-canvas"
@ -339,9 +328,8 @@ Graph Console
const wsStatus = document.getElementById("graph-ws-status");
const syncRevnEl = document.getElementById("graph-sync-revn");
const syncErrorEl = document.getElementById("graph-sync-error");
const changelog = document.getElementById("graph-changelog");
const changelogBox = document.getElementById("graph-changelog-box");
const changelogBody = document.getElementById("graph-changelog-body");
const changelogEmpty = document.getElementById("graph-changelog-empty");
let ws = null;
@ -607,7 +595,7 @@ Graph Console
const DEFAULT_LAYOUT = "tree";
// Always-on labels are the residual overlap driver on ring/stress
// layouts (their collision handling ignores label extents), so these
// get smaller labels; the hover tooltip carries full identity anywhere.
// get smaller labels; the node inspector carries full identity.
const DENSE_LABEL_LAYOUTS = {
"concentric": true, "radial": true, "d3-force": true
};
@ -703,9 +691,10 @@ Graph Console
});
}
// Derived fold state (overrides manual double-click folds while on):
// overview mode collapses every combo at depth ≥ the limit (all of
// them when only diff-fold is on), then diff-fold re-opens the
// ancestor paths of changed elements — combined, that reads as
// overview mode collapses every combo at depth ≥ the limit — except
// 0, which expands everything (saves hunting for the max depth) —
// and all of them when only diff-fold is on; diff-fold then re-opens
// the ancestor paths of changed elements — combined, that reads as
// "overview, with changes drilled open".
const depthLimit = depthFoldValue();
if (withCombos && (diffFold || depthLimit != null)) {
@ -718,6 +707,7 @@ Graph Console
};
collapsedIds = new Set();
Object.keys(hasCombo).forEach(function (id) {
if (depthLimit === 0) return;
if (depthLimit == null || depth(id) >= depthLimit) {
collapsedIds.add(comboIdFor(id));
}
@ -928,17 +918,6 @@ Graph Console
exportGraphPng();
}
}
}, {
key: "tooltip",
type: "tooltip",
getContent: function (_evt, items) {
return Promise.resolve(items.map(function (it) {
const d = it.data || {};
return "<div><b>" + escapeHtml(d.table || "") + "</b> "
+ escapeHtml(d.label || "") + "<br/><code>"
+ escapeHtml(String(it.id)) + "</code></div>";
}).join(""));
}
}]
};
if (layoutCfg) opts.layout = layoutCfg;
@ -1091,22 +1070,19 @@ Graph Console
})
.then(function (data) {
// Session-fieldset size line stays live even through skipped
// repaints (unlike the status line under the canvas). The MiB
// figure is a resident-size estimate fitted to the measurements
// in graph_sizes.md: ≈1.1 MiB session floor + ≈5.4 KiB per node
// (full-schema projection); hover shows per-table counts.
// repaints (unlike the status line under the canvas); hover
// shows per-table counts.
const sizeEl = document.getElementById("graph-size");
if (sizeEl) {
const mib = 1.1 + data.nodes.length * 5.4 / 1024;
sizeEl.textContent = data.nodes.length + " nodes, "
+ data.edges.length + " edges (≈" + mib.toFixed(1) + " MiB)";
+ data.edges.length + " edges";
const counts = {};
data.nodes.forEach(function (n) {
counts[n.table] = (counts[n.table] || 0) + 1;
});
sizeEl.title = Object.keys(counts).sort().map(function (t) {
return t + "=" + counts[t];
}).join(", ") + " — MiB ≈ 1.1 + 5.4 KiB × nodes (measured fit)";
}).join(", ");
}
// Skip the repaint when the display projection is unchanged: a
// change burst that only touches non-projected attrs (e.g. moving
@ -1174,8 +1150,9 @@ Graph Console
} else {
const columns = result.columns || [];
let hidden = 0;
html += '<table border="1" cellpadding="2" cellspacing="0"'
+ ' style="border-collapse: collapse; margin-top: 4px;">';
// Two-column flow; JSON-like values fold behind the same
// disclosure triangle the file tree uses.
html += '<div style="column-count: 2; column-gap: 16px; margin-top: 4px;">';
columns.forEach(function (col, i) {
const val = rows[0][i];
// Ladybug's value->clj fallback renders SQL nulls as "NULL".
@ -1183,11 +1160,21 @@ Graph Console
hidden += 1;
return;
}
html += "<tr><td><code>" + escapeHtml(col.replace(/^n\./, ""))
+ "</code></td><td><code>" + escapeHtml(String(val))
+ "</code></td></tr>";
const key = escapeHtml(col.replace(/^n\./, ""));
const sval = String(val);
// Fold structured values ({…}) and walls of text (long arrays
// like migrations) behind a disclosure triangle.
if (sval.indexOf("{") !== -1 || sval.length > 120) {
html += '<details style="break-inside: avoid;">'
+ '<summary style="cursor: pointer;"><code>' + key + '</code></summary>'
+ '<pre style="white-space: pre-wrap; word-break: break-all; margin: 2px 0 4px 1.2em;">'
+ escapeHtml(sval) + "</pre></details>";
} else {
html += '<div style="break-inside: avoid;"><code>' + key
+ '</code>: <code>' + escapeHtml(sval) + "</code></div>";
}
});
html += "</table>";
html += "</div>";
if (hidden) {
html += '<div style="color: #666;">' + hidden + " empty attrs hidden</div>";
}
@ -1304,8 +1291,9 @@ Graph Console
}
function appendChange(revn, summary) {
changelogEmpty.style.display = "none";
changelog.style.display = "table";
// The whole box stays hidden until the first change arrives; feed
// state lives in the session fieldset.
changelogBox.style.display = "block";
const row = document.createElement("tr");
const revnCell = document.createElement("td");