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
14 changes: 8 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,14 @@ for whole-diagram swaps).

## Conventions

- **CSS design tokens** in `css/style.css` `:root` (`--bg`, `--panel`, `--panel2`,
`--border`, `--text`, `--text-dim`, `--accent #4a9eff`, `--red`, `--green`,
`--font`). Style with tokens, not hardcoded hex. Note: `.btn-primary` and the
running Run button use darker shades (`#1565c0` / `#2e7d32`) rather than raw
`--accent`/`--green` to meet WCAG AA contrast against white text — match that when
putting text on a colored fill.
- **CSS design tokens** in `css/style.css` `:root` ("Graphite · Lime" system:
`--bg`, `--canvas`, `--panel`, `--panel2`, `--border`, `--border-soft`, `--text`,
`--text-bright`, `--text-dim`, `--text-faint`, `--accent #b6e94d` lime,
`--accent-ink #14151a` for text on lime, `--accent-tint`, `--red`, `--green`,
`--font` Space Grotesk, `--mono` JetBrains Mono). Style with tokens, not
hardcoded hex. Every numeric value renders in `--mono`. Text on a lime fill is
always `--accent-ink` (dark), never white — that pairing is what passes WCAG AA.
Fonts are vendored in `vendor/fonts/` (no Google Fonts requests at runtime).
- **Shared App helpers:** `_faIcon(name)` (Font Awesome `<i aria-hidden>`),
`_toast(msg)`, and `_confirmGuard(message, title)` (Promise-based styled confirm —
use instead of `confirm()`).
Expand Down
878 changes: 631 additions & 247 deletions css/style.css

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
128 changes: 69 additions & 59 deletions index.html

Large diffs are not rendered by default.

