feat(sidebar): node-preview fidelity — read nodeData, 5-col grid, widget pills (#78) (#82)

Two changes that ship together because the visual rework was masking the
real bug:

1. `readSlots` (and the title/category/description reads in
   `renderPreviewCard`) were looking at static `nodeClass.INPUT_TYPES` /
   `RETURN_TYPES` properties, which **don't exist** on
   ComfyUI-registered nodes — ComfyUI parks the original V1 def at
   `nodeClass.nodeData` (set by `litegraphService.ts`'s
   `registerNodeDef` right before `LiteGraph.registerNodeType`). Every
   card was rendering as just header-and-badge because the slot arrays
   came back empty for every ComfyUI node. Reading from `nodeData`
   first (with the old property names as a legacy fallback for any
   non-ComfyUI registered nodes) fixes the empty-card class.

2. Visual rework to look like a real node mock instead of a generic
   info card. Mirrors upstream NodePreview.vue more faithfully: flat
   header row (small colored dot + display name) over a red PREVIEW
   badge; slot rows in a 5-column grid (`[dot] [input-name] [spacer]
   [output-name] [dot]`) so inputs and outputs line up horizontally as
   parallel sockets; widgets rendered as individual rounded pills with
   `◀ name [spacer] value ▶` chrome to mimic LiteGraph widget UI.

Closes #78

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-05-05 23:16:42 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent d021e8c8da
commit 75b7e76f11
3 changed files with 242 additions and 123 deletions
+21
View File
@@ -144,6 +144,27 @@ The format is inspired by Keep a Changelog and SemVer.
[np-vue]: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/node/NodePreview.vue
### Changed
- **Hover preview rebuilt to read like a real node mock.** The original
layout (HSL-hashed title strip + two-column slot table + flat widget
text list) read as a generic info card. Reworked to mirror upstream
`NodePreview.vue` more faithfully: a flat header row (small colored
dot + node display name) over a red **PREVIEW** badge, slot rows
laid out as a 5-column grid (`[dot] [input-name] [spacer] [output-name] [dot]`)
so inputs and outputs line up horizontally as parallel sockets, and
widgets rendered as individual rounded pills with `◀ name [spacer]
value ▶` chrome to mimic LiteGraph widget UI. **Bigger fix
underneath:** `readSlots` and the title / category / description
reads were looking at static `nodeClass.INPUT_TYPES` /
`RETURN_TYPES` properties, which **don't exist** on
ComfyUI-registered nodes — ComfyUI parks the original V1 def at
`nodeClass.nodeData` (set by `litegraphService.ts`'s
`registerNodeDef` right before `LiteGraph.registerNodeType`). Every
card was rendering as just-the-header-and-badge because the slot
arrays came back empty. Reading from `nodeData` first (with the old
property names as a legacy fallback for non-ComfyUI registered
nodes) fixes the empty-card class. (#78)
### Fixed
- **`pinExpanded` paths now expand on the immediate render** instead of
being delayed by one. `buildFolder`'s expansion-resolution chain gained
+20 -19
View File
@@ -181,25 +181,26 @@ export function ensureStyle() {
.koolook-mode-toggle-btn.koolook-mode-active { background: rgba(80,140,235,0.25); opacity: 1; }
.koolook-leaf-unresolved { opacity: 0.55; font-style: italic; }
.koolook-leaf-crumb { opacity: 0.5; font-size: 11px; margin-right: 1px; }
.koolook-preview-card { position: fixed; z-index: 10000; background: var(--comfy-menu-bg, #2a2a2a); border: 1px solid var(--border-color, rgba(255,255,255,0.18)); border-radius: 6px; box-shadow: 0 6px 24px rgba(0,0,0,0.55); color: var(--input-text, #ddd); font-size: 12px; width: 320px; max-width: 90vw; max-height: calc(100vh - 16px); pointer-events: none; overflow: hidden auto; }
.koolook-preview-title { padding: 6px 10px; font-size: 13px; font-weight: 600; color: #fff; }
.koolook-preview-title-unresolved { background: rgba(180,80,80,0.4); }
.koolook-preview-cat { padding: 3px 10px 5px; font-size: 10px; opacity: 0.6; border-bottom: 1px solid rgba(255,255,255,0.05); font-family: monospace; }
.koolook-preview-stub { padding: 10px; font-style: italic; opacity: 0.7; }
.koolook-preview-io { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: rgba(255,255,255,0.06); }
.koolook-preview-col { padding: 7px 9px; background: var(--comfy-menu-bg, #2a2a2a); }
.koolook-preview-section-title { font-size: 10px; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 4px; }
.koolook-preview-slot-row { display: flex; align-items: center; gap: 5px; padding: 1px 0; line-height: 1.35; }
.koolook-preview-slot-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; box-shadow: 0 0 0 1px rgba(0,0,0,0.4); }
.koolook-preview-slot-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.koolook-preview-slot-name.koolook-preview-slot-optional { opacity: 0.7; font-style: italic; }
.koolook-preview-slot-type { font-size: 10px; opacity: 0.5; flex-shrink: 0; font-family: monospace; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.koolook-preview-empty { opacity: 0.4; font-size: 11px; padding: 1px 0; }
.koolook-preview-widgets { padding: 6px 9px; border-top: 1px solid rgba(255,255,255,0.06); }
.koolook-preview-widget-row { display: flex; gap: 6px; padding: 1px 0; line-height: 1.35; }
.koolook-preview-widget-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.koolook-preview-widget-default { opacity: 0.5; font-size: 10px; font-family: monospace; max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0; }
.koolook-preview-desc { padding: 6px 9px; border-top: 1px solid rgba(255,255,255,0.06); font-size: 11px; opacity: 0.78; line-height: 1.4; max-height: 100px; overflow: hidden; }
.koolook-preview-card { position: fixed; z-index: 10000; background: var(--comfy-menu-bg, #232323); border: 1px solid var(--border-color, rgba(255,255,255,0.22)); border-radius: 12px; box-shadow: 0 6px 24px rgba(0,0,0,0.55); color: var(--input-text, #ddd); font-size: 12px; min-width: 300px; max-width: 90vw; max-height: calc(100vh - 16px); pointer-events: none; overflow: hidden auto; padding-bottom: 10px; }
.koolook-preview-header { display: flex; align-items: center; gap: 9px; padding: 8px 13px 7px; font-size: 14px; line-height: 1; white-space: nowrap; overflow: hidden; }
.koolook-preview-headtitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
.koolook-preview-header-unresolved .koolook-preview-headtitle { font-style: italic; opacity: 0.75; }
.koolook-preview-headdot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; box-shadow: 0 0 0 1px rgba(0,0,0,0.4); }
.koolook-preview-badge { padding: 0 13px 7px; color: #ff5f6d; font-size: 11px; letter-spacing: 0.08em; font-weight: 600; text-transform: uppercase; }
.koolook-preview-stub { margin: 4px 10px 6px; padding: 8px 10px; background: rgba(255,255,255,0.04); border-radius: 6px; font-style: italic; opacity: 0.78; line-height: 1.4; }
.koolook-preview-row { display: grid; grid-template-columns: 14px 1fr 14px 1fr 14px; column-gap: 8px; align-items: center; padding: 0 9px; line-height: 1.55; min-height: 18px; }
.koolook-preview-row-slot { padding: 2px 9px; }
.koolook-preview-col { overflow: hidden; min-width: 0; }
.koolook-preview-col-input { text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.koolook-preview-col-output { text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.koolook-preview-col-middle { /* spacer, intentionally empty */ }
.koolook-preview-slot-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 0 1px rgba(0,0,0,0.4); vertical-align: middle; }
.koolook-preview-slot-optional { opacity: 0.7; font-style: italic; }
.koolook-preview-row-widget { background: rgba(0,0,0,0.28); border: 1px solid rgba(255,255,255,0.09); margin: 5px 6px 0; border-radius: 9px; padding: 0 6px; line-height: 1.65; }
.koolook-preview-arrow { color: rgba(255,255,255,0.6); font-size: 9px; text-align: center; user-select: none; }
.koolook-preview-widget-name { text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.koolook-preview-widget-value { text-align: right; font-size: 11px; opacity: 0.85; font-family: monospace; text-overflow: ellipsis; white-space: nowrap; }
.koolook-preview-desc { margin: 10px 9px 0; padding: 7px 9px; background: rgba(255,255,255,0.06); border-radius: 6px; font-style: italic; font-weight: 500; font-size: 11px; line-height: 1.45; word-break: break-word; opacity: 0.85; max-height: 120px; overflow: hidden; }
`;
document.head.appendChild(s);
}
+201 -104
View File
@@ -129,11 +129,9 @@ export function renderPreviewCard(nodeClass, type) {
// Pack not currently loaded. Same source-of-truth as the
// (unresolved) bucket in category-mode gather. The stub keeps the
// hover-preview affordance consistent across resolved/unresolved
// picks rather than silently no-oping for missing packs.
const title = document.createElement("div");
title.className = "koolook-preview-title koolook-preview-title-unresolved";
title.textContent = type;
card.appendChild(title);
// picks rather than silently no-oping for missing packs. Renders
// header + stub only, no slot grid — there's nothing to mock.
appendHeader(card, type, /* unresolved */ true);
const stub = document.createElement("div");
stub.className = "koolook-preview-stub";
stub.textContent = "Pack not loaded — use the ↓ Install missing button in the Tools row.";
@@ -141,58 +139,48 @@ export function renderPreviewCard(nodeClass, type) {
return card;
}
const cat = (nodeClass.category && String(nodeClass.category)) || "";
const titleText = (nodeClass.title && String(nodeClass.title)) || type;
// Category / title / description — try the constructor-level properties
// first (set by some non-ComfyUI registered nodes), then fall through to
// `nodeData` (where ComfyUI's `registerNodeDef` parks the original def).
const def = nodeClass.nodeData || null;
const cat = (nodeClass.category && String(nodeClass.category))
|| (def && def.category && String(def.category))
|| "";
const titleText = (nodeClass.title && String(nodeClass.title))
|| (def && def.display_name && String(def.display_name))
|| type;
const title = document.createElement("div");
title.className = "koolook-preview-title";
title.textContent = titleText;
title.style.background = categoryColor(cat);
card.appendChild(title);
appendHeader(card, titleText, /* unresolved */ false, cat);
if (cat) {
const catEl = document.createElement("div");
catEl.className = "koolook-preview-cat";
catEl.textContent = cat;
card.appendChild(catEl);
}
// "PREVIEW" badge — purely a visual marker that this is a static mock,
// not a live node. Mirrors upstream NodePreview.vue's `_sb_preview_badge`.
const badge = document.createElement("div");
badge.className = "koolook-preview-badge";
badge.textContent = "Preview";
card.appendChild(badge);
const slots = readSlots(nodeClass);
if (slots.inputs.length > 0 || slots.outputs.length > 0) {
const ioGrid = document.createElement("div");
ioGrid.className = "koolook-preview-io";
ioGrid.appendChild(renderSlotsColumn("Inputs", slots.inputs));
ioGrid.appendChild(renderSlotsColumn("Outputs", slots.outputs));
card.appendChild(ioGrid);
// Slot rows pair inputs and outputs horizontally — row i shows
// `[dot] [input[i].name] [spacer] [output[i].name] [dot]`. When one
// side is shorter, the other side's later cells are blank. This is
// what makes the card read as a node mock: the sockets line up the
// same way they do on the real node.
const pairCount = Math.max(slots.inputs.length, slots.outputs.length);
for (let i = 0; i < pairCount; i++) {
appendSlotPairRow(card, slots.inputs[i] || null, slots.outputs[i] || null);
}
if (slots.widgets.length > 0) {
const wSec = document.createElement("div");
wSec.className = "koolook-preview-widgets";
const wTitle = document.createElement("div");
wTitle.className = "koolook-preview-section-title";
wTitle.textContent = "Widgets";
wSec.appendChild(wTitle);
for (const w of slots.widgets) {
const wRow = document.createElement("div");
wRow.className = "koolook-preview-widget-row";
const name = document.createElement("span");
name.className = "koolook-preview-widget-name";
name.textContent = w.name;
wRow.appendChild(name);
const def = document.createElement("span");
def.className = "koolook-preview-widget-default";
const dStr = w.default !== undefined && w.default !== null
? ` = ${truncate(String(w.default), 32)}`
: "";
def.textContent = `${w.type}${dStr}`;
wRow.appendChild(def);
wSec.appendChild(wRow);
}
card.appendChild(wSec);
// Widget rows — each rendered as a rounded "pill" with ◄ ► chrome to
// mimic LiteGraph widget UI. The triangles are static glyphs, no
// interaction; the value column shows the widget's default (or its
// first choice for COMBOs).
for (const w of slots.widgets) {
appendWidgetRow(card, w);
}
const description = nodeClass.description ? String(nodeClass.description).trim() : "";
const description = (nodeClass.description && String(nodeClass.description).trim())
|| (def && def.description && String(def.description).trim())
|| "";
if (description) {
const desc = document.createElement("div");
desc.className = "koolook-preview-desc";
@@ -203,51 +191,145 @@ export function renderPreviewCard(nodeClass, type) {
return card;
}
function renderSlotsColumn(label, slots) {
const col = document.createElement("div");
col.className = "koolook-preview-col";
// Header — colored dot + display name. The dot color is derived from the
// node's category via `categoryColor` so cards from the same category share
// a hue, making them visually groupable at a glance. Unresolved cards get
// the same shape with a muted dot and an "italic" title to read as a stub.
function appendHeader(card, title, unresolved, category) {
const header = document.createElement("div");
header.className = "koolook-preview-header";
if (unresolved) header.classList.add("koolook-preview-header-unresolved");
const dot = document.createElement("span");
dot.className = "koolook-preview-headdot";
dot.style.background = unresolved ? "rgba(180,120,120,0.7)" : categoryColor(category || "");
header.appendChild(dot);
const titleEl = document.createElement("span");
titleEl.className = "koolook-preview-headtitle";
titleEl.textContent = title;
header.appendChild(titleEl);
card.appendChild(header);
}
const t = document.createElement("div");
t.className = "koolook-preview-section-title";
t.textContent = label;
col.appendChild(t);
// One row in the 5-column slot grid: `[dot] [in-name] [middle] [out-name] [dot]`.
// Either slot may be null when the input/output counts are unequal — the
// corresponding cells render as empty placeholders so the grid alignment
// holds.
function appendSlotPairRow(card, inputSlot, outputSlot) {
const row = document.createElement("div");
row.className = "koolook-preview-row koolook-preview-row-slot";
if (slots.length === 0) {
const empty = document.createElement("div");
empty.className = "koolook-preview-empty";
empty.textContent = "—";
col.appendChild(empty);
return col;
}
for (const slot of slots) {
const row = document.createElement("div");
row.className = "koolook-preview-slot-row";
// Column 1: input dot
const inDot = document.createElement("div");
inDot.className = "koolook-preview-col";
if (inputSlot) {
const dot = document.createElement("span");
dot.className = "koolook-preview-slot-dot";
dot.style.background = slotColor(slot.type);
row.appendChild(dot);
const nameEl = document.createElement("span");
nameEl.className = "koolook-preview-slot-name";
if (slot.optional) nameEl.classList.add("koolook-preview-slot-optional");
nameEl.textContent = slot.name;
row.appendChild(nameEl);
const tEl = document.createElement("span");
tEl.className = "koolook-preview-slot-type";
tEl.textContent = slot.type;
row.appendChild(tEl);
col.appendChild(row);
dot.style.background = slotColor(inputSlot.type);
inDot.appendChild(dot);
}
return col;
row.appendChild(inDot);
// Column 2: input name (left-aligned)
const inName = document.createElement("div");
inName.className = "koolook-preview-col koolook-preview-col-input";
if (inputSlot) {
inName.textContent = inputSlot.name;
if (inputSlot.optional) inName.classList.add("koolook-preview-slot-optional");
inName.title = `${inputSlot.name}: ${inputSlot.type}`;
}
row.appendChild(inName);
// Column 3: middle spacer (empty by design)
const mid = document.createElement("div");
mid.className = "koolook-preview-col koolook-preview-col-middle";
row.appendChild(mid);
// Column 4: output name (right-aligned)
const outName = document.createElement("div");
outName.className = "koolook-preview-col koolook-preview-col-output";
if (outputSlot) {
outName.textContent = outputSlot.name;
outName.title = `${outputSlot.name}: ${outputSlot.type}`;
}
row.appendChild(outName);
// Column 5: output dot
const outDot = document.createElement("div");
outDot.className = "koolook-preview-col";
if (outputSlot) {
const dot = document.createElement("span");
dot.className = "koolook-preview-slot-dot";
dot.style.background = slotColor(outputSlot.type);
outDot.appendChild(dot);
}
row.appendChild(outDot);
card.appendChild(row);
}
// Widget pill — same 5-column grid shape as slot rows but with `◄` / `►`
// triangle glyphs in the dot columns and a rounded-bordered background to
// read as a LiteGraph widget. Value column truncates long defaults.
function appendWidgetRow(card, widget) {
const row = document.createElement("div");
row.className = "koolook-preview-row koolook-preview-row-widget";
const arrowL = document.createElement("div");
arrowL.className = "koolook-preview-col koolook-preview-arrow";
arrowL.textContent = "◀";
row.appendChild(arrowL);
const name = document.createElement("div");
name.className = "koolook-preview-col koolook-preview-widget-name";
name.textContent = widget.name;
name.title = `${widget.name} (${widget.type})`;
row.appendChild(name);
const mid = document.createElement("div");
mid.className = "koolook-preview-col koolook-preview-col-middle";
row.appendChild(mid);
const value = document.createElement("div");
value.className = "koolook-preview-col koolook-preview-widget-value";
if (widget.default !== undefined && widget.default !== null) {
value.textContent = truncate(String(widget.default), 24);
} else {
value.textContent = widget.type;
}
row.appendChild(value);
const arrowR = document.createElement("div");
arrowR.className = "koolook-preview-col koolook-preview-arrow";
arrowR.textContent = "▶";
row.appendChild(arrowR);
card.appendChild(row);
}
// =============================================================================
// Metadata extraction
//
// ComfyUI's INPUT_TYPES is convention-driven: each input is `[type, config?]`
// where `type` is either a string (slot type like "IMAGE", "MODEL", or a
// scalar widget type like "INT") or an array of choices (a COMBO widget).
// The same convention is mirrored on the JS side because LiteGraph reads
// the Python-derived definitions verbatim.
// ComfyUI doesn't keep `INPUT_TYPES` / `RETURN_TYPES` as static properties on
// the registered constructor. Instead, the original Python-side node def
// (the V1 shape returned by `/object_info`) is stashed at `nodeClass.nodeData`
// during registration (see Comfy-Org/ComfyUI_frontend `litegraphService.ts`
// `registerNodeDef` — `node.nodeData = nodeDef` right before
// `LiteGraph.registerNodeType`). `nodeData` exposes:
//
// nodeData.input.required { name: ["TYPE", config?], ... } V1 shape
// nodeData.input.optional { name: ["TYPE", config?], ... } V1 shape
// nodeData.output ["TYPE1", "TYPE2", ...] V1 shape
// nodeData.output_name ["name1", "name2", ...] V1 shape
// nodeData.description string
// nodeData.category string
// nodeData.display_name string
//
// Plus a parallel V2 transform (`inputs` record / `outputs` array) — we read
// V1 because it's a 1-to-1 with the Python convention and matches the
// upstream `INPUT_TYPES` shape exactly. Fall back to direct
// `nodeClass.INPUT_TYPES` / `RETURN_TYPES` for any future or non-ComfyUI
// LiteGraph nodes that follow the old convention.
//
// "Widget" classification matches ComfyUI's frontend rule: scalar types
// (INT, FLOAT, STRING, BOOLEAN) and arrays-of-choices (COMBO). Anything
@@ -261,28 +343,34 @@ function readSlots(nodeClass) {
const widgets = [];
const outputs = [];
let inputTypes = null;
try {
if (typeof nodeClass.INPUT_TYPES === "function") {
inputTypes = nodeClass.INPUT_TYPES();
} else if (nodeClass.INPUT_TYPES && typeof nodeClass.INPUT_TYPES === "object") {
inputTypes = nodeClass.INPUT_TYPES;
const def = nodeClass.nodeData || null;
// Inputs: prefer `nodeData.input` (V1 shape ComfyUI preserves verbatim);
// fall back to direct `INPUT_TYPES` for non-ComfyUI registered nodes.
let inputBlock = null;
if (def && def.input && typeof def.input === "object") {
inputBlock = def.input;
} else {
try {
if (typeof nodeClass.INPUT_TYPES === "function") {
inputBlock = nodeClass.INPUT_TYPES();
} else if (nodeClass.INPUT_TYPES && typeof nodeClass.INPUT_TYPES === "object") {
inputBlock = nodeClass.INPUT_TYPES;
}
} catch (e) {
// Some legacy packs throw from INPUT_TYPES under unusual
// conditions (missing sibling deps, lazy imports). Log so the
// failure is visible during pack debugging instead of silently
// rendering an empty-looking node. Matches the project-wide
// convention (see picks_store.js, canvas_io.js, installer.js).
console.warn("[Kforge Labs preview] INPUT_TYPES() threw for", nodeClass.title || "<no title>", e);
inputBlock = null;
}
} catch (e) {
// Some custom-node packs throw from INPUT_TYPES under unusual
// conditions (missing sibling deps, lazy imports). Treat as
// "no input metadata available" and continue — the card still
// renders the title + outputs + description. Log so the failure
// is visible during pack debugging instead of silently rendering
// an empty-looking node. Matches the project-wide convention
// (see picks_store.js, canvas_io.js, installer.js).
console.warn("[Kforge Labs preview] INPUT_TYPES() threw for", nodeClass.title || "<no title>", e);
inputTypes = null;
}
if (inputTypes && typeof inputTypes === "object") {
if (inputBlock && typeof inputBlock === "object") {
for (const section of ["required", "optional"]) {
const block = inputTypes[section];
const block = inputBlock[section];
if (!block || typeof block !== "object") continue;
for (const [name, spec] of Object.entries(block)) {
if (!Array.isArray(spec) || spec.length === 0) continue;
@@ -310,8 +398,17 @@ function readSlots(nodeClass) {
}
}
const returnTypes = Array.isArray(nodeClass.RETURN_TYPES) ? nodeClass.RETURN_TYPES : [];
const returnNames = Array.isArray(nodeClass.RETURN_NAMES) ? nodeClass.RETURN_NAMES : [];
// Outputs: prefer `nodeData.output` (parallel `output_name` for labels);
// fall back to direct `RETURN_TYPES` / `RETURN_NAMES` on the constructor.
let returnTypes = [];
let returnNames = [];
if (def && Array.isArray(def.output)) {
returnTypes = def.output;
returnNames = Array.isArray(def.output_name) ? def.output_name : [];
} else if (Array.isArray(nodeClass.RETURN_TYPES)) {
returnTypes = nodeClass.RETURN_TYPES;
returnNames = Array.isArray(nodeClass.RETURN_NAMES) ? nodeClass.RETURN_NAMES : [];
}
for (let i = 0; i < returnTypes.length; i++) {
const t = returnTypes[i];
const tStr = typeof t === "string" ? t : String(t);