diff --git a/src/index.html b/src/index.html
index ca7b41b..8fc1d24 100644
--- a/src/index.html
+++ b/src/index.html
@@ -2088,6 +2088,20 @@
0%,100% { box-shadow: 0 0 0 3px rgba(59,130,246,.9); }
50% { box-shadow: 0 0 0 6px rgba(59,130,246,.35); }
}
+
+ /* J3.c: thread-hover highlight. When the user hovers any port on
+ a threaded frame, every frame in the same chain glows so the
+ flow path is visible at a glance. Implemented by tagging
+ chain-member freeLayers with `.threadHighlight` from a JS
+ hover handler. */
+ .freeLayer.threadHighlight {
+ outline: 2px solid rgba(34,197,94,.85);
+ outline-offset: 2px;
+ transition: outline-color .12s;
+ }
+ .freeLayer.threadHighlight.threadHighlightHead {
+ outline-color: rgba(59,130,246,.85);
+ }
.bookFrameIdBadge {
position: absolute; bottom: 4px; left: 4px;
font-size: .58rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -22662,6 +22676,54 @@
layer.nextFrame = null;
render();
}
+ // J3.c — walk forward + backward along nextFrame refs from
+ // `layer` and tag every chain member's freeLayer wrapper. The
+ // head of the thread (first frame, no incoming link) gets a
+ // distinct blue outline so direction is readable.
+ function _bookHighlightThread(layer) {
+ _bookClearThreadHighlight();
+ const chain = _bookCollectThreadChain(layer);
+ if (!chain.length) return;
+ chain.forEach((l, i) => {
+ const el = document.querySelector(`.freeLayer[data-layer-id="${CSS.escape(l.id)}"]`);
+ if (!el) return;
+ el.classList.add('threadHighlight');
+ if (i === 0) el.classList.add('threadHighlightHead');
+ });
+ }
+ function _bookClearThreadHighlight() {
+ document.querySelectorAll('.freeLayer.threadHighlight').forEach(el => {
+ el.classList.remove('threadHighlight', 'threadHighlightHead');
+ });
+ }
+ function _bookCollectThreadChain(layer) {
+ // Walk backwards to the head, then forward to the tail.
+ // Returns layers in flow order (head → ... → tail).
+ const seen = new Set();
+ // Backwards: find the layer whose nextFrame.frameId equals
+ // current.frameId; loop until none exists.
+ let head = layer;
+ let guard = 0;
+ while (guard++ < 1000) {
+ const prev = _findBookFrameLayer(o => o !== head && o.nextFrame?.frameId === head.frameId);
+ if (!prev || seen.has(prev.id)) break;
+ seen.add(prev.id);
+ head = prev;
+ }
+ const chain = [head];
+ seen.add(head.id);
+ let cursor = head;
+ guard = 0;
+ while (cursor.nextFrame?.frameId && guard++ < 1000) {
+ const nxt = _findBookFrameLayer(o => o.frameId === cursor.nextFrame.frameId);
+ if (!nxt || seen.has(nxt.id)) break;
+ seen.add(nxt.id);
+ chain.push(nxt);
+ cursor = nxt;
+ }
+ return chain;
+ }
+
function _bookPortOnClick(layer, portKind, ev) {
ev.preventDefault();
ev.stopPropagation();
@@ -22713,6 +22775,10 @@
inPort.dataset.framePort = 'in';
inPort.dataset.frameId = myFrameId;
inPort.addEventListener('click', (ev) => _bookPortOnClick(layer, 'in', ev));
+ if (linkedIn || linkedOut) {
+ inPort.addEventListener('mouseenter', () => _bookHighlightThread(layer));
+ inPort.addEventListener('mouseleave', _bookClearThreadHighlight);
+ }
el.appendChild(inPort);
const outPort = document.createElement('div');
@@ -22727,6 +22793,10 @@
outPort.dataset.framePort = 'out';
outPort.dataset.frameId = myFrameId;
outPort.addEventListener('click', (ev) => _bookPortOnClick(layer, 'out', ev));
+ if (linkedIn || linkedOut) {
+ outPort.addEventListener('mouseenter', () => _bookHighlightThread(layer));
+ outPort.addEventListener('mouseleave', _bookClearThreadHighlight);
+ }
el.appendChild(outPort);
const badge = document.createElement('div');