37 changes: 24 additions & 13 deletions js/app-analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,13 @@ class AppAnalysis {
// Sets this._mcCancel, which every runner passes to the engine as shouldCancel.
_mcBeginProgress(out, label) {
this._mcCancel = false;
out.innerHTML = '<p class="mc-empty"><span class="mc-prog-text"></span>'
+ '<button class="btn mc-cancel-btn" id="mc-cancel">Cancel</button></p>';
// Mono status line + slim lime progress bar + a red-outline Cancel. The
// block is rendered once; updates only touch the text and the bar width,
// so the click handler survives.
out.innerHTML = '<div class="mc-progress"><p class="mc-empty">'
+ '<span class="mc-prog-text"></span>'
+ '<button class="btn mc-cancel-btn" id="mc-cancel">Cancel</button></p>'
+ '<div class="mc-progress-bar"><div style="width:0%"></div></div></div>';
out.querySelector('.mc-prog-text').textContent = label;
document.getElementById('mc-cancel').addEventListener('click', () => {
this._mcCancel = true;
Expand All @@ -64,10 +69,14 @@ class AppAnalysis {
this._mcSetRunning(true);
}

_mcSetProgress(out, label) {
_mcSetProgress(out, label, done = null, total = null) {
const t = out.querySelector('.mc-prog-text');
if (t) t.textContent = label;
else out.innerHTML = `<p class="mc-empty">${this._esc(label)}</p>`;
if (done != null && total > 0) {
const bar = out.querySelector('.mc-progress-bar > div');
if (bar) bar.style.width = `${Math.round(done / total * 100)}%`;
}
}

// Disable the three run buttons during a batch (and restore each to its prior
Expand Down Expand Up @@ -101,7 +110,7 @@ class AppAnalysis {
const res = await this.engine.runMonteCarloAsync(runs, steps, {
seed: this._mcSeed() || null,
shouldCancel: () => this._mcCancel,
onProgress: (done, total) => this._mcSetProgress(out, `Running… ${done} / ${total}`),
onProgress: (done, total) => this._mcSetProgress(out, `Running… ${done} / ${total}`, done, total),
});
if (!res) { out.innerHTML = '<p class="mc-empty">Cancelled.</p>'; return; }
const ms = Math.round(performance.now() - t0);
Expand Down Expand Up @@ -227,7 +236,8 @@ class AppAnalysis {
seed,
shouldCancel: () => this._mcCancel,
onProgress: (done, total) => this._mcSetProgress(out,
`Sweeping ${name} = ${values[i]} (${i + 1}/${values.length}), run ${done}/${total}`),
`Sweeping ${name} = ${values[i]} (${i + 1}/${values.length}), run ${done}/${total}`,
i * total + done, values.length * total),
});
if (!res) { out.innerHTML = '<p class="mc-empty">Cancelled.</p>'; return; }
results.push(res);
Expand Down Expand Up @@ -302,7 +312,8 @@ class AppAnalysis {
const totalBatches = 1 + params.length * 2;
let batch = 0;
const prog = (label) => (done, total) => this._mcSetProgress(out,
`Sensitivity: ${label} (batch ${batch}/${totalBatches}), run ${done}/${total}`);
`Sensitivity: ${label} (batch ${batch}/${totalBatches}), run ${done}/${total}`,
(batch - 1) * total + done, totalBatches * total);
const cancelled = () => { out.innerHTML = '<p class="mc-empty">Cancelled.</p>'; };

try {
Expand Down Expand Up @@ -466,7 +477,7 @@ class AppAnalysis {
inp.type = 'text';
inp.value = src;
inp.placeholder = 'always gold < 500';
inp.style.cssText = 'flex:1;font-family:monospace;font-size:11px;min-width:0;';
inp.style.cssText = 'flex:1;font-family:var(--mono);font-size:11px;min-width:0;';
const validate = () => {
let ok = true;
try { parseAssertion(inp.value); } catch { ok = false; }
Expand Down Expand Up @@ -663,7 +674,7 @@ class AppAnalysis {
chain.style.cssText = 'font-size:11px;word-break:break-word;';
chain.textContent = [...loop.labels, loop.labels[0]].join(' → ');
const detail = document.createElement('div');
detail.style.cssText = 'font-size:10px;color:var(--text-dim);font-family:monospace;';
detail.style.cssText = 'font-size:10px;color:var(--text-dim);font-family:var(--mono);';
detail.textContent = loop.links.map(l => `${signGlyph(l.sign)} ${l.kinds.join('/')}`).join(', ');
body.appendChild(chain); body.appendChild(detail);

Expand Down Expand Up @@ -704,8 +715,8 @@ class AppAnalysis {
pop.id = 'why-popover';
pop.setAttribute('role', 'dialog');
pop.setAttribute('aria-label', `Change breakdown for ${data.label}`);
pop.style.cssText = 'position:fixed;z-index:1000;max-width:300px;min-width:190px;'
+ 'background:var(--panel);border:1px solid var(--border);border-radius:8px;'
pop.style.cssText = 'position:fixed;z-index:1000;max-width:300px;min-width:230px;'
+ 'background:var(--panel);border:1px solid var(--accent);border-radius:10px;'
+ 'padding:10px 12px;font-size:11px;color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.45);';

const head = document.createElement('div');
Expand All @@ -720,7 +731,7 @@ class AppAnalysis {
pop.appendChild(head);

const deltaLine = document.createElement('div');
deltaLine.style.cssText = 'font-family:monospace;font-size:11px;margin-bottom:6px;';
deltaLine.style.cssText = 'font-family:var(--mono);font-size:11px;margin-bottom:6px;';
deltaLine.textContent = data.initial
? `starts at ${fmt(data.to)}`
: `${fmt(data.from)} → ${fmt(data.to)} (Δ ${signed(data.delta)})`;
Expand All @@ -730,7 +741,7 @@ class AppAnalysis {
const r = document.createElement('div');
r.style.cssText = 'display:flex;gap:8px;align-items:baseline;margin:2px 0;';
const a = document.createElement('span');
a.style.cssText = 'font-family:monospace;min-width:44px;text-align:right;flex-shrink:0;'
a.style.cssText = 'font-family:var(--mono);min-width:44px;text-align:right;flex-shrink:0;'
+ `color:${amount > 0 ? 'var(--green)' : (amount < 0 ? 'var(--red)' : 'var(--text-dim)')};`;
a.textContent = signed(amount);
const t = document.createElement('span');
Expand Down Expand Up @@ -821,7 +832,7 @@ class AppAnalysis {
r.style.cssText = 'display:flex;align-items:baseline;gap:6px;margin:3px 0;';
r.appendChild(chip(pass, pass ? 'PASS' : 'FAIL'));
const t = document.createElement('span');
t.style.cssText = 'font-family:monospace;font-size:11px;word-break:break-word;';
t.style.cssText = 'font-family:var(--mono);font-size:11px;word-break:break-word;';
t.textContent = label;
r.appendChild(t);
container.appendChild(r);
Expand Down
48 changes: 29 additions & 19 deletions js/app-clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ class AppClipboard {
// old right-click-to-delete gesture with a discoverable menu, and surfaces the
// otherwise keyboard-only actions (copy, paste, duplicate, save-as-component).

_showContextMenu(ctx, x, y) {
// Generic popup on the shared ctx-menu chrome: `build(add, sep)` fills the
// items, then the menu is positioned, focus-managed and dismissed exactly
// like the canvas context menu. Also used by the Library row "…" overflow.
_openMenu(x, y, build) {
this._hideContextMenu(); // clear any prior instance (and its listeners)
const menu = document.getElementById('ctx-menu');
menu.innerHTML = '';
Expand All @@ -82,25 +85,8 @@ class AppClipboard {
menu.appendChild(b);
};
const sep = () => { const d = document.createElement('div'); d.className = 'menu-sep'; d.setAttribute('role', 'separator'); menu.appendChild(d); };
const hasClip = !!(this._clipboard && this._clipboard.nodes && this._clipboard.nodes.length);

if (ctx.kind === 'node') {
const n = ctx.count || 1;
const noun = n > 1 ? `${n} nodes` : 'node';
add('Duplicate', 'clone', () => this._duplicate(), { shortcut: 'Ctrl+D' });
add('Copy', 'copy', () => this._copy(), { shortcut: 'Ctrl+C' });
add('Save as component…', 'shapes', () => this._saveComponentPrompt());
sep();
add(`Delete ${noun}`, 'trash-can', () => this._contextDelete(ctx), { shortcut: 'Del', danger: true });
} else if (ctx.kind === 'element') {
const nouns = { conn: 'connection', group: 'group', note: 'note', chart: 'chart' };
add(`Delete ${nouns[ctx.type] || 'item'}`, 'trash-can', () => this._contextDelete(ctx), { shortcut: 'Del', danger: true });
} else {
add('Paste', 'paste', () => this._paste(), { shortcut: 'Ctrl+V', disabled: !hasClip });
add('Select all', 'object-group', () => this._selectAll(), { shortcut: 'Ctrl+A' });
sep();
add('Fit to view', 'expand', () => this.renderer.fitView(), { shortcut: 'Ctrl+0' });
}
build(add, sep);

// When opened by keyboard (Shift+F10 / Menu key), x and y are 0 — centre on the canvas.
if (!x && !y) {
Expand Down Expand Up @@ -148,6 +134,29 @@ class AppClipboard {
menu.addEventListener('keydown', this._ctxKeyNav);
}

_showContextMenu(ctx, x, y) {
const hasClip = !!(this._clipboard && this._clipboard.nodes && this._clipboard.nodes.length);
this._openMenu(x, y, (add, sep) => {
if (ctx.kind === 'node') {
const n = ctx.count || 1;
const noun = n > 1 ? `${n} nodes` : 'node';
add('Duplicate', 'clone', () => this._duplicate(), { shortcut: 'Ctrl+D' });
add('Copy', 'copy', () => this._copy(), { shortcut: 'Ctrl+C' });
add('Save as component…', 'shapes', () => this._saveComponentPrompt());
sep();
add(`Delete ${noun}`, 'trash-can', () => this._contextDelete(ctx), { shortcut: 'Del', danger: true });
} else if (ctx.kind === 'element') {
const nouns = { conn: 'connection', group: 'group', note: 'note', chart: 'chart' };
add(`Delete ${nouns[ctx.type] || 'item'}`, 'trash-can', () => this._contextDelete(ctx), { shortcut: 'Del', danger: true });
} else {
add('Paste', 'paste', () => this._paste(), { shortcut: 'Ctrl+V', disabled: !hasClip });
add('Select all', 'object-group', () => this._selectAll(), { shortcut: 'Ctrl+A' });
sep();
add('Fit to view', 'expand', () => this.renderer.fitView(), { shortcut: 'Ctrl+0' });
}
});
}

_hideContextMenu() {
const menu = document.getElementById('ctx-menu');
if (!menu || menu.classList.contains('hidden')) return;
Expand Down Expand Up @@ -192,6 +201,7 @@ class AppClipboard {
_saveComponentPrompt() {
if (!this.editor.selection.size) { this._toast('Select nodes first to save a component.'); return; }
this._openLibrary();
this._setLibraryTab('components');
const input = document.getElementById('comp-name');
if (input) { input.scrollIntoView({ block: 'center' }); input.focus(); }
}
Expand Down
2 changes: 1 addition & 1 deletion js/app-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class AppExport {
// The grid/dot patterns track the live pan/zoom; content exports untransformed.
for (const p of defs.querySelectorAll('pattern')) p.removeAttribute('patternTransform');
out.appendChild(defs);
const bg = r._bgRect.getAttribute('fill') || '#0f1117';
const bg = r._bgRect.getAttribute('fill') || '#0d0e11';
out.appendChild(svgEl('rect', { x, y, width: w, height: h, fill: bg }));
out.appendChild(svgEl('rect', { x, y, width: w, height: h, fill: 'url(#grid)' }));
out.appendChild(content);
Expand Down
77 changes: 66 additions & 11 deletions js/app-fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,46 @@ class AppFields {
panel.appendChild(wrap);
}

// Hero readout card (design 4a): the node's headline number, big and mono on
// a canvas-dark card, with a context line and the history sparkline below.
_heroCard(panel, node) {
const card = document.createElement('div');
card.className = 'hero-card';

const label = document.createElement('div');
label.className = 'hero-card-label';
label.textContent =
node.type === NodeType.DRAIN ? 'Consumed' :
node.type === NodeType.TRADER ? 'Trades' :
node.type === NodeType.REGISTER ? 'Value' :
node.type === NodeType.QUEUE ? 'In line' : 'Current';
card.appendChild(label);

const val = document.createElement('div');
val.className = 'hero-card-value';
val.id = 'props-hero-value';
val.textContent = String(node.displayCount);
card.appendChild(val);

let subText = '';
if (node.capacity !== undefined && isFinite(node.capacity)) subText = `of ${node.capacity} capacity`;
else if (node.type === NodeType.DRAIN || node.type === NodeType.TRADER) subText = 'this run';
else if (node.type === NodeType.REGISTER && node.formula) subText = `ƒx ${node.formula}`;
if (subText) {
const sub = document.createElement('div');
sub.className = 'hero-card-sub';
sub.textContent = subText;
card.appendChild(sub);
}

panel.appendChild(card);

const sl = new Sparkline(card, node.id, this.engine);
this._sparklines.set(node.id, sl);
sl.update();
return card;
}

_info(panel, text) {
const p = document.createElement('p');
p.className = 'props-info';
Expand Down Expand Up @@ -300,6 +340,28 @@ class AppFields {
return picker;
}

// A row of mode chips (16a): one always active, click to switch. `options`
// is [value, label] pairs or plain strings.
_chipRow(panel, options, value, onChange, ariaLabel = 'Mode') {
const row = document.createElement('div');
row.className = 'var-chip-group chip-row';
row.setAttribute('role', 'radiogroup');
row.setAttribute('aria-label', ariaLabel);
for (const opt of options) {
const [v, t] = Array.isArray(opt) ? opt : [opt, opt.charAt(0).toUpperCase() + opt.slice(1)];
const b = document.createElement('button');
b.type = 'button';
b.className = 'var-chip' + (v === value ? ' active' : '');
b.setAttribute('role', 'radio');
b.setAttribute('aria-checked', String(v === value));
b.textContent = t;
b.addEventListener('click', () => { if (v !== value) onChange(v); });
row.appendChild(b);
}
panel.appendChild(row);
return row;
}

_select2(panel, label, options, value, onChange) {
const row = document.createElement('div');
row.className = 'prop-row';
Expand Down Expand Up @@ -328,17 +390,10 @@ class AppFields {
const node = this.diagram.nodes.get(this._selectedId);
if (!node || node.type === NodeType.SOURCE) return;

if (node.type === NodeType.REGISTER) {
const rv = document.querySelector('#props-content .reg-value');
if (rv) rv.textContent = `= ${node.displayCount}`;
return;
}

if (node.type === NodeType.DRAIN) {
const el = document.querySelector('#props-content .drain-stat');
if (el) el.textContent = `${node.drained || 0}`;
return;
}
// Big hero readout tracks the live value for every node kind.
const hero = document.getElementById('props-hero-value');
if (hero) hero.textContent = String(node.displayCount);
if (node.type === NodeType.REGISTER || node.type === NodeType.DRAIN) return;

// First number input is always the Resources field.
const inp = document.querySelector('#props-content input[type="number"]');
Expand Down
Loading
Loading