OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 57e8d01

gather: G did nothing on the Nodes tab, silently
A node's HEAD and TITLE open it — that is the obvious thing to click, and it calls stopPropagation, so only the body selects. G required a selection, found none, and returned without a word. Verified by driving real key events, not by reading the handler: before, "after G -> gather: null"; after, depth 1 with 5 nodes gathered. So G now acts on what you are POINTING at as well as what is selected, and when there is genuinely nothing it says so in the legend instead of no-opping. Also the keys the floor view has and the Nodes tab did not: F frames, R routing, O only-connected. And neither handler guarded the event target, which I should have caught when I wrote them — the page has a search box and a path field, so typing "graph" into search was gathering and toggling frames and routing on the way past. Both now ignore INPUT, TEXTAREA and contentEditable, ignore modified keys, and the node view ignores keys while another tab is up. Verified with real key events over the DevTools protocol, both surfaces: nodes hover -> NV.hover mod:anproto; G -> depth 1, 5 gathered, 1 gather box ] -> depth 2, 25 gathered; [ [ -> released; F, R toggle floor G -> "GATHERED anproto + 4 within 1 hop", url ?select=anproto&gather=1 ] -> +24 within 2 hops, 15 categories -> 9 as the gather empties them B -> 105 edges to 29 bundled; R -> 0 curved links to 105 both -> G while the path field has focus does nothing B and R are the keys the last commit unblocked at system level; that is them being exercised rather than merely unguarded. The probes are kept in test/manual/ with what it takes to trust a run: aim at a node that is really on screen, because elementFromPoint on empty space reports nothing rather than failing, and an earlier version of the probe "reproduced" a bug that was only my own bad aim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
57e8d01ec02474c37627fdd8f1ee39747dd6158c
by plankatron <fleeky@gmail.com> · 2026-09-21T02:17:08.000Z

Changed files (6) +346 -7

