Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions js/app-analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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');
Expand Down
15 changes: 12 additions & 3 deletions js/app-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
26 changes: 26 additions & 0 deletions js/app-fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <body>: 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
Expand Down
24 changes: 20 additions & 4 deletions js/app-props.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions js/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))]);
Expand Down
12 changes: 12 additions & 0 deletions js/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
29 changes: 18 additions & 11 deletions js/kb.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '
Expand Down Expand Up @@ -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',
Expand Down
11 changes: 9 additions & 2 deletions js/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion js/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}

Expand All @@ -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 {
Expand Down
Loading
Loading