From 6950bdf5ab72ef70217acf6117a7c33368aca4b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:08:45 +0000 Subject: [PATCH 1/5] web: add mock-only dock-editor shell for graphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor page at /graphs.html (served from server/, deliberately not data/ — it must not ship to devices while LittleFS is nearly full, see #377): column/row board, palette with the M2 parity node set, place/move/select, port-to-port edges with SVG rendering, notes, thin cfg panels, save/load of schema-v1 documents. Mock gains in-memory GET/PUT/DELETE /api/graphs with two seeded example graphs and structural stub validation; authoritative validation and execution arrive with the M2 engine. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QCqsYkCrhRHLoWZFuDTWjh --- eslint.config.js | 2 +- server/graphs.html | 513 +++++++++++++++++++++++++++++++++++++++++++ server/index.js | 64 ++++++ server/index.test.js | 54 +++++ 4 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 server/graphs.html diff --git a/eslint.config.js b/eslint.config.js index 76abf1cb..7435a128 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,7 +19,7 @@ export default [ }, }, { - files: ['data/index.html'], + files: ['data/index.html', 'server/graphs.html'], plugins: { html }, languageOptions: { ecmaVersion: 2022, diff --git a/server/graphs.html b/server/graphs.html new file mode 100644 index 00000000..15e6b67c --- /dev/null +++ b/server/graphs.html @@ -0,0 +1,513 @@ + + + + + +Battery Light — Graph Editor + + + + +

Graph editor mock-only shell ← Dashboard

+ +
+ + + + + + + +
+ +
+ + +
+ +
+
+

Building blocks

+
+
Click a block, then an empty cell to place it.
+ Click a node to select it; click an empty cell to move it there.
+ Connect: click an out-port, then an in-port. Click an edge to remove it.
+
+
+
+ +
+
+ +
Nothing selected.
+ +
+ + + + diff --git a/server/index.js b/server/index.js index 5166aeea..3dca327f 100644 --- a/server/index.js +++ b/server/index.js @@ -1192,6 +1192,70 @@ app.post('/api/update/apply-pr', (req, res) => { _simulateFlash(newVersion, () => { MOCK_CONFIG.prTrack = tag; }); }); +// ── Graphs (mock-only, issue #464) ────────────────────────────────────────── +// The dock-editor shell and its /api/graphs storage exist only in this mock +// for now — the real endpoints, validation, and execution arrive with the M2 +// graph engine (GraphManager, SceneManager-style). Schema v1 per the rework +// plan; col/row on nodes and the notes array are editor-only metadata. The +// editor page lives in server/ (not data/) so it never ships to devices — +// LittleFS is nearly full (#377); it moves into the device UI with M8. +const GRAPH_NAME_RE = /^[a-z0-9-]{1,32}$/i; + +const mockGraphs = new Map([ + ['buzzergame', { + v: 1, name: 'buzzergame', active: true, requires: ['button:main', 'stage:main'], + nodes: [ + { id: 1, type: 'button', role: 'button:main', col: 1, row: 1, cfg: {} }, + { id: 2, type: 'wait', col: 2, row: 1, cfg: { ms: 2000 } }, + { id: 3, type: 'action', col: 3, row: 1, cfg: { action: 'SceneNext' } }, + ], + edges: [[1, 'pressed', 2, 'start'], [2, 'done', 3, 'trigger']], + notes: [{ col: 1, row: 5, text: 'registration' }], + }], + ['nightlight', { + v: 1, name: 'nightlight', active: false, requires: [], + nodes: [ + { id: 1, type: 'mesh-receive', col: 1, row: 2, cfg: { eventType: 'buzz.press' } }, + { id: 2, type: 'range', col: 2, row: 2, cfg: { min: 1, max: 3 } }, + { id: 3, type: 'log', col: 3, row: 1, cfg: {} }, + { id: 4, type: 'action', col: 3, row: 3, cfg: { action: 'ColorSet' } }, + ], + edges: [[1, 'payload', 2, 'value'], [2, 'inRange', 4, 'trigger'], [2, 'outOfRange', 3, 'value']], + notes: [], + }], +]); + +app.get('/graphs.html', (_req, res) => + res.sendFile(join(dirname(fileURLToPath(import.meta.url)), 'graphs.html'))); + +app.get('/api/graphs', (_req, res) => res.json({ + graphs: [...mockGraphs.values()].map(({ name, active }) => ({ name, active })), +})); + +app.get('/api/graphs/:name', (req, res) => { + const graph = mockGraphs.get(req.params.name); + if (!graph) return res.status(404).json({ error: 'not found' }); + res.json(graph); +}); + +// Structural stub validation only — the authoritative validate lives in the +// M2 engine's load/validate/compile. +app.put('/api/graphs/:name', (req, res) => { + const name = req.params.name; + if (!GRAPH_NAME_RE.test(name)) return res.status(400).json({ error: 'invalid name' }); + const doc = req.body || {}; + if (doc.v !== 1 || doc.name !== name || !Array.isArray(doc.nodes) || !Array.isArray(doc.edges)) { + return res.status(400).json({ error: 'invalid graph document' }); + } + mockGraphs.set(name, doc); + res.json({ ok: true }); +}); + +app.delete('/api/graphs/:name', (req, res) => { + if (!mockGraphs.delete(req.params.name)) return res.status(404).json({ error: 'not found' }); + res.json({ ok: true }); +}); + app.get('/*path', (_req, res) => res.sendFile(join(DATA_DIR, 'index.html'))); const server = http.createServer(app); diff --git a/server/index.test.js b/server/index.test.js index 9a6ed20d..a56b5ce7 100644 --- a/server/index.test.js +++ b/server/index.test.js @@ -184,6 +184,60 @@ describe('POST /api/lights/update', () => { }); }); +describe('/api/graphs (mock-only, issue #464)', () => { + const putGraph = (name, doc) => fetch(`${baseUrl}/api/graphs/${name}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(doc), + }); + + test('lists the seeded graphs', async () => { + const res = await fetch(`${baseUrl}/api/graphs`); + assert.equal(res.status, 200); + const { graphs } = await res.json(); + assert.ok(graphs.some(g => g.name === 'buzzergame' && g.active === true)); + assert.ok(graphs.some(g => g.name === 'nightlight')); + }); + + test('returns a full schema-v1 document', async () => { + const res = await fetch(`${baseUrl}/api/graphs/buzzergame`); + assert.equal(res.status, 200); + const doc = await res.json(); + assert.equal(doc.v, 1); + assert.equal(doc.nodes.length, 3); + assert.deepEqual(doc.edges[0], [1, 'pressed', 2, 'start']); + assert.equal(doc.notes[0].text, 'registration'); + }); + + test('404 for an unknown graph', async () => { + assert.equal((await fetch(`${baseUrl}/api/graphs/nope`)).status, 404); + }); + + test('PUT stores a document and it round-trips', async () => { + const doc = { + v: 1, name: 'testgraph', active: false, requires: [], + nodes: [{ id: 1, type: 'button', col: 2, row: 3, cfg: {} }], + edges: [], notes: [{ col: 1, row: 1, text: 'hi' }], + }; + assert.equal((await putGraph('testgraph', doc)).status, 200); + const stored = await (await fetch(`${baseUrl}/api/graphs/testgraph`)).json(); + assert.deepEqual(stored, doc); + }); + + test('PUT rejects an invalid name and a structurally invalid document', async () => { + const ok = { v: 1, name: 'bad name', nodes: [], edges: [] }; + assert.equal((await putGraph('bad%20name', { ...ok })).status, 400); + assert.equal((await putGraph('valid', { name: 'valid', nodes: [], edges: [] })).status, 400); + assert.equal((await putGraph('valid', { v: 1, name: 'other', nodes: [], edges: [] })).status, 400); + }); + + test('DELETE removes a graph', async () => { + await putGraph('doomed', { v: 1, name: 'doomed', nodes: [], edges: [], notes: [] }); + assert.equal((await fetch(`${baseUrl}/api/graphs/doomed`, { method: 'DELETE' })).status, 200); + assert.equal((await fetch(`${baseUrl}/api/graphs/doomed`)).status, 404); + }); +}); + describe('GET /api/sounds', () => { test('returns the configured sound outputs', async () => { const res = await fetch(`${baseUrl}/api/sounds`); From 83bff40d0fe596eb387873050689dc50c7c7a50d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 23:12:56 +0000 Subject: [PATCH 2/5] web: rebuild graph editor as dock editor per interaction concept v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the SVG-wire shell with the concept's core loop (P0 + multi rows): design tokens, stones on a fixed grid with typed pins (shape = type), no drawn wires — connections arise from adjacency (neighboring columns, row distance <= 1, the only line segments are the 16px +-1 bows), joints for defined type conversions, chips with editable defaults on free inputs, repeatable connectors that grow extra rows (multi-row inputs with a merge-rule chip, repeatable outputs as the fan-out mechanism). One pointer-event code path drives stone dragging with per-frame candidate search, snap docking, trash-zone delete, and board panning; the palette is a collapsible bottom bar so the layout stacks vertically on phones. Snapshot undo (50 steps), localStorage draft with debounce, Apply validates and PUTs. The pure grid model (registry, candidate search, edge settling, validation) lives in graphs-core.js, shared by the page, the mock's PUT validation, and node:test unit tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QCqsYkCrhRHLoWZFuDTWjh --- eslint.config.js | 16 +- server/graphs-core.js | 302 ++++++++ server/graphs-core.test.js | 176 +++++ server/graphs.html | 1348 ++++++++++++++++++++++++++---------- server/index.js | 53 +- server/index.test.js | 4 +- 6 files changed, 1494 insertions(+), 405 deletions(-) create mode 100644 server/graphs-core.js create mode 100644 server/graphs-core.test.js diff --git a/eslint.config.js b/eslint.config.js index 7435a128..a2e19bce 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,7 +19,7 @@ export default [ }, }, { - files: ['data/index.html', 'server/graphs.html'], + files: ['data/index.html'], plugins: { html }, languageOptions: { ecmaVersion: 2022, @@ -32,4 +32,18 @@ export default [ 'no-var': 'error', }, }, + { + files: ['server/graphs.html'], + plugins: { html }, + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: globals.browser, + }, + rules: { + eqeqeq: 'error', + 'prefer-const': 'error', + 'no-var': 'error', + }, + }, ]; diff --git a/server/graphs-core.js b/server/graphs-core.js new file mode 100644 index 00000000..8282b5eb --- /dev/null +++ b/server/graphs-core.js @@ -0,0 +1,302 @@ +// Pure, DOM-free core of the dock editor (interaction concept v1, §11: the +// DockResolver as pure functions over the grid model) — shared between the +// editor page (loaded as an ES module) and node:test unit tests. Mock-only +// for now; the M2 engine adopts this registry as its node registry, which is +// what keeps editor and engine from drifting. + +// Wire/pin types (concept §1.3): shape is the primary code. +// event ▶ · switch ■ · value ● · color ◉ +export const PIN_SHAPES = { event: '▶', switch: '■', value: '●', color: '◉' }; + +// Node registry: every stone type with its fixed connector rows (PIN-01). +// Each row may carry one in-pin (left notch) and/or one out-pin (right +// tenon); row order is fixed (stones are static). `repeat: true` marks a +// repeatable connector (MULTI-01): the stone can grow extra rows for it — +// repeatable inputs collect several sources, repeatable outputs feed several +// neighbors (this is the concept's fan-out mechanism). Repeatable ports must +// sit alone on their registry row so instance rows can be inserted below. +// Port shapes are still speculative until the M2 engine pins them down. +export const NODE_TYPES = { + 'button': { cat: 'in', rows: [{ out: { id: 'pressed', type: 'event', repeat: true } }] }, + 'timer': { cat: 'in', rows: [{ in: { id: 'start', type: 'event' }, out: { id: 'elapsed', type: 'event' } }, + { in: { id: 'stop', type: 'event' } }] }, + 'mesh-receive': { cat: 'in', rows: [{ out: { id: 'payload', type: 'value', repeat: true } }] }, + 'constant': { cat: 'in', rows: [{ out: { id: 'value', type: 'value', repeat: true } }] }, + 'compare': { cat: 'logic', rows: [{ in: { id: 'value', type: 'value' }, out: { id: 'isTrue', type: 'switch' } }, + { out: { id: 'isFalse', type: 'switch' } }] }, + 'range': { cat: 'logic', rows: [{ in: { id: 'value', type: 'value' }, out: { id: 'inRange', type: 'switch' } }, + { out: { id: 'outOfRange', type: 'switch' } }] }, + 'gate': { cat: 'logic', rows: [{ in: { id: 'value', type: 'value' }, out: { id: 'value', type: 'value' } }, + { in: { id: 'open', type: 'switch' } }] }, + 'wait': { cat: 'logic', rows: [{ in: { id: 'start', type: 'event' }, out: { id: 'done', type: 'event' } }] }, + 'action': { cat: 'out', rows: [{ in: { id: 'trigger', type: 'event', repeat: true } }] }, + 'mesh-send': { cat: 'out', rows: [{ in: { id: 'value', type: 'value', repeat: true } }] }, + 'log': { cat: 'out', rows: [{ in: { id: 'value', type: 'value', repeat: true } }] }, +}; + +export const CATEGORIES = { in: 'Sources', logic: 'Logic', out: 'Sinks' }; + +// Merge rules for multi-row value inputs (MULTI-02); event rows are always OR. +export const MULTI_RULES = ['max', 'sum', 'mean', 'last']; + +// Joint matrix (DOCK-04): which type conversions dock as a two-colored joint +// pin, with which default rule. PROVISIONAL — the authoritative matrix lives +// in the system concept §5.3, which is not in this repo yet; replace this +// table when it lands. Key: `${outType}>${inType}`. +export const JOINT_MATRIX = { + 'value>switch': 'threshold 50%', + 'switch>value': '0 / 100%', + 'value>color': 'color ramp', + 'event>switch': 'pulse', +}; + +// Default chip values for free in-pins (PIN-03): percent for values, +// on/off for switches; event pins carry no chip. +export function defaultChip(type) { + if (type === 'value') return 50; + if (type === 'switch') return 0; + return null; +} + +// ── Geometry ───────────────────────────────────────────────────────────────── + +// Extra instance rows of a repeatable port: node.rep = { 'in:trigger': 1, … }. +export function extraRows(node, dir, portId) { + return (node.rep && node.rep[`${dir}:${portId}`]) || 0; +} + +// A stone occupies rows [row .. row + height - 1]: header + connector rows, +// where repeatable connectors contribute 1 + their extra rows. +export function stoneHeight(node) { + const type = typeof node === 'string' ? node : node.type; + const t = NODE_TYPES[type]; + if (!t) return 1; + let h = 1; + for (const r of t.rows) { + let rows = 1; + if (typeof node === 'object') { + if (r.in && r.in.repeat) rows += extraRows(node, 'in', r.in.id); + if (r.out && r.out.repeat) rows += extraRows(node, 'out', r.out.id); + } + h += rows; + } + return h; +} + +// All pin instances of a node with their global grid rows: +// [{dir, id, type, repeat, inst, row}] — inst 0 is the base row (MULTI-01). +export function pinsOf(node) { + const t = NODE_TYPES[node.type]; + if (!t) return []; + const out = []; + let row = node.row + 1; + for (const r of t.rows) { + const emit = (port, dir) => { + const count = 1 + (port.repeat ? extraRows(node, dir, port.id) : 0); + for (let inst = 0; inst < count; inst++) + out.push({ dir, id: port.id, type: port.type, repeat: !!port.repeat, inst, row: row + inst }); + return count; + }; + let used = 1; + if (r.in) used = Math.max(used, emit(r.in, 'in')); + if (r.out) used = Math.max(used, emit(r.out, 'out')); + row += used; + } + return out; +} + +export function overlaps(a, b) { + if (a.col !== b.col) return false; + const aEnd = a.row + stoneHeight(a) - 1; + const bEnd = b.row + stoneHeight(b) - 1; + return a.row <= bEnd && b.row <= aEnd; +} + +// True if `node` can sit at (col,row) without overlapping any other stone. +export function placementFree(doc, node, col, row) { + const probe = { ...node, col, row }; + return !doc.nodes.some(o => o.id !== node.id && overlaps(probe, o)); +} + +// ── Compatibility & candidate search (DOCK-03/04) ──────────────────────────── + +// null = incompatible; 'direct' = same shape; otherwise the joint rule name. +export function compat(outType, inType) { + if (outType === inType) return 'direct'; + return JOINT_MATRIX[`${outType}>${inType}`] || null; +} + +// How many edges already use a given (node, dir, port). +export function portLoad(doc, nodeId, dir, portId) { + return doc.edges.filter(e => dir === 'out' + ? e[0] === nodeId && e[1] === portId + : e[2] === nodeId && e[3] === portId).length; +} + +// A port's connection capacity = its instance-row count. +export function portCapacity(node, dir, portId) { + const t = NODE_TYPES[node.type]; + if (!t) return 0; + for (const r of t.rows) { + const p = dir === 'in' ? r.in : r.out; + if (p && p.id === portId) return 1 + (p.repeat ? extraRows(node, dir, portId) : 0); + } + return 0; +} + +// Candidate search for a stone hypothetically placed at (col,row): for each +// of its pin instances, compatible counter-pin instances in the adjacent +// column with a row distance ≤ 1. Priority: exact row > ±1 · direct > joint +// (DOCK-03). Returns candidates sorted best-first. +export function dockCandidates(doc, node, col, row) { + const probe = { ...node, col, row }; + const found = []; + for (const my of pinsOf(probe)) { + const otherCol = my.dir === 'out' ? col + 1 : col - 1; + for (const other of doc.nodes) { + if (other.id === node.id || other.col !== otherCol) continue; + for (const their of pinsOf(other)) { + if (their.dir === my.dir) continue; + const dRow = Math.abs(my.row - their.row); + if (dRow > 1) continue; + const rule = my.dir === 'out' ? compat(my.type, their.type) : compat(their.type, my.type); + if (!rule) continue; + const edge = my.dir === 'out' + ? [probe.id, my.id, other.id, their.id] + : [other.id, their.id, probe.id, my.id]; + found.push({ edge, rule, dRow, myPin: my, theirPin: their, other }); + } + } + } + found.sort((a, b) => + (a.dRow - b.dRow) || + ((a.rule === 'direct' ? 0 : 1) - (b.rule === 'direct' ? 0 : 1))); + return found; +} + +// True if an exact-row, adjacent-column pin pairing exists that is NOT +// compatible — the transient red "does not dock" state (DOCK-04). +export function hasRejectedDock(doc, node, col, row) { + const probe = { ...node, col, row }; + for (const my of pinsOf(probe)) { + const otherCol = my.dir === 'out' ? col + 1 : col - 1; + for (const other of doc.nodes) { + if (other.id === node.id || other.col !== otherCol) continue; + for (const their of pinsOf(other)) { + if (their.dir === my.dir || their.row !== my.row) continue; + const rule = my.dir === 'out' ? compat(my.type, their.type) : compat(their.type, my.type); + if (!rule) return true; + } + } + } + return false; +} + +// ── Edge maintenance (INV-01, DOCK-05/07) ──────────────────────────────────── + +// An edge is geometrically valid when some instance pairing of its two ports +// sits in adjacent columns with row distance ≤ 1 and the types dock. Rails +// (P1) will add the second legal representation per INV-01. +export function edgeValid(doc, edge) { + const from = doc.nodes.find(n => n.id === edge[0]); + const to = doc.nodes.find(n => n.id === edge[2]); + if (!from || !to) return false; + if (to.col !== from.col + 1) return false; + const outs = pinsOf(from).filter(p => p.dir === 'out' && p.id === edge[1]); + const ins = pinsOf(to).filter(p => p.dir === 'in' && p.id === edge[3]); + if (!outs.length || !ins.length) return false; + if (!compat(outs[0].type, ins[0].type)) return false; + return outs.some(o => ins.some(i => Math.abs(o.row - i.row) <= 1)); +} + +// The joint rule an edge docks with ('direct' or a JOINT_MATRIX rule). +export function edgeRule(doc, edge) { + const from = doc.nodes.find(n => n.id === edge[0]); + const to = doc.nodes.find(n => n.id === edge[2]); + if (!from || !to) return null; + const outPin = pinsOf(from).find(p => p.dir === 'out' && p.id === edge[1]); + const inPin = pinsOf(to).find(p => p.dir === 'in' && p.id === edge[3]); + if (!outPin || !inPin) return null; + return compat(outPin.type, inPin.type); +} + +// After a stone moved (or was added): drop edges that no longer hold +// geometrically, then dock every pin of the moved stone with free capacity +// to its best candidates. Mutates doc.edges; returns {dropped, added}. +export function settleEdges(doc, movedId) { + const before = doc.edges.length; + doc.edges = doc.edges.filter(e => edgeValid(doc, e)); + const dropped = before - doc.edges.length; + + const node = doc.nodes.find(n => n.id === movedId); + let added = 0; + if (node) { + for (const cand of dockCandidates(doc, node, node.col, node.row)) { + const [fromId, outId, toId, inId] = cand.edge; + if (doc.edges.some(e => e[0] === fromId && e[1] === outId && e[2] === toId && e[3] === inId)) + continue; + const fromNode = doc.nodes.find(n => n.id === fromId); + const toNode = doc.nodes.find(n => n.id === toId); + if (portLoad(doc, fromId, 'out', outId) >= portCapacity(fromNode, 'out', outId)) continue; + if (portLoad(doc, toId, 'in', inId) >= portCapacity(toNode, 'in', inId)) continue; + doc.edges.push(cand.edge); + added++; + } + } + return { dropped, added }; +} + +// ── Multi-row helpers (MULTI-01/02) ────────────────────────────────────────── + +// Grow/shrink a repeatable port by one row. Shrinking below the number of +// connected edges (or below one row) is refused. Returns true on change. +export function setPortRows(doc, node, dir, portId, delta) { + const cap = portCapacity(node, dir, portId); + if (!cap) return false; + const t = NODE_TYPES[node.type]; + const port = t.rows.map(r => (dir === 'in' ? r.in : r.out)).find(p => p && p.id === portId); + if (!port || !port.repeat) return false; + const next = cap + delta; + if (next < 1 || next < portLoad(doc, node.id, dir, portId)) return false; + node.rep = node.rep || {}; + node.rep[`${dir}:${portId}`] = next - 1; + // Growing/shrinking may break stones below — the caller re-settles; here we + // only refuse a shrink that would orphan connected rows (checked above). + return true; +} + +// The merge rule of a multi-row value input (MULTI-02), default 'max'. +export function multiRule(node, portId) { + return (node.cfg && node.cfg.rules && node.cfg.rules[portId]) || MULTI_RULES[0]; +} + +// ── Document validation (VAL-02, structural) ───────────────────────────────── + +export function validateDoc(doc) { + const errors = []; + if (doc.v !== 1) errors.push('schema version must be 1'); + if (!Array.isArray(doc.nodes)) errors.push('nodes must be an array'); + if (!Array.isArray(doc.edges)) errors.push('edges must be an array'); + if (errors.length) return errors; + const ids = new Set(); + for (const n of doc.nodes) { + if (ids.has(n.id)) errors.push(`duplicate node id ${n.id}`); + ids.add(n.id); + if (!NODE_TYPES[n.type]) errors.push(`unknown node type "${n.type}"`); + } + for (const n of doc.nodes) + for (const o of doc.nodes) + if (n.id < o.id && overlaps(n, o)) errors.push(`stones #${n.id} and #${o.id} overlap`); + for (const e of doc.edges) + if (!edgeValid(doc, e)) errors.push(`edge ${JSON.stringify(e)} is not dockable`); + for (const n of doc.nodes) { + if (!NODE_TYPES[n.type]) continue; + for (const dir of ['in', 'out']) + for (const r of NODE_TYPES[n.type].rows) { + const p = dir === 'in' ? r.in : r.out; + if (p && portLoad(doc, n.id, dir, p.id) > portCapacity(n, dir, p.id)) + errors.push(`port ${n.id}:${dir}:${p.id} exceeds its row capacity`); + } + } + return errors; +} diff --git a/server/graphs-core.test.js b/server/graphs-core.test.js new file mode 100644 index 00000000..de8b5b23 --- /dev/null +++ b/server/graphs-core.test.js @@ -0,0 +1,176 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { + NODE_TYPES, compat, dockCandidates, hasRejectedDock, settleEdges, + edgeValid, edgeRule, stoneHeight, placementFree, validateDoc, + portCapacity, portLoad, setPortRows, pinsOf, +} from './graphs-core.js'; + +const doc = (nodes, edges = []) => ({ v: 1, name: 't', nodes, edges, notes: [] }); + +describe('registry & geometry', () => { + test('stone height = header + connector rows, repeat rows grow it', () => { + assert.equal(stoneHeight('button'), 2); + assert.equal(stoneHeight('timer'), 3); + assert.equal(stoneHeight('range'), 3); + const grown = { type: 'action', rep: { 'in:trigger': 2 } }; + assert.equal(stoneHeight(grown), 4); + }); + + test('pinsOf emits one instance per repeat row with consecutive rows', () => { + const n = { id: 1, type: 'action', col: 1, row: 1, rep: { 'in:trigger': 1 } }; + const pins = pinsOf(n).filter(p => p.id === 'trigger'); + assert.equal(pins.length, 2); + assert.deepEqual(pins.map(p => p.row), [3, 4].map(r => r - 1)); // rows 2 and 3 + }); + + test('placement rejects overlap in the same column only', () => { + const d = doc([{ id: 1, type: 'timer', col: 2, row: 1 }]); + const probe = { id: 2, type: 'button' }; + assert.equal(placementFree(d, probe, 2, 2), false); // rows 2-3 vs timer 1-3 + assert.equal(placementFree(d, probe, 2, 4), true); + assert.equal(placementFree(d, probe, 3, 2), true); // other column + }); +}); + +describe('compatibility (DOCK-04)', () => { + test('same shape docks directly, defined pairs dock as joint, others not', () => { + assert.equal(compat('event', 'event'), 'direct'); + assert.equal(compat('value', 'switch'), 'threshold 50%'); + assert.equal(compat('event', 'color'), null); + }); +}); + +describe('candidate search (DOCK-03)', () => { + test('finds an exact-row direct candidate first', () => { + const d = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, // pressed out at row 2 + { id: 2, type: 'wait', col: 2, row: 1 }, // start in at row 2 + ]); + const c = dockCandidates(d, d.nodes[0], 1, 1); + assert.ok(c.length >= 1); + assert.deepEqual(c[0].edge, [1, 'pressed', 2, 'start']); + assert.equal(c[0].dRow, 0); + assert.equal(c[0].rule, 'direct'); + }); + + test('±1 row still qualifies, larger distances do not', () => { + const near = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 2, type: 'wait', col: 2, row: 2 }, // start at row 3 → dRow 1 + ]); + assert.equal(dockCandidates(near, near.nodes[0], 1, 1)[0].dRow, 1); + const far = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 2, type: 'wait', col: 2, row: 3 }, // start at row 5 → dRow 3 + ]); + assert.equal(dockCandidates(far, far.nodes[0], 1, 1).length, 0); + }); + + test('incompatible exact-row pairing reports the rejected state', () => { + const d = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, // pressed (event) out at row 2 + { id: 2, type: 'mesh-send', col: 2, row: 1 }, // value (value) in at row 2 + ]); + assert.equal(hasRejectedDock(d, d.nodes[0], 1, 1), true); + assert.equal(dockCandidates(d, d.nodes[0], 1, 1).length, 0); + }); +}); + +describe('edge settling (DOCK-05/07, INV-01)', () => { + test('moving a stone next to a partner docks it; moving away undocks', () => { + const d = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 2, type: 'wait', col: 4, row: 1 }, + ]); + assert.deepEqual(settleEdges(d, 2), { dropped: 0, added: 0 }); + d.nodes[1].col = 2; + const r = settleEdges(d, 2); + assert.equal(r.added, 1); + assert.deepEqual(d.edges, [[1, 'pressed', 2, 'start']]); + assert.equal(edgeRule(d, d.edges[0]), 'direct'); + d.nodes[1].col = 4; + assert.equal(settleEdges(d, 2).dropped, 1); + assert.deepEqual(d.edges, []); + }); + + test('a joint edge validates and reports its rule', () => { + const d = doc([ + { id: 1, type: 'timer', col: 1, row: 1 }, // elapsed out at row 2 + { id: 2, type: 'gate', col: 2, row: 0 }, // open in at row 2 + ], [[1, 'elapsed', 2, 'open']]); + assert.equal(edgeValid(d, d.edges[0]), true); + assert.equal(edgeRule(d, d.edges[0]), 'pulse'); + }); +}); + +describe('multi rows (MULTI-01/02)', () => { + test('capacity gates docking; growing a row unlocks a second source', () => { + const d = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, // pressed out at row 2 + { id: 2, type: 'wait', col: 1, row: 3 }, // done out at row 4 + { id: 3, type: 'action', col: 2, row: 1 }, // trigger in at row 2, capacity 1 + ], [[1, 'pressed', 3, 'trigger']]); + // Full: wait.done (row 4) is too far anyway, move wait so done sits at row 3. + d.nodes[1].row = 2; // done at row 3 → dRow 1 to trigger + assert.equal(settleEdges(d, 2).added, 0); // capacity 1 already used + assert.equal(setPortRows(d, d.nodes[2], 'in', 'trigger', +1), true); + assert.equal(portCapacity(d.nodes[2], 'in', 'trigger'), 2); + const r = settleEdges(d, 2); // trigger row 3 now exists + assert.equal(r.added, 1); + assert.equal(portLoad(d, 3, 'in', 'trigger'), 2); + }); + + test('shrinking below the connected load is refused', () => { + const d = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 3, type: 'action', col: 2, row: 1, rep: { 'in:trigger': 1 } }, + ], [[1, 'pressed', 3, 'trigger']]); + assert.equal(setPortRows(d, d.nodes[1], 'in', 'trigger', -1), true); // load 1, min 1 ok + assert.equal(setPortRows(d, d.nodes[1], 'in', 'trigger', -1), false); // below 1 row + }); + + test('a repeatable output fans out to several neighbors', () => { + const d = doc([ + { id: 1, type: 'constant', col: 1, row: 1, rep: { 'out:value': 1 } }, // value rows 2+3 + { id: 2, type: 'log', col: 2, row: 1 }, // value in at row 2 + { id: 3, type: 'log', col: 2, row: 3 }, // value in at row 4 (±1 of value row 3) + ]); + const r = settleEdges(d, 1); + assert.equal(r.added, 2); + assert.equal(portLoad(d, 1, 'out', 'value'), 2); + assert.deepEqual(validateDoc(d), []); + }); +}); + +describe('validateDoc (VAL-02)', () => { + test('accepts a consistent document and flags overlap + undockable edges', () => { + const good = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 2, type: 'wait', col: 2, row: 1 }, + ], [[1, 'pressed', 2, 'start']]); + assert.deepEqual(validateDoc(good), []); + + const bad = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 2, type: 'timer', col: 1, row: 2 }, + ], [[1, 'pressed', 2, 'start']]); + const errors = validateDoc(bad); + assert.ok(errors.some(e => e.includes('overlap'))); + assert.ok(errors.some(e => e.includes('not dockable'))); + }); + + test('flags edges beyond a port\'s row capacity', () => { + const d = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 2, type: 'wait', col: 1, row: 3 }, + { id: 3, type: 'action', col: 2, row: 1 }, + ], [[1, 'pressed', 3, 'trigger'], [2, 'done', 3, 'trigger']]); + d.nodes[1].row = 2; // done at row 3, within reach of trigger row 2 + assert.ok(validateDoc(d).some(e => e.includes('exceeds its row capacity'))); + }); + + test('every registry entry produces a positive height', () => { + for (const t of Object.keys(NODE_TYPES)) assert.ok(stoneHeight(t) >= 2, t); + }); +}); diff --git a/server/graphs.html b/server/graphs.html index 15e6b67c..1c762cf6 100644 --- a/server/graphs.html +++ b/server/graphs.html @@ -3,167 +3,243 @@ -Battery Light — Graph Editor +LightWitch — Graph Editor -

Graph editor mock-only shell ← Dashboard

-
+ - - - - - - + + + + + +
-
- - -
+
-
-
-

Building blocks

-
-
Click a block, then an empty cell to place it.
- Click a node to select it; click an empty cell to move it there.
- Connect: click an out-port, then an in-port. Click an edge to remove it.
-
-
-
- -
+
🗑 Drop here to delete
+
+
+
+
-
Nothing selected.
- +
+
+
+
→ swipe to see more columns
- diff --git a/server/index.js b/server/index.js index 3dca327f..617a1e2e 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,7 @@ import { dirname, join } from 'path'; import express from 'express'; import { WebSocketServer } from 'ws'; import { authRouter, requireAuth } from './auth.js'; +import { validateDoc as validateGraphDoc } from './graphs-core.js'; const DATA_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'data'); @@ -1194,22 +1195,28 @@ app.post('/api/update/apply-pr', (req, res) => { // ── Graphs (mock-only, issue #464) ────────────────────────────────────────── // The dock-editor shell and its /api/graphs storage exist only in this mock -// for now — the real endpoints, validation, and execution arrive with the M2 -// graph engine (GraphManager, SceneManager-style). Schema v1 per the rework -// plan; col/row on nodes and the notes array are editor-only metadata. The -// editor page lives in server/ (not data/) so it never ships to devices — -// LittleFS is nearly full (#377); it moves into the device UI with M8. +// for now — the real endpoints and execution arrive with the M2 graph engine +// (GraphManager, SceneManager-style). Schema v1 per the rework plan; col/row +// on nodes and the notes array are editor-only metadata, and connections are +// defined by adjacency (interaction concept: docking, no drawn wires) — the +// shared core in graphs-core.js validates that geometry on PUT. The editor +// page lives in server/ (not data/) so it never ships to devices — LittleFS +// is nearly full (#377); it moves into the device UI with M8. const GRAPH_NAME_RE = /^[a-z0-9-]{1,32}$/i; +// Seeds are adjacency-consistent: every edge's pins sit in neighboring +// columns within ±1 row (see graphs-core.js). buzzergame also seeds a +// multi-row input (action.trigger with one extra row) fed by two sources. const mockGraphs = new Map([ ['buzzergame', { v: 1, name: 'buzzergame', active: true, requires: ['button:main', 'stage:main'], nodes: [ { id: 1, type: 'button', role: 'button:main', col: 1, row: 1, cfg: {} }, { id: 2, type: 'wait', col: 2, row: 1, cfg: { ms: 2000 } }, - { id: 3, type: 'action', col: 3, row: 1, cfg: { action: 'SceneNext' } }, + { id: 3, type: 'action', col: 3, row: 1, cfg: { action: 'SceneNext' }, rep: { 'in:trigger': 1 } }, + { id: 4, type: 'timer', col: 2, row: 3, cfg: { ms: 10000 } }, ], - edges: [[1, 'pressed', 2, 'start'], [2, 'done', 3, 'trigger']], + edges: [[1, 'pressed', 2, 'start'], [2, 'done', 3, 'trigger'], [4, 'elapsed', 3, 'trigger']], notes: [{ col: 1, row: 5, text: 'registration' }], }], ['nightlight', { @@ -1217,16 +1224,26 @@ const mockGraphs = new Map([ nodes: [ { id: 1, type: 'mesh-receive', col: 1, row: 2, cfg: { eventType: 'buzz.press' } }, { id: 2, type: 'range', col: 2, row: 2, cfg: { min: 1, max: 3 } }, - { id: 3, type: 'log', col: 3, row: 1, cfg: {} }, - { id: 4, type: 'action', col: 3, row: 3, cfg: { action: 'ColorSet' } }, + { id: 3, type: 'gate', col: 3, row: 2, cfg: {} }, + { id: 4, type: 'log', col: 4, row: 2, cfg: {} }, + { id: 5, type: 'constant', col: 1, row: 6, cfg: { defaults: { value: 80 } } }, + { id: 6, type: 'gate', col: 2, row: 5, cfg: {} }, + { id: 7, type: 'log', col: 3, row: 5, cfg: {} }, ], - edges: [[1, 'payload', 2, 'value'], [2, 'inRange', 4, 'trigger'], [2, 'outOfRange', 3, 'value']], - notes: [], + edges: [ + [1, 'payload', 2, 'value'], + [2, 'outOfRange', 3, 'open'], + [3, 'value', 4, 'value'], + [5, 'value', 6, 'open'], // value>switch — docks as a joint (threshold) + [6, 'value', 7, 'value'], + ], + notes: [{ col: 4, row: 5, text: 'joint demo: value → switch' }], }], ]); -app.get('/graphs.html', (_req, res) => - res.sendFile(join(dirname(fileURLToPath(import.meta.url)), 'graphs.html'))); +const serverDir = dirname(fileURLToPath(import.meta.url)); +app.get('/graphs.html', (_req, res) => res.sendFile(join(serverDir, 'graphs.html'))); +app.get('/graphs-core.js', (_req, res) => res.sendFile(join(serverDir, 'graphs-core.js'))); app.get('/api/graphs', (_req, res) => res.json({ graphs: [...mockGraphs.values()].map(({ name, active }) => ({ name, active })), @@ -1238,15 +1255,15 @@ app.get('/api/graphs/:name', (req, res) => { res.json(graph); }); -// Structural stub validation only — the authoritative validate lives in the -// M2 engine's load/validate/compile. +// Validation via the shared editor core (VAL-02: applying with errors is not +// allowed) — the M2 engine's load/validate/compile stays authoritative later. app.put('/api/graphs/:name', (req, res) => { const name = req.params.name; if (!GRAPH_NAME_RE.test(name)) return res.status(400).json({ error: 'invalid name' }); const doc = req.body || {}; - if (doc.v !== 1 || doc.name !== name || !Array.isArray(doc.nodes) || !Array.isArray(doc.edges)) { - return res.status(400).json({ error: 'invalid graph document' }); - } + if (doc.name !== name) return res.status(400).json({ error: 'invalid graph document' }); + const errors = validateGraphDoc(doc); + if (errors.length) return res.status(400).json({ error: errors[0] }); mockGraphs.set(name, doc); res.json({ ok: true }); }); diff --git a/server/index.test.js b/server/index.test.js index a56b5ce7..21e4d4bb 100644 --- a/server/index.test.js +++ b/server/index.test.js @@ -204,9 +204,11 @@ describe('/api/graphs (mock-only, issue #464)', () => { assert.equal(res.status, 200); const doc = await res.json(); assert.equal(doc.v, 1); - assert.equal(doc.nodes.length, 3); + assert.equal(doc.nodes.length, 4); assert.deepEqual(doc.edges[0], [1, 'pressed', 2, 'start']); assert.equal(doc.notes[0].text, 'registration'); + // The seeded multi-row input carries two sources (MULTI-01). + assert.equal(doc.edges.filter(e => e[2] === 3 && e[3] === 'trigger').length, 2); }); test('404 for an unknown graph', async () => { From ac43877194561099d169ad0d11ff2a5d378af9bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 11:39:10 +0000 Subject: [PATCH 3/5] treewide: one vocabulary for the graph editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the stone/pin/edge/bow wording that had leaked in from the German interaction concept and settles a single set of words used identically in schema keys, code, docs and UI — there is no translation layer, so a second user-facing vocabulary would only be overhead: stone -> node (graph and node are a matched pair; block would collide with CSS display:block, 41x in these files) pin -> port (pin means GPIO pin everywhere else in this project) edge -> link (nothing is drawn, and connection means a network connection everywhere else) bow -> arc The schema key edges becomes links; nothing ships that format yet, so this is free now and expensive after M2. Requirement ids PIN-xx become PORT-xx. The editor gains a one-line hint that flow runs left to right, with inputs on a node's left edge and outputs on its right. The vocabulary now has a single definition site: a table at the top of the rework plan, where the terminology rule already lived. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QCqsYkCrhRHLoWZFuDTWjh --- docs/lightwitch-decisions.md | 2 +- docs/lightwitch-rework-plan.md | 30 ++++- server/graphs-core.js | 118 ++++++++--------- server/graphs-core.test.js | 62 ++++----- server/graphs.html | 232 +++++++++++++++++---------------- server/index.js | 6 +- server/index.test.js | 14 +- 7 files changed, 244 insertions(+), 220 deletions(-) diff --git a/docs/lightwitch-decisions.md b/docs/lightwitch-decisions.md index 7ffaf4d0..4243c2c9 100644 --- a/docs/lightwitch-decisions.md +++ b/docs/lightwitch-decisions.md @@ -101,7 +101,7 @@ the user enables them), `off` (ignore graphs from mesh/group entirely). ### D5 · Canonical graph schema: language & v1 scope ☑️ -**Decided: English canonical keys** (`nodes`, `edges`, `requires`), matching +**Decided: English canonical keys** (`nodes`, `links`, `requires`), matching the rest of the repo; German terms appear only as editor labels. This mirrors the concept's own §5.5 split between display language and stored form. The v1 scope additionally includes: the edge list as sketched, the `v` field, the diff --git a/docs/lightwitch-rework-plan.md b/docs/lightwitch-rework-plan.md index e997f972..80475503 100644 --- a/docs/lightwitch-rework-plan.md +++ b/docs/lightwitch-rework-plan.md @@ -1,10 +1,28 @@ # LightWitch rework plan Derived from the LightWitch system concept v1.0 and the resolved decisions in -[lightwitch-decisions.md](lightwitch-decisions.md) (D1–D7). Terminology follows -the repo: **node** (not "Stein"), **graph**, **stage node**, **role**, -**group**. All identifiers, schema keys, docs, and UI strings are English; -German appears only as optional editor labels later. +[lightwitch-decisions.md](lightwitch-decisions.md) (D1–D7). + +## Vocabulary + +One word per thing, used identically in schema keys, code identifiers, docs, +and UI strings — there is no separate user-facing vocabulary and no +translation layer, so a term that reads well in the editor must also be the +term in the code. + +| Term | Meaning | +|---|---| +| `graph` | One named, activatable program: nodes plus their links. Stored as one JSON file. | +| `node` | One building block of logic — a typed unit with ports. Never "Stein"/"stone"/"block". | +| `port` | A connection point on a node. Input ports sit on the node's left edge, output ports on its right; flow runs left to right. Say "input port"/"output port" when the direction matters. Never "pin" — that means a GPIO pin everywhere else in this project. | +| `link` | Two ports joined. Arises from adjacency (neighboring columns, row distance ≤ 1), not from a drawn line — so never "wire", "edge", or "connection" (the latter means a network connection everywhere else). | +| `joint` | A link between two different signal types, carrying a conversion rule. | +| `signal type` | What flows through a port: event, switch, value, color. Shape is the primary code, not colour. | +| `chip` | The editable default value shown on a free input port. | +| `arc` | The small curve in the gutter drawn for a link whose two ports sit one row apart — the only line segment in the system. | +| `rail` | A named portal that links distant nodes without adjacency. | +| `dock` (verb) | To join two ports by placing their nodes next to each other. | +| `stage node`, `role`, `group` | As used elsewhere in this repo. | Scope guardrails (from D3/D7): no native/desktop tooling, no measurement suite, no mesh protocol rework. Every milestone ships a usable feature on the @@ -173,7 +191,7 @@ frozen as of now: bug fixes only, no new trigger types, no new features. "nodes": [ {"id": 1, "type": "button", "role": "button:main", "col": 1, "row": 1, "cfg": {}} ], - "edges": [[1, "pressed", 2, "start"]], + "links": [[1, "pressed", 2, "start"]], "notes": [{"col": 1, "row": 5, "text": "registration"}] } ``` @@ -231,7 +249,7 @@ notice. ### M4 · Value layer + stage node Value-layer nodes (`lfo` via LUT, `math`, `hold`, `hsv-mix`, joints/type -adapters on edges per concept §5.3) and the **stage node**: binds to a light +adapters on links per concept §5.3) and the **stage node**: binds to a light role, claims the light through the M1 arbitration, drives its `PatternRunner` with a channel-driven pattern (intensity, stimulus, movement, limit, color — scenes read the channels they know). Existing patterns stay available as diff --git a/server/graphs-core.js b/server/graphs-core.js index 8282b5eb..8a69b956 100644 --- a/server/graphs-core.js +++ b/server/graphs-core.js @@ -4,14 +4,16 @@ // for now; the M2 engine adopts this registry as its node registry, which is // what keeps editor and engine from drifting. -// Wire/pin types (concept §1.3): shape is the primary code. +// Wire/port types (concept §1.3): shape is the primary code. // event ▶ · switch ■ · value ● · color ◉ -export const PIN_SHAPES = { event: '▶', switch: '■', value: '●', color: '◉' }; +export const SIGNAL_SHAPES = { event: '▶', switch: '■', value: '●', color: '◉' }; -// Node registry: every stone type with its fixed connector rows (PIN-01). -// Each row may carry one in-pin (left notch) and/or one out-pin (right -// tenon); row order is fixed (stones are static). `repeat: true` marks a -// repeatable connector (MULTI-01): the stone can grow extra rows for it — +// Node registry: every node type with its fixed connector rows (PORT-01). +// Each row may carry one input port (left edge of the node) and/or one output +// port (right edge) — flow runs left to right, so a node's outputs meet the +// inputs of the node in the next column. Row order is fixed (nodes are +// static). `repeat: true` marks a +// repeatable connector (MULTI-01): the node can grow extra rows for it — // repeatable inputs collect several sources, repeatable outputs feed several // neighbors (this is the concept's fan-out mechanism). Repeatable ports must // sit alone on their registry row so instance rows can be inserted below. @@ -40,7 +42,7 @@ export const CATEGORIES = { in: 'Sources', logic: 'Logic', out: 'Sinks' }; export const MULTI_RULES = ['max', 'sum', 'mean', 'last']; // Joint matrix (DOCK-04): which type conversions dock as a two-colored joint -// pin, with which default rule. PROVISIONAL — the authoritative matrix lives +// port, with which default rule. PROVISIONAL — the authoritative matrix lives // in the system concept §5.3, which is not in this repo yet; replace this // table when it lands. Key: `${outType}>${inType}`. export const JOINT_MATRIX = { @@ -50,8 +52,8 @@ export const JOINT_MATRIX = { 'event>switch': 'pulse', }; -// Default chip values for free in-pins (PIN-03): percent for values, -// on/off for switches; event pins carry no chip. +// Default chip values for free in-ports (PORT-03): percent for values, +// on/off for switches; event ports carry no chip. export function defaultChip(type) { if (type === 'value') return 50; if (type === 'switch') return 0; @@ -65,9 +67,9 @@ export function extraRows(node, dir, portId) { return (node.rep && node.rep[`${dir}:${portId}`]) || 0; } -// A stone occupies rows [row .. row + height - 1]: header + connector rows, +// A node occupies rows [row .. row + height - 1]: header + connector rows, // where repeatable connectors contribute 1 + their extra rows. -export function stoneHeight(node) { +export function nodeHeight(node) { const type = typeof node === 'string' ? node : node.type; const t = NODE_TYPES[type]; if (!t) return 1; @@ -83,9 +85,9 @@ export function stoneHeight(node) { return h; } -// All pin instances of a node with their global grid rows: +// All port instances of a node with their global grid rows: // [{dir, id, type, repeat, inst, row}] — inst 0 is the base row (MULTI-01). -export function pinsOf(node) { +export function portsOf(node) { const t = NODE_TYPES[node.type]; if (!t) return []; const out = []; @@ -107,12 +109,12 @@ export function pinsOf(node) { export function overlaps(a, b) { if (a.col !== b.col) return false; - const aEnd = a.row + stoneHeight(a) - 1; - const bEnd = b.row + stoneHeight(b) - 1; + const aEnd = a.row + nodeHeight(a) - 1; + const bEnd = b.row + nodeHeight(b) - 1; return a.row <= bEnd && b.row <= aEnd; } -// True if `node` can sit at (col,row) without overlapping any other stone. +// True if `node` can sit at (col,row) without overlapping any other node. export function placementFree(doc, node, col, row) { const probe = { ...node, col, row }; return !doc.nodes.some(o => o.id !== node.id && overlaps(probe, o)); @@ -126,9 +128,9 @@ export function compat(outType, inType) { return JOINT_MATRIX[`${outType}>${inType}`] || null; } -// How many edges already use a given (node, dir, port). +// How many links already use a given (node, dir, port). export function portLoad(doc, nodeId, dir, portId) { - return doc.edges.filter(e => dir === 'out' + return doc.links.filter(e => dir === 'out' ? e[0] === nodeId && e[1] === portId : e[2] === nodeId && e[3] === portId).length; } @@ -144,27 +146,27 @@ export function portCapacity(node, dir, portId) { return 0; } -// Candidate search for a stone hypothetically placed at (col,row): for each -// of its pin instances, compatible counter-pin instances in the adjacent +// Candidate search for a node hypothetically placed at (col,row): for each +// of its port instances, compatible counter-port instances in the adjacent // column with a row distance ≤ 1. Priority: exact row > ±1 · direct > joint // (DOCK-03). Returns candidates sorted best-first. export function dockCandidates(doc, node, col, row) { const probe = { ...node, col, row }; const found = []; - for (const my of pinsOf(probe)) { + for (const my of portsOf(probe)) { const otherCol = my.dir === 'out' ? col + 1 : col - 1; for (const other of doc.nodes) { if (other.id === node.id || other.col !== otherCol) continue; - for (const their of pinsOf(other)) { + for (const their of portsOf(other)) { if (their.dir === my.dir) continue; const dRow = Math.abs(my.row - their.row); if (dRow > 1) continue; const rule = my.dir === 'out' ? compat(my.type, their.type) : compat(their.type, my.type); if (!rule) continue; - const edge = my.dir === 'out' + const link = my.dir === 'out' ? [probe.id, my.id, other.id, their.id] : [other.id, their.id, probe.id, my.id]; - found.push({ edge, rule, dRow, myPin: my, theirPin: their, other }); + found.push({ link, rule, dRow, myPort: my, theirPort: their, other }); } } } @@ -174,15 +176,15 @@ export function dockCandidates(doc, node, col, row) { return found; } -// True if an exact-row, adjacent-column pin pairing exists that is NOT +// True if an exact-row, adjacent-column port pairing exists that is NOT // compatible — the transient red "does not dock" state (DOCK-04). export function hasRejectedDock(doc, node, col, row) { const probe = { ...node, col, row }; - for (const my of pinsOf(probe)) { + for (const my of portsOf(probe)) { const otherCol = my.dir === 'out' ? col + 1 : col - 1; for (const other of doc.nodes) { if (other.id === node.id || other.col !== otherCol) continue; - for (const their of pinsOf(other)) { + for (const their of portsOf(other)) { if (their.dir === my.dir || their.row !== my.row) continue; const rule = my.dir === 'out' ? compat(my.type, their.type) : compat(their.type, my.type); if (!rule) return true; @@ -192,54 +194,54 @@ export function hasRejectedDock(doc, node, col, row) { return false; } -// ── Edge maintenance (INV-01, DOCK-05/07) ──────────────────────────────────── +// ── Link maintenance (INV-01, DOCK-05/07) ──────────────────────────────────── -// An edge is geometrically valid when some instance pairing of its two ports +// A link is geometrically valid when some instance pairing of its two ports // sits in adjacent columns with row distance ≤ 1 and the types dock. Rails // (P1) will add the second legal representation per INV-01. -export function edgeValid(doc, edge) { - const from = doc.nodes.find(n => n.id === edge[0]); - const to = doc.nodes.find(n => n.id === edge[2]); +export function linkValid(doc, link) { + const from = doc.nodes.find(n => n.id === link[0]); + const to = doc.nodes.find(n => n.id === link[2]); if (!from || !to) return false; if (to.col !== from.col + 1) return false; - const outs = pinsOf(from).filter(p => p.dir === 'out' && p.id === edge[1]); - const ins = pinsOf(to).filter(p => p.dir === 'in' && p.id === edge[3]); + const outs = portsOf(from).filter(p => p.dir === 'out' && p.id === link[1]); + const ins = portsOf(to).filter(p => p.dir === 'in' && p.id === link[3]); if (!outs.length || !ins.length) return false; if (!compat(outs[0].type, ins[0].type)) return false; return outs.some(o => ins.some(i => Math.abs(o.row - i.row) <= 1)); } -// The joint rule an edge docks with ('direct' or a JOINT_MATRIX rule). -export function edgeRule(doc, edge) { - const from = doc.nodes.find(n => n.id === edge[0]); - const to = doc.nodes.find(n => n.id === edge[2]); +// The joint rule a link docks with ('direct' or a JOINT_MATRIX rule). +export function linkRule(doc, link) { + const from = doc.nodes.find(n => n.id === link[0]); + const to = doc.nodes.find(n => n.id === link[2]); if (!from || !to) return null; - const outPin = pinsOf(from).find(p => p.dir === 'out' && p.id === edge[1]); - const inPin = pinsOf(to).find(p => p.dir === 'in' && p.id === edge[3]); - if (!outPin || !inPin) return null; - return compat(outPin.type, inPin.type); + const outPort = portsOf(from).find(p => p.dir === 'out' && p.id === link[1]); + const inPort = portsOf(to).find(p => p.dir === 'in' && p.id === link[3]); + if (!outPort || !inPort) return null; + return compat(outPort.type, inPort.type); } -// After a stone moved (or was added): drop edges that no longer hold -// geometrically, then dock every pin of the moved stone with free capacity -// to its best candidates. Mutates doc.edges; returns {dropped, added}. -export function settleEdges(doc, movedId) { - const before = doc.edges.length; - doc.edges = doc.edges.filter(e => edgeValid(doc, e)); - const dropped = before - doc.edges.length; +// After a node moved (or was added): drop links that no longer hold +// geometrically, then dock every port of the moved node with free capacity +// to its best candidates. Mutates doc.links; returns {dropped, added}. +export function settleLinks(doc, movedId) { + const before = doc.links.length; + doc.links = doc.links.filter(e => linkValid(doc, e)); + const dropped = before - doc.links.length; const node = doc.nodes.find(n => n.id === movedId); let added = 0; if (node) { for (const cand of dockCandidates(doc, node, node.col, node.row)) { - const [fromId, outId, toId, inId] = cand.edge; - if (doc.edges.some(e => e[0] === fromId && e[1] === outId && e[2] === toId && e[3] === inId)) + const [fromId, outId, toId, inId] = cand.link; + if (doc.links.some(e => e[0] === fromId && e[1] === outId && e[2] === toId && e[3] === inId)) continue; const fromNode = doc.nodes.find(n => n.id === fromId); const toNode = doc.nodes.find(n => n.id === toId); if (portLoad(doc, fromId, 'out', outId) >= portCapacity(fromNode, 'out', outId)) continue; if (portLoad(doc, toId, 'in', inId) >= portCapacity(toNode, 'in', inId)) continue; - doc.edges.push(cand.edge); + doc.links.push(cand.link); added++; } } @@ -249,7 +251,7 @@ export function settleEdges(doc, movedId) { // ── Multi-row helpers (MULTI-01/02) ────────────────────────────────────────── // Grow/shrink a repeatable port by one row. Shrinking below the number of -// connected edges (or below one row) is refused. Returns true on change. +// connected links (or below one row) is refused. Returns true on change. export function setPortRows(doc, node, dir, portId, delta) { const cap = portCapacity(node, dir, portId); if (!cap) return false; @@ -260,7 +262,7 @@ export function setPortRows(doc, node, dir, portId, delta) { if (next < 1 || next < portLoad(doc, node.id, dir, portId)) return false; node.rep = node.rep || {}; node.rep[`${dir}:${portId}`] = next - 1; - // Growing/shrinking may break stones below — the caller re-settles; here we + // Growing/shrinking may break nodes below — the caller re-settles; here we // only refuse a shrink that would orphan connected rows (checked above). return true; } @@ -276,7 +278,7 @@ export function validateDoc(doc) { const errors = []; if (doc.v !== 1) errors.push('schema version must be 1'); if (!Array.isArray(doc.nodes)) errors.push('nodes must be an array'); - if (!Array.isArray(doc.edges)) errors.push('edges must be an array'); + if (!Array.isArray(doc.links)) errors.push('links must be an array'); if (errors.length) return errors; const ids = new Set(); for (const n of doc.nodes) { @@ -286,9 +288,9 @@ export function validateDoc(doc) { } for (const n of doc.nodes) for (const o of doc.nodes) - if (n.id < o.id && overlaps(n, o)) errors.push(`stones #${n.id} and #${o.id} overlap`); - for (const e of doc.edges) - if (!edgeValid(doc, e)) errors.push(`edge ${JSON.stringify(e)} is not dockable`); + if (n.id < o.id && overlaps(n, o)) errors.push(`nodes #${n.id} and #${o.id} overlap`); + for (const e of doc.links) + if (!linkValid(doc, e)) errors.push(`link ${JSON.stringify(e)} is not dockable`); for (const n of doc.nodes) { if (!NODE_TYPES[n.type]) continue; for (const dir of ['in', 'out']) diff --git a/server/graphs-core.test.js b/server/graphs-core.test.js index de8b5b23..4d42f992 100644 --- a/server/graphs-core.test.js +++ b/server/graphs-core.test.js @@ -1,27 +1,27 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { - NODE_TYPES, compat, dockCandidates, hasRejectedDock, settleEdges, - edgeValid, edgeRule, stoneHeight, placementFree, validateDoc, - portCapacity, portLoad, setPortRows, pinsOf, + NODE_TYPES, compat, dockCandidates, hasRejectedDock, settleLinks, + linkValid, linkRule, nodeHeight, placementFree, validateDoc, + portCapacity, portLoad, setPortRows, portsOf, } from './graphs-core.js'; -const doc = (nodes, edges = []) => ({ v: 1, name: 't', nodes, edges, notes: [] }); +const doc = (nodes, links = []) => ({ v: 1, name: 't', nodes, links, notes: [] }); describe('registry & geometry', () => { - test('stone height = header + connector rows, repeat rows grow it', () => { - assert.equal(stoneHeight('button'), 2); - assert.equal(stoneHeight('timer'), 3); - assert.equal(stoneHeight('range'), 3); + test('node height = header + connector rows, repeat rows grow it', () => { + assert.equal(nodeHeight('button'), 2); + assert.equal(nodeHeight('timer'), 3); + assert.equal(nodeHeight('range'), 3); const grown = { type: 'action', rep: { 'in:trigger': 2 } }; - assert.equal(stoneHeight(grown), 4); + assert.equal(nodeHeight(grown), 4); }); - test('pinsOf emits one instance per repeat row with consecutive rows', () => { + test('portsOf emits one instance per repeat row with consecutive rows', () => { const n = { id: 1, type: 'action', col: 1, row: 1, rep: { 'in:trigger': 1 } }; - const pins = pinsOf(n).filter(p => p.id === 'trigger'); - assert.equal(pins.length, 2); - assert.deepEqual(pins.map(p => p.row), [3, 4].map(r => r - 1)); // rows 2 and 3 + const ports = portsOf(n).filter(p => p.id === 'trigger'); + assert.equal(ports.length, 2); + assert.deepEqual(ports.map(p => p.row), [3, 4].map(r => r - 1)); // rows 2 and 3 }); test('placement rejects overlap in the same column only', () => { @@ -49,7 +49,7 @@ describe('candidate search (DOCK-03)', () => { ]); const c = dockCandidates(d, d.nodes[0], 1, 1); assert.ok(c.length >= 1); - assert.deepEqual(c[0].edge, [1, 'pressed', 2, 'start']); + assert.deepEqual(c[0].link, [1, 'pressed', 2, 'start']); assert.equal(c[0].dRow, 0); assert.equal(c[0].rule, 'direct'); }); @@ -77,30 +77,30 @@ describe('candidate search (DOCK-03)', () => { }); }); -describe('edge settling (DOCK-05/07, INV-01)', () => { - test('moving a stone next to a partner docks it; moving away undocks', () => { +describe('link settling (DOCK-05/07, INV-01)', () => { + test('moving a node next to a partner docks it; moving away undocks', () => { const d = doc([ { id: 1, type: 'button', col: 1, row: 1 }, { id: 2, type: 'wait', col: 4, row: 1 }, ]); - assert.deepEqual(settleEdges(d, 2), { dropped: 0, added: 0 }); + assert.deepEqual(settleLinks(d, 2), { dropped: 0, added: 0 }); d.nodes[1].col = 2; - const r = settleEdges(d, 2); + const r = settleLinks(d, 2); assert.equal(r.added, 1); - assert.deepEqual(d.edges, [[1, 'pressed', 2, 'start']]); - assert.equal(edgeRule(d, d.edges[0]), 'direct'); + assert.deepEqual(d.links, [[1, 'pressed', 2, 'start']]); + assert.equal(linkRule(d, d.links[0]), 'direct'); d.nodes[1].col = 4; - assert.equal(settleEdges(d, 2).dropped, 1); - assert.deepEqual(d.edges, []); + assert.equal(settleLinks(d, 2).dropped, 1); + assert.deepEqual(d.links, []); }); - test('a joint edge validates and reports its rule', () => { + test('a joint link validates and reports its rule', () => { const d = doc([ { id: 1, type: 'timer', col: 1, row: 1 }, // elapsed out at row 2 { id: 2, type: 'gate', col: 2, row: 0 }, // open in at row 2 ], [[1, 'elapsed', 2, 'open']]); - assert.equal(edgeValid(d, d.edges[0]), true); - assert.equal(edgeRule(d, d.edges[0]), 'pulse'); + assert.equal(linkValid(d, d.links[0]), true); + assert.equal(linkRule(d, d.links[0]), 'pulse'); }); }); @@ -113,10 +113,10 @@ describe('multi rows (MULTI-01/02)', () => { ], [[1, 'pressed', 3, 'trigger']]); // Full: wait.done (row 4) is too far anyway, move wait so done sits at row 3. d.nodes[1].row = 2; // done at row 3 → dRow 1 to trigger - assert.equal(settleEdges(d, 2).added, 0); // capacity 1 already used + assert.equal(settleLinks(d, 2).added, 0); // capacity 1 already used assert.equal(setPortRows(d, d.nodes[2], 'in', 'trigger', +1), true); assert.equal(portCapacity(d.nodes[2], 'in', 'trigger'), 2); - const r = settleEdges(d, 2); // trigger row 3 now exists + const r = settleLinks(d, 2); // trigger row 3 now exists assert.equal(r.added, 1); assert.equal(portLoad(d, 3, 'in', 'trigger'), 2); }); @@ -136,7 +136,7 @@ describe('multi rows (MULTI-01/02)', () => { { id: 2, type: 'log', col: 2, row: 1 }, // value in at row 2 { id: 3, type: 'log', col: 2, row: 3 }, // value in at row 4 (±1 of value row 3) ]); - const r = settleEdges(d, 1); + const r = settleLinks(d, 1); assert.equal(r.added, 2); assert.equal(portLoad(d, 1, 'out', 'value'), 2); assert.deepEqual(validateDoc(d), []); @@ -144,7 +144,7 @@ describe('multi rows (MULTI-01/02)', () => { }); describe('validateDoc (VAL-02)', () => { - test('accepts a consistent document and flags overlap + undockable edges', () => { + test('accepts a consistent document and flags overlap + undockable links', () => { const good = doc([ { id: 1, type: 'button', col: 1, row: 1 }, { id: 2, type: 'wait', col: 2, row: 1 }, @@ -160,7 +160,7 @@ describe('validateDoc (VAL-02)', () => { assert.ok(errors.some(e => e.includes('not dockable'))); }); - test('flags edges beyond a port\'s row capacity', () => { + test('flags links beyond a port\'s row capacity', () => { const d = doc([ { id: 1, type: 'button', col: 1, row: 1 }, { id: 2, type: 'wait', col: 1, row: 3 }, @@ -171,6 +171,6 @@ describe('validateDoc (VAL-02)', () => { }); test('every registry entry produces a positive height', () => { - for (const t of Object.keys(NODE_TYPES)) assert.ok(stoneHeight(t) >= 2, t); + for (const t of Object.keys(NODE_TYPES)) assert.ok(nodeHeight(t) >= 2, t); }); }); diff --git a/server/graphs.html b/server/graphs.html index 1c762cf6..f984b300 100644 --- a/server/graphs.html +++ b/server/graphs.html @@ -10,7 +10,7 @@ --row: 28px; --colw: 168px; --gut: 16px; - --pin: 16px; + --port: 16px; --hit: 44px; --c-event: #e6b45a; --c-switch: #5ac8e6; @@ -49,66 +49,66 @@ background-size: calc(var(--colw) + var(--gut)) 100%; background-position: calc(-1 * var(--gut)) 0; pointer-events: none; } -/* ── Stones (PIN-01) ── */ -.stone { position: absolute; width: var(--colw); background: #1f2430; +/* ── Nodes (PORT-01) ── */ +.node { position: absolute; width: var(--colw); background: #1f2430; border: 1px solid #333d52; border-radius: 9px; cursor: grab; user-select: none; -webkit-user-select: none; touch-action: none; } -.stone.selected { border-color: #4af; box-shadow: 0 0 0 1px #4af; } -.stone.ghost { opacity: .75; transform: rotate(-2deg); z-index: 30; cursor: grabbing; +.node.selected { border-color: #4af; box-shadow: 0 0 0 1px #4af; } +.node.ghost { opacity: .75; transform: rotate(-2deg); z-index: 30; cursor: grabbing; box-shadow: 0 6px 18px rgba(0,0,0,.5); } -.stone.reject { border-color: #c44; box-shadow: 0 0 0 1px #c44; } -.stone-head { height: var(--row); display: flex; align-items: center; gap: .35rem; +.node.reject { border-color: #c44; box-shadow: 0 0 0 1px #c44; } +.node-head { height: var(--row); display: flex; align-items: center; gap: .35rem; padding: 0 .5rem; font-size: .74rem; font-weight: 600; color: #dde; border-bottom: 1px solid #2a3040; cursor: pointer; } -.stone-head .cat { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.node-head .cat { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .cat-in { background: #e6b45a; } .cat-logic { background: #5ac8e6; } .cat-out { background: #7ee65a; } -.stone-head .sid { margin-left: auto; font-size: .62rem; color: #567; font-weight: 400; } -.stone-row { height: var(--row); position: relative; display: flex; align-items: center; +.node-head .sid { margin-left: auto; font-size: .62rem; color: #567; font-weight: 400; } +.node-row { height: var(--row); position: relative; display: flex; align-items: center; justify-content: space-between; font-size: .64rem; color: #9ab; } -.stone-row .lbl-in { padding-left: .55rem; display: flex; align-items: center; gap: .3rem; } -.stone-row .lbl-out { padding-right: .55rem; margin-left: auto; } -.stone-row .rep-mark { color: #567; } +.node-row .lbl-in { padding-left: .55rem; display: flex; align-items: center; gap: .3rem; } +.node-row .lbl-out { padding-right: .55rem; margin-left: auto; } +.node-row .rep-mark { color: #567; } -/* ── Pins (PIN-02) — protrude 9px into the 16px gutter ── */ -.pin { position: absolute; width: var(--pin); height: var(--pin); top: 50%; +/* ── Ports (PORT-02) — protrude 9px into the 16px gutter ── */ +.port { position: absolute; width: var(--port); height: var(--port); top: 50%; transform: translateY(-50%); z-index: 5; } -.pin.in { left: -9px; } -.pin.out { right: -9px; } -.pin::after { content: ''; position: absolute; inset: 0; display: block; } -.pin[data-t=event]::after { background: var(--c-event); +.port.in { left: -9px; } +.port.out { right: -9px; } +.port::after { content: ''; position: absolute; inset: 0; display: block; } +.port[data-t=event]::after { background: var(--c-event); clip-path: polygon(15% 0, 100% 50%, 15% 100%); } -.pin[data-t=switch]::after { background: var(--c-switch); border-radius: 2px; inset: 2px; } -.pin[data-t=value]::after { background: var(--c-value); border-radius: 50%; inset: 1px; } -.pin[data-t=color]::after { border: 4px solid var(--c-color); border-radius: 50%; +.port[data-t=switch]::after { background: var(--c-switch); border-radius: 2px; inset: 2px; } +.port[data-t=value]::after { background: var(--c-value); border-radius: 50%; inset: 1px; } +.port[data-t=color]::after { border: 4px solid var(--c-color); border-radius: 50%; inset: 1px; background: none; } -.pin.free::after { opacity: .35; } -.pin.linked::after { opacity: 1; filter: drop-shadow(0 0 4px currentColor); } -.pin.joint::after { background: linear-gradient(135deg, var(--c-value) 50%, var(--c-switch) 50%); +.port.free::after { opacity: .35; } +.port.linked::after { opacity: 1; filter: drop-shadow(0 0 4px currentColor); } +.port.joint::after { background: linear-gradient(135deg, var(--c-value) 50%, var(--c-switch) 50%); border-radius: 3px; inset: 1px; clip-path: none; border: none; } -.pin.cand::after { opacity: .7; outline: 2px solid #2a64; } -.pin.cand-best::after { opacity: 1; outline: 2px solid #2a6; animation: pulse .7s infinite; } +.port.cand::after { opacity: .7; outline: 2px solid #2a64; } +.port.cand-best::after { opacity: 1; outline: 2px solid #2a6; animation: pulse .7s infinite; } @keyframes pulse { 50% { filter: drop-shadow(0 0 8px #2a6); } } -/* Invisible hit area (PIN-05): 44px wide, but capped to the row height +/* Invisible hit area (PORT-05): 44px wide, but capped to the row height vertically — at 28px rows a full 44px square would overlap the neighbor - row's pin on the same stone and steal its taps (audit note: deviation + row's port on the same node and steal its taps (audit note: deviation from the blanket ≥44px; the gutter direction keeps the full width). Docked counterparts still overlap in the gutter — both resolve to the - same connection, so either pin opens the same popover. */ -.pin::before { content: ''; position: absolute; width: var(--hit); height: var(--row); + same connection, so either port opens the same popover. */ +.port::before { content: ''; position: absolute; width: var(--hit); height: var(--row); left: 50%; top: 50%; transform: translate(-50%, -50%); } -/* ── Chips (PIN-03, MULTI-02) ── */ +/* ── Chips (PORT-03, MULTI-02) ── */ .chip, .rulechip { background: #2a2f3d; color: #cde; font-size: .6rem; border-radius: 8px; padding: .1rem .35rem; cursor: pointer; position: relative; z-index: 4; } .rulechip { background: #33305a; color: #cbe; } .chip::before, .rulechip::before { content: ''; position: absolute; width: var(--hit); height: var(--hit); left: 50%; top: 50%; transform: translate(-50%, -50%); } -/* ── ±1 bows (GRID-03) — the only "line segments" in the system ── */ -.bow { position: absolute; width: var(--gut); pointer-events: none; z-index: 3; } -.bow i { position: absolute; inset: 0; display: block; border: 2px solid #6a7; +/* ── ±1 arcs (GRID-03) — the only "line segments" in the system ── */ +.arc { position: absolute; width: var(--gut); pointer-events: none; z-index: 3; } +.arc i { position: absolute; inset: 0; display: block; border: 2px solid #6a7; border-left: none; border-radius: 0 8px 8px 0; } -.bow.up i { transform: scaleY(-1); } +.arc.up i { transform: scaleY(-1); } /* ── Notes (VIEW-06) ── */ .note-card { position: absolute; width: var(--colw); background: #2b2620; @@ -124,6 +124,7 @@ cursor: pointer; } #palette-grip::after { content: ''; width: 42px; height: 4px; border-radius: 2px; background: #333; } +#flow-hint { font-size: .68rem; color: #667; padding: 0 .6rem .35rem; line-height: 1.35; } #palette-cats { display: flex; gap: .35rem; padding: 0 .6rem .35rem; } .pal-cat { font-size: .68rem; padding: .18rem .55rem; border-radius: 10px; background: #222; color: #888; cursor: pointer; } @@ -135,7 +136,8 @@ cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; display: flex; align-items: center; gap: .3rem; } .pal-item.armed { border-color: #2a6; background: #1a2f22; } -#palette.collapsed #palette-cats, #palette.collapsed #palette-items { display: none; } +#palette.collapsed #palette-cats, #palette.collapsed #palette-items, +#palette.collapsed #flow-hint { display: none; } /* ── Trash zone (DOCK-08) — replaces the palette while dragging ── */ #trash { display: none; height: 56px; background: #3a1515; border-top: 2px dashed #933; @@ -210,6 +212,8 @@
🗑 Drop here to delete
+
Flow runs left to right — inputs sit on a node's left edge, + outputs on its right. Drag a node next to another so their ports meet.
@@ -222,9 +226,9 @@