lib/floor/level.js +24 -3
……@@ -69,6 +69,7 @@ const view = {
6969 // Temporarily regroup the board so the selected card's neighbourhood is laid
7070 // out together: { id, depth }, depth in hops. Null is the ordinary board.
7171 gather: null,
72 hovered: null, // the card under the pointer, so a key can act on it
7273 linkEls: [],
7374 cardEls: null,
7475 laid: null,
……@@ -520,8 +521,14 @@ function drawCard (world, card, pos, closure) {
520521 // Edges are dim until you point at something: at real scale they are the
521522 // noise, not the signal - 105 crossing curves at the system level, 316 inside
522523 // one 58-file module.
523 g.addEventListener('mouseenter', () => { if (!view.selected) focusCard(card) })
524 g.addEventListener('mouseleave', () => { if (!view.selected) unfocus() })
524 g.addEventListener('mouseenter', () => {
525 view.hovered = card.id
526 if (!view.selected) focusCard(card)
527 })
528 g.addEventListener('mouseleave', () => {
529 if (view.hovered === card.id) view.hovered = null
530 if (!view.selected) unfocus()
531 })
525532 g.addEventListener('click', (e) => {
526533 e.stopPropagation()
527534 select(view.selected === card.id ? null : card.id)
……@@ -779,8 +786,22 @@ function wire () {
779786 // while the hint bar went on promising them.
780787 if (!view.tree && !view.model) return
781788
789 // This view has a path input. Without this guard, typing a path containing g,
790 // b, r, e or c fired every one of these shortcuts mid-word.
791 const t = e.target
792 if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
793 if (e.metaKey || e.ctrlKey || e.altKey) return
794
782795 if (e.key === 'g' || e.key === 'G') {
783 gather(view.gather ? null : view.selected, view.gather ? 0 : 1)
796 // Hover counts, not just selection. On this board the obvious click target
797 // is a card's TITLE, and that opens the card rather than selecting it — so
798 // requiring a selection made G look broken.
799 const target = view.gather ? null : (view.selected || view.hovered)
800 if (!view.gather && !target) {
801 banner('point at a node, or click one, then press G to gather it')
802 return
803 }
804 gather(target, view.gather ? 0 : 1)
784805 } else if (e.key === ']' || e.key === '}') {
785806 if (view.gather) gather(view.gather.id, view.gather.depth + 1)
786807 } else if (e.key === '[' || e.key === '{') {
lib/render-nodes.js +40 -4
……@@ -128,6 +128,8 @@ var NV = {
128128 // { id, depth } in hops, null for the ordinary board.
129129 gather: null,
130130 gathered: null,
131 hover: null, // the node under the pointer, so a key can act on it
132 note: null, // a one-line message in the legend
131133 // A LIST of cached routings, not one slot: gathering and releasing swaps
132134 // between two geometries over and over, and a single slot makes every swap a
133135 // fresh A* pass over the whole board.
……@@ -854,8 +856,14 @@ function nvDrawNode (parent, n) {
854856
855857 // Hover traces this node's own wires; click keeps its whole dependency depth
856858 // on screen until you click it again or hit Escape.
857 g.addEventListener('mouseenter', function () { if (!NV.selected) nvFocus(n) })
858 g.addEventListener('mouseleave', function () { if (!NV.selected) nvUnfocus() })
859 g.addEventListener('mouseenter', function () {
860 NV.hover = n.id
861 if (!NV.selected) nvFocus(n)
862 })
863 g.addEventListener('mouseleave', function () {
864 if (NV.hover === n.id) NV.hover = null
865 if (!NV.selected) nvUnfocus()
866 })
859867 g.addEventListener('click', function (e) {
860868 e.stopPropagation()
861869 NV.selected = NV.selected === n.id ? null : n.id
……@@ -1048,11 +1056,13 @@ function nvLegend () {
10481056 '<span style="color:#f85149">&#9675;</span> unresolved' +
10491057 '<br>left sockets demand &middot; right sockets supply' +
10501058 '<br>hover traces a node &middot; click for its full dependency depth' +
1051 '<br><b>G</b> gathers the selection &middot; <b>[ ]</b> its depth' +
1059 '<br><b>G</b> gathers what you point at &middot; <b>[ ]</b> its depth &middot; ' +
1060 '<b>F</b> frames &middot; <b>R</b> routing &middot; <b>O</b> only connected &middot; <b>Esc</b>' +
10521061 (NV.gather && NV.gathered
10531062 ? '<br><span style="color:#ffa657">gathered ' + (NV.gathered.size - 1) +
10541063 ' within ' + NV.gather.depth + (NV.gather.depth === 1 ? ' hop' : ' hops') + '</span>'
10551064 : '') +
1065 (NV.note ? '<br><span style="color:#f0883e">' + escapeHtml(NV.note) + '</span>' : '') +
10561066 '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' +
10571067 '<input type="checkbox" id="nv-only-connected"' + (NV.onlyConnected ? ' checked' : '') +
10581068 ' style="vertical-align:-1px;accent-color:#58a6ff"> collapse to only connected</label>' +
……@@ -1191,12 +1201,38 @@ function nvInit () {
11911201 })
11921202
11931203 document.addEventListener('keydown', function (e) {
1204 // The page has a search box and a path field. Without this guard every one of
1205 // these letters fires while you are typing.
1206 var t = e.target
1207 if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
1208 if (e.metaKey || e.ctrlKey || e.altKey) return
1209 // These are the node view's keys; they must not fire while another tab is up.
1210 if (typeof currentTab !== 'undefined' && currentTab !== 'nodes') return
1211
1212 NV.note = null
11941213 if (e.key === 'g' || e.key === 'G') {
1195 nvGather(NV.gather ? null : NV.selected, NV.gather ? 0 : 1)
1214 // Hover counts, not just selection. A node's HEAD and TITLE open it — that
1215 // is the obvious thing to click — so only the body selects, and requiring a
1216 // selection made G look broken.
1217 var target = NV.gather ? null : (NV.selected || NV.hover)
1218 if (!NV.gather && !target) {
1219 NV.note = 'point at a node, or click its body, then press G'
1220 nvLegend()
1221 return
1222 }
1223 nvGather(target, NV.gather ? 0 : 1)
11961224 } else if (e.key === ']' || e.key === '}') {
11971225 if (NV.gather) nvGather(NV.gather.id, NV.gather.depth + 1)
11981226 } else if (e.key === '[' || e.key === '{') {
11991227 if (NV.gather) nvGather(NV.gather.id, NV.gather.depth - 1)
1228 } else if (e.key === 'f' || e.key === 'F') {
1229 NV.showFrames = !NV.showFrames
1230 nvRender()
1231 } else if (e.key === 'r' || e.key === 'R') {
1232 NV.circuit = !NV.circuit
1233 nvRender()
1234 } else if (e.key === 'o' || e.key === 'O') {
1235 window.nvToggleConnected(!NV.onlyConnected)
12001236 } else if (e.key === 'Escape') {
12011237 if (NV.gather) { nvGather(null, 0); return }
12021238 if (NV.selected) { NV.selected = null; nvRender() }
test/manual/README.md +33 -0
……@@ -0,0 +1,33 @@
1# Manual probes
2
3These need a browser and a running server, so they are not part of `npm test`.
4
5## keyprobe-nodes.mjs / keyprobe-floor.mjs
6
7The only instrument here that sees a REAL key event. Everything else in the suite
8tests the pure modules, and the keyboard handling lives in emitted browser code
9where a handler can look perfectly correct and still never fire — which is
10exactly what happened: `G` did nothing on the Nodes tab because the obvious click
11target (a node's title) opens the node instead of selecting it, so the handler
12ran with nothing to act on and returned in silence.
13
14They drive Chromium over the DevTools protocol: launch headless with
15`--remote-debugging-port`, dispatch `Input.dispatchMouseEvent` to hover and
16`Input.dispatchKeyEvent` to type, then read the result back out of the DOM.
17
18```bash
19node index.js serve .. 9078 --depth 1 --no-watch &
20node test/manual/keyprobe-nodes.mjs "http://localhost:9078/?tab=nodes"
21node test/manual/keyprobe-floor.mjs "http://localhost:9078/floor"
22```
23
24Two things to know before believing a run:
25
26- **Aim at a node that is actually on screen.** Most of a real board is scrolled
27 off, and `elementFromPoint` on empty space reports nothing rather than failing.
28 Both probes pick the first node whose centre really hits its own body; an
29 earlier version aimed at the first node in the DOM and "reproduced" a bug that
30 was my probe missing the target.
31- **`view` is module-scoped in level.js**, so `Runtime.evaluate` cannot read it.
32 The floor probe asserts on the DOM — the gather box, the stats line, the URL —
33 which is what a user sees anyway.
test/manual/keyprobe-floor.mjs +118 -0
……@@ -0,0 +1,118 @@
1// Drive the real page with real key events over the DevTools protocol.
2// Asserting that a handler LOOKS right is not evidence that a key works.
3import { spawn } from 'child_process'
4
5const URL_ = process.argv[2]
6const PORT = 9335
7const dir = '/tmp/claude-1000/cdp-profile-' + PORT
8
9const chrome = spawn('chromium', [
10 '--headless=new', '--no-sandbox', '--disable-gpu', '--window-size=1600,1000',
11 '--remote-debugging-port=' + PORT, '--user-data-dir=' + dir, URL_
12], { stdio: 'ignore' })
13
14const sleep = (ms) => new Promise(r => setTimeout(r, ms))
15
16async function target () {
17 for (let i = 0; i < 60; i++) {
18 try {
19 const res = await fetch('http://127.0.0.1:' + PORT + '/json/list')
20 const list = await res.json()
21 const page = list.find(t => t.type === 'page' && t.webSocketDebuggerUrl)
22 if (page) return page.webSocketDebuggerUrl
23 } catch {}
24 await new Promise(r => setTimeout(r, 250))
25 }
26 throw new Error('no devtools target')
27}
28
29const ws = new WebSocket(await target())
30await new Promise(r => { ws.onopen = r })
31let id = 0
32const pending = new Map()
33ws.onmessage = (m) => {
34 const msg = JSON.parse(m.data)
35 if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id) }
36}
37function send (method, params = {}) {
38 const myId = ++id
39 ws.send(JSON.stringify({ id: myId, method, params }))
40 return new Promise(r => pending.set(myId, r))
41}
42async function evaluate (expr) {
43 const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
44 if (r.result && r.result.exceptionDetails) return { error: r.result.exceptionDetails.text }
45 return r.result && r.result.result ? r.result.result.value : undefined
46}
47const VK = { g: 71, f: 70, r: 82, o: 79, '[': 219, ']': 221 }
48async function key (k) {
49 const code = k === '[' ? 'BracketLeft' : k === ']' ? 'BracketRight' : 'Key' + k.toUpperCase()
50 const base = { key: k, code, windowsVirtualKeyCode: VK[k], nativeVirtualKeyCode: VK[k] }
51 await send('Input.dispatchKeyEvent', { type: 'keyDown', text: k, ...base })
52 await send('Input.dispatchKeyEvent', { type: 'keyUp', ...base })
53 await new Promise(r => setTimeout(r, 450))
54}
55async function moveTo (x, y) {
56 await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, buttons: 0 })
57 await new Promise(r => setTimeout(r, 200))
58}
59
60await send('Runtime.enable')
61await new Promise(r => setTimeout(r, 5000))
62
63const out = []
64const say = (label, value) => { out.push(label + ': ' + JSON.stringify(value)); console.log(label + ': ' + JSON.stringify(value)) }
65
66say('level', await evaluate('document.getElementById("levelname").textContent'))
67say('cards', await evaluate('view.cards.length'))
68say('containers drawn', await evaluate('document.querySelectorAll(".zone").length'))
69
70const rect = await evaluate(`(() => {
71 for (const g of document.querySelectorAll('#world > g')) {
72 const r = g.getBoundingClientRect();
73 const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2);
74 const el = document.elementFromPoint(x, y);
75 if (el && String(el.getAttribute('class') || '').indexOf('n-') === 0) return { x, y };
76 }
77 return null;
78})()`)
79say('a card point', rect)
80if (rect) await moveTo(rect.x, rect.y)
81
82// level.js is an ES module, so `view` is not reachable from here. Everything
83// below is read off the DOM, which is what the user sees anyway.
84const stats = () => evaluate('document.getElementById("stats").textContent')
85const curves = () => evaluate(
86 '[...document.querySelectorAll("path.link")].filter(p => p.getAttribute("d").indexOf(" C ") >= 0).length')
87
88await key('g')
89say('after G -> gather box', await evaluate('document.querySelectorAll(".zone-gather").length'))
90say('after G -> url', await evaluate('location.search'))
91say('after G -> stats', await stats())
92await key(']')
93say('after ] -> url', await evaluate('location.search'))
94say('after ] -> stats', await stats())
95await key('[')
96await key('[')
97say('after [[ -> gather box', await evaluate('document.querySelectorAll(".zone-gather").length'))
98say('after [[ -> url', await evaluate('location.search'))
99
100// B and R were dead on this rung: the handler began `if (!view.model) return`
101// and view.model is null at system level.
102await key('b')
103say('after B -> stats mentions bundled', await stats())
104await key('b')
105say('curved links before R', await curves())
106await key('r')
107say('curved links after R', await curves())
108await key('r')
109
110await evaluate('document.getElementById("path").focus()')
111say('focused', await evaluate('document.activeElement.id'))
112await key('g')
113say('G while typing -> gather box', await evaluate('document.querySelectorAll(".zone-gather").length'))
114
115console.log('---')
116ws.close()
117chrome.kill('SIGKILL')
118process.exit(0)
test/manual/keyprobe-nodes.mjs +115 -0
……@@ -0,0 +1,115 @@
1// Drive the real page with real key events over the DevTools protocol.
2// Asserting that a handler LOOKS right is not evidence that a key works.
3import { spawn } from 'child_process'
4
5const URL_ = process.argv[2]
6const PORT = 9333
7const dir = '/tmp/claude-1000/cdp-profile-' + PORT
8
9const chrome = spawn('chromium', [
10 '--headless=new', '--no-sandbox', '--disable-gpu', '--window-size=1600,1000',
11 '--remote-debugging-port=' + PORT, '--user-data-dir=' + dir, URL_
12], { stdio: 'ignore' })
13
14const sleep = (ms) => new Promise(r => setTimeout(r, ms))
15
16async function target () {
17 for (let i = 0; i < 60; i++) {
18 try {
19 const res = await fetch('http://127.0.0.1:' + PORT + '/json/list')
20 const list = await res.json()
21 const page = list.find(t => t.type === 'page' && t.webSocketDebuggerUrl)
22 if (page) return page.webSocketDebuggerUrl
23 } catch {}
24 await new Promise(r => setTimeout(r, 250))
25 }
26 throw new Error('no devtools target')
27}
28
29const ws = new WebSocket(await target())
30await new Promise(r => { ws.onopen = r })
31let id = 0
32const pending = new Map()
33ws.onmessage = (m) => {
34 const msg = JSON.parse(m.data)
35 if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id) }
36}
37function send (method, params = {}) {
38 const myId = ++id
39 ws.send(JSON.stringify({ id: myId, method, params }))
40 return new Promise(r => pending.set(myId, r))
41}
42async function evaluate (expr) {
43 const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
44 if (r.result && r.result.exceptionDetails) return { error: r.result.exceptionDetails.text }
45 return r.result && r.result.result ? r.result.result.value : undefined
46}
47const VK = { g: 71, f: 70, r: 82, o: 79, '[': 219, ']': 221 }
48async function key (k) {
49 const code = k === '[' ? 'BracketLeft' : k === ']' ? 'BracketRight' : 'Key' + k.toUpperCase()
50 const base = { key: k, code, windowsVirtualKeyCode: VK[k], nativeVirtualKeyCode: VK[k] }
51 await send('Input.dispatchKeyEvent', { type: 'keyDown', text: k, ...base })
52 await send('Input.dispatchKeyEvent', { type: 'keyUp', ...base })
53 await new Promise(r => setTimeout(r, 450))
54}
55async function moveTo (x, y) {
56 await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, buttons: 0 })
57 await new Promise(r => setTimeout(r, 200))
58}
59
60await send('Runtime.enable')
61await new Promise(r => setTimeout(r, 5000))
62
63const out = []
64const say = (label, value) => { out.push(label + ': ' + JSON.stringify(value)); console.log(label + ': ' + JSON.stringify(value)) }
65
66say('tab', await evaluate('typeof currentTab !== "undefined" ? currentTab : "n/a"'))
67say('nodes drawn', await evaluate('document.querySelectorAll(".nv-node").length'))
68
69// hover the first node's BODY (not its head, which opens it)
70// The first node whose CENTRE actually hits its own body: most of the board is
71// scrolled off-screen, and pointing at empty space proves nothing.
72const rect = await evaluate(`(() => {
73 for (const n of document.querySelectorAll('.nv-node')) {
74 const r = n.getBoundingClientRect();
75 const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2);
76 const el = document.elementFromPoint(x, y);
77 if (el && el.getAttribute('class') === 'nv-node-body') return { x, y };
78 }
79 return null;
80})()`)
81say('first node body point', rect)
82if (rect) await moveTo(rect.x, rect.y)
83say('NV.hover after move', await evaluate('NV.hover'))
84
85await key('g')
86say('after G -> gather', await evaluate('NV.gather && NV.gather.depth'))
87say('after G -> gather boxes', await evaluate('document.querySelectorAll(".nv-frame-gather").length'))
88say('after G -> gathered count', await evaluate('NV.gathered ? NV.gathered.size : null'))
89
90await key(']')
91say('after ] -> depth', await evaluate('NV.gather && NV.gather.depth'))
92say('after ] -> gathered count', await evaluate('NV.gathered ? NV.gathered.size : null'))
93
94await key('[')
95say('after [ -> depth', await evaluate('NV.gather && NV.gather.depth'))
96await key('[')
97say('after [[ -> gather released', await evaluate('NV.gather === null'))
98
99await key('f')
100say('after F -> showFrames', await evaluate('NV.showFrames'))
101await key('f')
102await key('r')
103say('after R -> circuit', await evaluate('NV.circuit'))
104await key('r')
105
106// the typing guard
107await evaluate(`(() => { const i = document.querySelector('input[type=text], #search, input'); if (i) { i.focus(); return i.id || i.type } return 'none' })()`)
108say('focused input', await evaluate('document.activeElement.tagName'))
109await key('g')
110say('G while typing -> gather stays null', await evaluate('NV.gather === null'))
111
112console.log('---')
113ws.close()
114chrome.kill('SIGKILL')
115process.exit(0)
test/render.test.js +16 -0
……@@ -88,3 +88,19 @@ test('render: embedded tree JSON cannot break out of the script tag', async (t)
8888 t.fail('data containing </script> truncated the script: ' + err.message)
8989 }
9090})
91
92test('render: the node view keys do not fire while you are typing', (t) => {
93 // The page has a search box and a path field. Without a guard on the event
94 // target, every single-letter shortcut fires mid-word — typing "graph" into
95 // search would gather, toggle frames and toggle routing on the way past.
96 const src = readFileSync(resolve(HERE, '../lib/render-nodes.js'), 'utf-8')
97 const handler = src.slice(src.indexOf("addEventListener('keydown'"))
98 const body = handler.slice(0, handler.indexOf('\n })'))
99
100 t.ok(body.includes('tagName'), 'the handler inspects the event target')
101 t.ok(/INPUT/.test(body) && /TEXTAREA/.test(body), 'and bails out for text fields')
102 t.ok(body.includes('isContentEditable'), 'contentEditable too')
103
104 // And it must not act on another tab's keystrokes.
105 t.ok(body.includes('currentTab'), 'the node view only takes keys while it is up')
106})