diff --git a/css/style.css b/css/style.css index 3091d51..982f11c 100644 --- a/css/style.css +++ b/css/style.css @@ -1477,8 +1477,33 @@ input[type="range"] { #props-panel .prop-row input, #props-panel .prop-row select, #props-panel .wide-input, #props-panel .var-wide-input { min-height: 42px; } - /* The timeline gives the canvas room back on small screens. */ - #timeline { height: 150px; } + /* The timeline gives the canvas room back on small screens, but not so much + that its own controls fall off the bottom. At 150px the chart, the legend + and the scrub row wanted 173px with a single-row legend and 195px with two, + so the whole footer (Replay, the slider, the step label, Back to live) sat + below the fold with between zero and three usable pixels. html and body do + not scroll and #tl-resize is mousedown-only, so there was no way to reach + it. Fit the content instead of cropping it, and let the drawer scroll if a + long legend still overflows. */ + #timeline { height: auto; min-height: 150px; max-height: 46vh; overflow-y: auto; } + /* The scrub row is the reason the drawer exists on a phone; never let it + shrink away under a tall legend. */ + #tl-scrub { flex-shrink: 0; } + .tl-legend { max-height: 34px; } +} + +/* The concept guide is the whole in-app documentation, and on a phone it had no + responsive rule at all: .kb-body is a flex row and .kb-nav is a hard 230px + that will not shrink, so inside a 94vw modal the article column collapsed to + 109px on a 390px screen and 21px on a 320px one, roughly one word per line + with the longer words running past the modal edge. Stack the two instead, and + cap the nav so the article always gets the bulk of the height. */ +@media (max-width: 768px) { + .kb-body { flex-direction: column; } + .kb-nav { + width: auto; max-height: 30vh; + border-right: none; border-bottom: 1px solid var(--border); + } } /* Small phones (iPhone SE class). The touch topbar does not wrap, so at 320px diff --git a/js/app-analysis.js b/js/app-analysis.js index ee78f74..c891c7a 100644 --- a/js/app-analysis.js +++ b/js/app-analysis.js @@ -494,7 +494,11 @@ class AppAnalysis { del.style.cssText = 'padding:2px 8px;flex-shrink:0'; del.setAttribute('aria-label', 'Delete check'); del.appendChild(this._faIcon('xmark')); - del.addEventListener('click', () => { list.splice(i, 1); this._renderProps(); this._commit(); }); + del.addEventListener('click', () => { + list.splice(i, 1); + this._renderPropsFocused(() => this._panelAddButton()); + this._commit(); + }); row.appendChild(inp); row.appendChild(del); panel.appendChild(row); }); @@ -509,7 +513,15 @@ class AppAnalysis { const addBtn = document.createElement('button'); addBtn.textContent = '+ Add check'; addBtn.className = 'btn var-add-btn'; - addBtn.addEventListener('click', () => { list.push(''); this._renderProps(); this._commit(); }); + addBtn.addEventListener('click', () => { + list.push(''); + // The new check is the last row, and it is empty and waiting for input. + this._renderPropsFocused(() => { + const rows = document.querySelectorAll('#props-content .prop-row input[type="text"]'); + return rows[rows.length - 1]; + }); + this._commit(); + }); panel.appendChild(addBtn); this._section(panel, 'Check against a run'); diff --git a/js/app-export.js b/js/app-export.js index 0a3b65f..9ef1ac0 100644 --- a/js/app-export.js +++ b/js/app-export.js @@ -77,13 +77,22 @@ class AppExport { const font = tok('--font'); for (const el of out.querySelectorAll('[font-family]')) if (el.getAttribute('font-family').startsWith('var(')) el.setAttribute('font-family', font); + const mono = tok('--mono'); const style = svgEl('style'); style.textContent = [ '.n-count { fill: #fff; font-size: 13px; font-weight: 700; font-family: monospace; }', `.n-label, .n-badge, .grp-label { paint-order: stroke; stroke: ${tok('--bg')}; stroke-width: 3px; stroke-linejoin: round; }`, - `.n-label { fill: ${tok('--text')}; font-size: 11px; }`, - `.n-badge { fill: ${tok('--text-dim')}; font-size: 11px; }`, - `.conn-label { fill: ${tok('--text')}; }`, + // font-family belongs here, not only in the var() rewrite above: that loop + // only reaches elements that already carry the attribute, so node labels, + // badges and connection pills fell back to the browser serif in the file. + `.n-label { fill: ${tok('--text')}; font-size: 11px; font-family: ${font}; }`, + `.n-badge { fill: ${tok('--text-dim')}; font-size: 11px; font-family: ${font}; }`, + `.conn-label { fill: ${tok('--text')}; font-family: ${font}; }`, + // A converter's recipe caption carries no inline fill or size, so without + // a rule it took the SVG defaults: black at 16px on the exported #0d0e11 + // background, a contrast ratio of about 1.09 to 1, and overrunning the + // node's label zone at nearly twice the intended size. + `.n-caption { fill: ${tok('--text-dim')}; font-size: 9px; font-family: ${mono}; }`, ].join('\n'); out.insertBefore(style, defs); return { svg: out, w, h, bg }; diff --git a/js/app-fields.js b/js/app-fields.js index af557f2..f05fb5b 100644 --- a/js/app-fields.js +++ b/js/app-fields.js @@ -103,6 +103,13 @@ class AppFields { panel.appendChild(card); const sl = new Sparkline(card, node.id, this.engine); + // Inherit the scrub immediately. _updateSparklines pushes _scrubIndex into + // the sparklines it already knows about, but a card built DURING a scrub + // (selecting another node to see what it was doing at that step) created + // one at scrubIndex null, so the hero read the replayed value while the + // trace right below it plotted the whole run and labelled the end of it. + // The same card contradicting itself, which is what this was meant to end. + sl.scrubIndex = this._scrubIndex; this._sparklines.set(node.id, sl); sl.update(); return card; @@ -407,6 +414,25 @@ class AppFields { // ── Live update helpers ─────────────────────────────────────────────────── + // Rebuild the properties panel, then put focus somewhere deliberate. + // _renderProps() tears down #props-content, so a handler triggered from a + // button inside the panel destroys the very element that had focus and it + // falls back to : no focus ring, nothing announced, and getting back to + // the row costs a Tab press for every control that survived. pick() runs + // after the rebuild and returns the element to land on. + _renderPropsFocused(pick) { + this._renderProps(); + let el = null; + try { el = pick ? pick() : null; } catch { el = null; } + if (el && typeof el.focus === 'function') el.focus(); + } + + // The panel's own "+ Add" button, the sensible landing spot after a row is + // removed: it is where the user was working and it always exists. + _panelAddButton() { + return document.querySelector('#props-content .var-add-btn'); + } + // What the properties panel should display for a node right now. While the // timeline is scrubbed that is the recorded value at the previewed step, not // the live model. The canvas and the timeline both had a scrub-aware read diff --git a/js/app-props.js b/js/app-props.js index 694e2c1..ebf18e8 100644 --- a/js/app-props.js +++ b/js/app-props.js @@ -692,7 +692,11 @@ class AppProps { delBtn.setAttribute('aria-label', 'Delete parameter'); delBtn.className = 'btn'; delBtn.style.cssText = 'padding:2px 8px;flex-shrink:0'; - delBtn.addEventListener('click', () => { delete params[key]; this._renderProps(); this._commit(); }); + delBtn.addEventListener('click', () => { + delete params[key]; + this._renderPropsFocused(() => this._panelAddButton()); + this._commit(); + }); row.appendChild(ki); row.appendChild(vi); row.appendChild(delBtn); panel.appendChild(row); } @@ -704,7 +708,10 @@ class AppProps { let k = 'param' + (Object.keys(params).length + 1); while (params[k] !== undefined) k += '_'; params[k] = 0; - this._renderProps(); + // Land on the name box of the row that was just created, which is what + // the user is about to type into. + this._renderPropsFocused(() => + document.querySelector(`#props-content input[aria-label="Name of parameter ${k}"]`)); this._commit(); }); panel.appendChild(addBtn); @@ -1737,7 +1744,12 @@ class AppProps { name.textContent = `→ ${tgtNode ? (tgtNode.label || tgtNode.type) : '?'}`; const pct = document.createElement('span'); pct.className = 'gate-out-pct'; - pct.textContent = totalW > 0 ? `${Math.round(getW(c) / totalW * 100)}%` : '0%'; + // All mode is not a split: every output takes its full weight in units + // each step (engine _fireGate), so a share of the total is simply the + // wrong number and read as a promise the gate never made. + pct.textContent = node.gateMode === 'all' + ? `${Math.round(getW(c))}/step` + : (totalW > 0 ? `${Math.round(getW(c) / totalW * 100)}%` : '0%'); head.appendChild(name); head.appendChild(pct); card.appendChild(head); @@ -1939,7 +1951,11 @@ class AppProps { const n = parseFloat(v); conn.weight = isFinite(n) ? Math.max(0, n) : 0; this.renderer.render(); }, 'output share (0 = off)'); } - this._info(panel, 'Share of the gate\'s resources routed down this output (deterministic split or weighted chance). A formula is re-evaluated each step, so the split can track variables (e.g. difficulty, gold).'); + // The same overclaim the guide carried: in All mode the weight is not a + // share of anything, it is the number of units this output takes. + this._info(panel, src.gateMode === 'all' + ? 'Units routed down this output each step. In All mode every output takes its own weight independently, so these do not add up to a share. A formula is re-evaluated each step, so it can track variables (e.g. difficulty, gold).' + : 'Share of the gate\'s resources routed down this output (Split proportion or Random weighted chance). A formula is re-evaluated each step, so the split can track variables (e.g. difficulty, gold).'); } else if (fromDelay) { // Delays release matured resources, split across outputs by rate. rateField(); diff --git a/js/editor.js b/js/editor.js index e29f36a..f41df4a 100644 --- a/js/editor.js +++ b/js/editor.js @@ -944,7 +944,13 @@ class Editor { const order = this._nodesInReadingOrder(); const at = order.findIndex(n => n.id === this.renderer.selectedId); if (at === -1 && e.shiftKey) return; // nothing selected: Shift+Tab leaves + // Both ends release. Forward past the last node was handled; backward from + // the first was not, so `next` went to -1, clamped back to 0 and reselected + // the same node forever after preventDefault had already eaten the key. + // Backward focus order through the page was simply a dead end, and a user + // who merely still had a node selected got pulled into it. if (at === order.length - 1 && !e.shiftKey) { this._select(null, null); this.renderer.render(); return; } + if (at === 0 && e.shiftKey) { this._select(null, null); this.renderer.render(); return; } e.preventDefault(); const next = at === -1 ? 0 : at + (e.shiftKey ? -1 : 1); this._keyboardGoTo(order[Math.max(0, Math.min(order.length - 1, next))]); diff --git a/js/engine.js b/js/engine.js index 192199a..c1706f0 100644 --- a/js/engine.js +++ b/js/engine.js @@ -966,6 +966,18 @@ class SimEngine { const color = n.resourceColor || DEFAULT_COLOR; return (conn.colorFilter && color !== conn.colorFilter) ? 0 : Infinity; } + // A delay and a queue do not hold their contents freely: what they show is + // mirrored by an internal queue (_queue / _fifo) that releases on its own + // schedule. pay() below reaches for takeResources, which would draw the + // count down while leaving that queue untouched, so the same units are + // handed to the partner AND released again when their time comes. That + // creates resources out of nothing every step, silently, and the count and + // the queue drift apart (measured: a delay partner grew the system total + // from 25 to 36 over twelve steps while its queue owed 2 against a count + // of 1). Nothing in transit is available to trade, so it cannot pay. + // Receiving is unaffected: _give routes an arrival through the queue + // properly, so a delay or queue on the trader's far end still works. + if (n.type === NodeType.DELAY || n.type === NodeType.QUEUE) return 0; n.reconcile(); return conn.colorFilter ? (n.colorMap[conn.colorFilter] || 0) : n.resources; }; diff --git a/js/kb.js b/js/kb.js index 4143623..9788288 100644 --- a/js/kb.js +++ b/js/kb.js @@ -53,16 +53,22 @@ const KB_ARTICLES = [ { id: 'node-gate', category: 'Nodes', title: 'Gate', keywords: 'split route distribute weight deterministic probabilistic random branch', - body: 'A gate routes incoming resources to its outputs without storing ' - + 'anything itself. Each outgoing connection carries a weight, and the gate ' - + 'splits the flow by those weights normalized against their sum, so weights ' - + 'of three and one send three-quarters of the flow one way and one-quarter ' - + 'the other, and weights of 70, 22, 7 and 1 split it into 70%, 22%, 7% and ' - + '1%. In deterministic mode that proportional split happens every step. In ' - + 'probabilistic mode the gate instead sends each whole unit to a single ' - + 'output chosen at random, higher weights more likely to win and a weight of ' - + 'zero never chosen; over many units the shares converge on the same ' - + 'percentages. A weight can be a fixed number or a formula over diagram ' + body: 'A gate passes incoming resources straight out to its outputs rather ' + + 'than accumulating them, holding at most one step of inflow in transit. ' + + 'Each outgoing connection carries a weight, and what the gate does with ' + + 'those weights depends on its routing mode. There are three. In split ' + + 'mode the weights are normalized against their sum, so weights of three ' + + 'and one send three-quarters of the flow one way and one-quarter the ' + + 'other, and weights of 70, 22, 7 and 1 split it into 70%, 22%, 7% and 1%. ' + + 'In random mode the gate sends each whole unit to a single output chosen ' + + 'at random, higher weights more likely to win and a weight of zero never ' + + 'chosen; over many units the shares converge on the same percentages. In ' + + 'all mode the weights are not shares at all: every output takes its own ' + + 'weight in units each step, so weights of three and one send three units ' + + 'down one branch and one down the other, independently. If the gate does ' + + 'not hold enough to satisfy every output, the ones it reaches first are ' + + 'served and the remainder stays put until the next step. A weight can be ' + + 'a fixed number or a formula over diagram ' + 'variables, just like a connection rate. A formula weight is re-evaluated ' + 'each step, so the split can shift as the run unfolds. Route more flow down ' + 'the hard branch as difficulty climbs, for example. Switch a gate output ' @@ -679,7 +685,8 @@ const KB_ARTICLES = [ + 'way to learn how a loop is wired before building your own. Your saved ' + 'diagrams are entries you manage yourself: give the current canvas a name ' + 'and click Save diagram to keep it, then Load to restore it later. The ' - + 'Library is reachable from the toolbar or the File menu.', + + 'Library is reachable from the Library button in the toolbar, or from ' + + 'the menu button when the screen is narrow.', }, { id: 'saving', category: 'Saving and sharing', title: 'Saving and loading', diff --git a/js/model.js b/js/model.js index c1b704e..9ad5eee 100644 --- a/js/model.js +++ b/js/model.js @@ -490,8 +490,15 @@ class MNode { // ticking over — silently became the model's new starting amount. // Infinite sources are excluded: JSON cannot carry Infinity, and loadJSON // already restores their baseline from the node type. - if (isFinite(this._initialResources) && this._initialResources !== d.resources) { - d.initialResources = this._initialResources; + // The amount and the colour mix drift independently, so they are tested + // independently. Gating the map on the amount meant a balanced loop, income + // and spend at matching rates, never recorded either: the total still read + // 20 at the moment of the write, the guard was false, and the reload rebased + // the authored mix to whatever was in transit ({orange:20} coming back as + // {orange:14, green:6}). Any colour filter or converter recipe keyed on the + // authored colour then drew from a smaller pool than the model says it has. + if (isFinite(this._initialResources)) { + if (this._initialResources !== d.resources) d.initialResources = this._initialResources; const base = this._initialColorMap || {}; const bk = Object.keys(base); const sameMap = bk.length === Object.keys(this.colorMap).length diff --git a/js/renderer.js b/js/renderer.js index 68f68ab..ae5990c 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -840,7 +840,19 @@ class Renderer { _makeNoteEl(note) { const g = svgEl('g', { 'data-id': note.id, cursor: 'pointer' }); g.appendChild(svgEl('rect', { class: 'note-bg', rx: '4', 'stroke-width': '1.5' })); - g.appendChild(svgEl('text', { class: 'note-text', 'font-size': '11', 'font-family': 'var(--font)', 'pointer-events': 'none' })); + // Clip the text to the note. Wrapping alone cannot guarantee a fit: it + // counts characters against a width, so wide glyphs and CJK overrun a line + // the wrapper believes fits. Unclipped, that text painted outside the note + // in near-black on the dark canvas, and noteLayer sits above nodeLayer so it + // covered whatever was to the right. Belt and braces with the hard break in + // _wrapNoteText. + const clip = svgEl('clipPath', { id: `noteclip-${note.id}` }); + clip.appendChild(svgEl('rect', { class: 'note-clip-rect' })); + g.appendChild(clip); + g.appendChild(svgEl('text', { + class: 'note-text', 'font-size': '11', 'font-family': 'var(--font)', + 'pointer-events': 'none', 'clip-path': `url(#noteclip-${note.id})`, + })); return g; } @@ -872,6 +884,13 @@ class Renderer { textEl.appendChild(ts); }); textEl.setAttribute('fill', '#1a1a1a'); + const clipRect = el.querySelector('.note-clip-rect'); + if (clipRect) { + clipRect.setAttribute('x', String(note.x)); + clipRect.setAttribute('y', String(note.y)); + clipRect.setAttribute('width', String(Math.max(0, note.w))); + clipRect.setAttribute('height', String(Math.max(0, note.h))); + } this._updateResizeHandles(el, note, isSel); } @@ -884,6 +903,15 @@ class Renderer { let line = ''; for (const word of words) { if (!word) continue; + // A word longer than the line can never fit by moving it down, so break + // it. Without this a pasted URL was one 57-character "word" emitted as a + // single line that ran far past the note's edge. + if (word.length > maxChars) { + if (line) { result.push(line); line = ''; } + for (let i = 0; i < word.length; i += maxChars) result.push(word.slice(i, i + maxChars)); + line = result.pop(); + continue; + } if (line && line.length + 1 + word.length > maxChars) { result.push(line); line = word; } else { diff --git a/test/run.js b/test/run.js index 7c85780..f656386 100644 --- a/test/run.js +++ b/test/run.js @@ -734,6 +734,76 @@ test('a limited source emits its stock then runs dry', () => { eq(s.produced, 10, 'produced equals the emitted stock'); }); +test('a mid-run save keeps the authored colour mix when the total is unchanged', () => { + // The amount and the colour mix drift independently. Gating the map write on + // the amount meant a balanced loop (income and spend at matching rates) never + // recorded either, so the reload rebased the authored mix to whatever was in + // transit and any colour filter keyed on the authored colour drew from less + // than the model says it holds. + const ORANGE = '#ffa726', GREEN = '#4caf50'; + const d = new Diagram(); + const gold = new MNode(NodeType.POOL, 200, 100); gold.label = 'Gold'; gold.setCount(20, ORANGE); + const mine = new MNode(NodeType.SOURCE, 0, 100); mine.label = 'Mine'; mine.resourceColor = GREEN; + const spend = new MNode(NodeType.DRAIN, 400, 100); spend.label = 'Spend'; + d.addNode(gold); d.addNode(mine); d.addNode(spend); + d.addConnection(new MConnection(mine.id, gold.id, ConnectionType.RESOURCE)); + d.addConnection(new MConnection(gold.id, spend.id, ConnectionType.RESOURCE)); + + const e = new SimEngine(d); e.reset(); + for (let i = 0; i < 6; i++) e.doStep(); + eq(gold.resources, 20, 'the balanced loop leaves the total untouched'); + assert(gold.colorMap[GREEN] > 0, 'but the mix has drifted'); + + const back = new Diagram(); back.loadJSON(d.toJSON()); + const g2 = [...back.nodes.values()].find(n => n.label === 'Gold'); + const e2 = new SimEngine(back); e2.reset(); + eq(g2.colorMap[ORANGE], 20, 'reset after reload restores the authored colour'); + eq(g2.colorMap[GREEN], undefined, 'and holds none of what was in transit'); +}); + +test('an at-rest diagram still writes no baseline fields', () => { + // The point of the guard: files that never ran stay byte-identical. + const d = new Diagram(); + const p = new MNode(NodeType.POOL, 0, 0); p.label = 'Gold'; p.setCount(20, '#ffa726'); + const s = new MNode(NodeType.SOURCE, 100, 0); s.label = 'Mine'; + d.addNode(p); d.addNode(s); + d.addConnection(new MConnection(s.id, p.id, ConnectionType.RESOURCE)); + const before = JSON.stringify(d.toJSON()); + assert(!/initial(Resources|ColorMap)/.test(before), 'no baseline fields at rest'); + const e = new SimEngine(d); e.reset(); + eq(JSON.stringify(d.toJSON()), before, 'reset() alone changes nothing on disk'); +}); + +test('a trader cannot pay out of a delay or a queue', () => { + // Their contents are mirrored by an internal queue that releases on its own + // schedule. takeResources would draw the count down and leave that queue + // intact, handing the same units to the partner and releasing them again: + // resources created from nothing, every step. + const qlen = (n) => (n._queue ? n._queue.reduce((s, b) => s + b.amount, 0) : 0) + + (n._fifo ? n._fifo.reduce((s, b) => s + b.amount, 0) : 0); + + for (const partnerType of [NodeType.DELAY, NodeType.QUEUE, NodeType.POOL]) { + const d = new Diagram(); + const gold = new MNode(NodeType.POOL, 0, 0); gold.label = 'Gold'; gold.setCount(20); + const mkt = new MNode(NodeType.TRADER, 200, 0); mkt.label = 'Market'; + const ship = new MNode(partnerType, 400, 0); ship.label = 'Shipping'; ship.setCount(5); + const spent = new MNode(NodeType.DRAIN, 600, 0); spent.label = 'Spent'; + d.addNode(gold); d.addNode(mkt); d.addNode(ship); d.addNode(spent); + d.addConnection(new MConnection(gold.id, mkt.id, ConnectionType.RESOURCE)); + d.addConnection(new MConnection(mkt.id, ship.id, ConnectionType.RESOURCE)); + d.addConnection(new MConnection(ship.id, spent.id, ConnectionType.RESOURCE)); + + const e = new SimEngine(d); e.reset(); + const conserved = () => gold.resources + ship.resources + (spent.drained || 0); + const start = conserved(); + for (let i = 0; i < 12; i++) e.doStep(); + eq(conserved(), start, `${partnerType} partner: nothing is created or destroyed`); + if (partnerType !== NodeType.POOL) { + eq(qlen(ship), ship.resources, `${partnerType} partner: queue agrees with the count`); + } + } +}); + test('.econ round-trip survives a node labelled with a DSL head keyword', () => { // dslParse dispatches on tokens[0] before scanning for an arrow, so a node // labelled `pool` that is the SOURCE of a connection emitted `pool -> Gold` diff --git a/test/smoke.js b/test/smoke.js index 9b9e582..c7df6d1 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -495,6 +495,110 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; ok('a colliding rename is rejected across the shared namespace, a real one still works'); else fail('rename guard: ' + JSON.stringify(renameGuard)); + // Round 2 findings. + + // Backward Tab had no counterpart to the forward release: at the first node it + // computed -1, clamped back to 0 and reselected forever after preventDefault. + const backTab = await page.evaluate(() => { + window.app._clearAll(); + window.app._closeFeature(); + const d = window.app.diagram; + d.addNode(new MNode(NodeType.POOL, 200, 200)); + d.addNode(new MNode(NodeType.POOL, 400, 300)); + window.app.editor.setTool('select'); + window.app.renderer.render(); + const canvas = document.getElementById('canvas'); + canvas.focus(); + const tab = (shift) => window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', shiftKey: shift, bubbles: true })); + tab(false); // select the first node + const selected = window.app.renderer.selectedId; + tab(true); // must release, not reselect + return { selected: !!selected, afterBack: window.app.renderer.selectedId }; + }); + if (backTab.selected && backTab.afterBack === null) + ok('Shift+Tab releases backward off the first node instead of reselecting it'); + else fail('backward tab: ' + JSON.stringify(backTab)); + + // Adding or deleting a rail row rebuilds #props-content, destroying the button + // that had focus and dropping it out of the panel entirely. + const rowFocus = await page.evaluate(() => { + window.app._clearAll(); + window.app._closeFeature(); + window.app.diagram.params = { alpha: 1, beta: 2 }; + window.app.renderer.render(); + window.app._commit(); + document.querySelector('#diagram-rail .rail-btn[data-feature="params"]').click(); + const label = () => { + const a = document.activeElement; + return a.tagName + (a.getAttribute && a.getAttribute('aria-label') ? `[${a.getAttribute('aria-label')}]` : ''); + }; + document.querySelector('#props-content .var-add-btn').click(); + const afterAdd = label(); + document.querySelector('#props-content button[aria-label="Delete parameter"]').click(); + const afterDelete = label(); + return { afterAdd, afterDelete }; + }); + if (/Name of parameter/.test(rowFocus.afterAdd) && rowFocus.afterDelete.startsWith('BUTTON')) + ok('adding or deleting a rail row keeps focus inside the panel'); + else fail('row focus: ' + JSON.stringify(rowFocus)); + + // Note text wrapped only on spaces and was never clipped, so a pasted URL ran + // far outside the note in near-black, over whatever sat to the right. + const noteFit = await page.evaluate(() => { + window.app._clearAll(); + window.app._closeFeature(); + const n = window.app.diagram.addNote(new MNote(200, 200)); + n.w = 160; n.h = 80; + n.text = 'Rebalance per https://example.com/economy/balance-notes/2026-08-28-draft'; + window.app.renderer.render(); + const el = document.querySelector('.sticky-note .note-text'); + const bb = el.getBBox(); + return { + spill: Math.round((bb.x + bb.width) - (n.x + n.w)), + clipped: !!el.getAttribute('clip-path'), + lines: el.querySelectorAll('tspan').length, + }; + }); + if (noteFit.spill <= 0 && noteFit.clipped && noteFit.lines > 1) + ok('a long unbroken word in a note wraps and stays inside its bounds'); + else fail('note fit: ' + JSON.stringify(noteFit)); + + // The export style block omitted .n-caption entirely (black on the exported + // dark background) and only set font-family on elements already carrying it. + const expStyles = await page.evaluate(() => { + window.app._clearAll(); + window.app._closeFeature(); + const c = window.app.diagram.addNode(new MNode(NodeType.CONVERTER, 300, 300)); + c.inputAmount = 2; + window.app.renderer.render(); + const txt = new XMLSerializer().serializeToString(window.app._buildExportSVG().svg); + return { + caption: /\.n-caption\s*\{/.test(txt), + captionBlack: /\.n-caption[^}]*fill:\s*(#000|black)/i.test(txt), + labelFont: /\.n-label[^}]*font-family/.test(txt), + }; + }); + if (expStyles.caption && !expStyles.captionBlack && expStyles.labelFont) + ok('exported SVG carries the caption rule and a real font for every label'); + else fail('export styles: ' + JSON.stringify(expStyles)); + + // The gate guide described only two of the three routing modes and claimed a + // gate stores nothing, both false once All mode exists. + const gateDocs = await page.evaluate(() => { + const gate = KB_ARTICLES.find(a => a.id === 'node-gate'); + const lib = KB_ARTICLES.find(a => /library/i.test(a.id) || /Library/i.test(a.title)); + return { + mentionsAll: /all mode/i.test(gate.body), + claimsNoStore: /without storing/i.test(gate.body), + libFileMenu: lib ? /File menu/i.test(lib.body) : true, + emDash: KB_ARTICLES.some(a => a.body.includes('\u2014')), + }; + }); + if (gateDocs.mentionsAll && !gateDocs.claimsNoStore && !gateDocs.libFileMenu && !gateDocs.emDash) + ok('guide describes all three gate modes and points at controls that exist'); + else fail('kb copy: ' + JSON.stringify(gateDocs)); + // Navigation: zoom controls step the scale and update the readout; fit-to-content // re-frames without error. const nav = await page.evaluate(() => {