Skip to content

Round-5 review fixes: two PR #64 regressions, undo/redo, embed, engine, a11y - #65

Merged
zntznt merged 6 commits into
mainfrom
claude/ui-ux-run-4qmy5r
Aug 31, 2026
Merged

zntznt merged 6 commits into
mainfrom
claude/ui-ux-run-4qmy5r

Conversation

@zntznt

@zntznt zntznt commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Fixes all fifteen findings from the fifth multi-agent review, which covered six surfaces no previous round had touched (clipboard/groups, embed mode and share URLs, checkpoints and interactive nodes, numeric edge cases, accessibility) plus round 4's own diff.

Each fix has a test that I verified fails against the unpatched code first. 255 unit tests, smoke green.

Two of these are regressions from PR #64

Both were introduced by round-4 fixes merged yesterday, and no existing test caught either.

Monte Carlo progress stopped updating entirely. The async driver drains yields for a 14ms chunk then inspects the last one, and reported progress only when that last yield ended a trial. Once the generator began yielding after every step (the round-4 responsiveness fix), well under 1% of yields ended a trial, so a chunk almost never stopped on one: the dialog sat at "Running..." with a 0% bar for the whole batch. Measured against the pre-PR engine on the same model, 1000x300 gave 76 progress callbacks before and 0 after; restored to 85.

The smoke test I wrote for that change checked that Cancel appears and the run buttons disable. It never checked the counter still moved.

Typing in a Delay's Amount field scrambled its release schedule. _field commits on the input event, once per keystroke, which was harmless when the handler was a plain setCount but is destructive now that it routes to SimEngine.setLiveCount. Typing "12" committed 1 first, physically discarding the units and timers the other 11 stood for. Measured: a batch of 12 due at step 5 arrived split as 1 at step 5 and 11 at step 8.

Findings fixed

# Sev Area Fix
3 high editor undo and redo flush a pending arrow-key nudge first
5 high embed _persistAutosave is a no-op in embed mode
8 high engine run baseline captured once; an interactive fire at step 0 takes it first
12 high engine a Split gate with every weight at 0 routes nothing, like Random and All
1 medium analysis Monte Carlo reports progress again
2 medium properties Delay and Queue amount edits commit on change, not per keystroke
4 medium app snapshots compared with the id counter excluded, so a no-op commit keeps redo
6 medium embed the overflow menu no longer hands back every hidden control
7 medium embed the escape link strips the embed marker from the hash too
9 medium player a rule whose target is gone says so instead of naming another node
10 medium checkpoints forking leaves replay first
11 medium engine a trader cannot hand resources to another trader
13 medium a11y canvas keyboard selection is announced through a live region
14 medium a11y the zoom readout's accessible name carries the live percentage
15 medium a11y the replay button's accessible name follows the tooltip

Notable detail

Findings 3 and 4 compound into unrecoverable loss. Place two nodes, nudge one with an arrow key, press Ctrl+Z inside the 400ms coalescing window: the second node disappears instead of the nudge being undone. The pending commit then lands and, through the id-counter bug, wipes the redo stack, so the node cannot be brought back at all.

Finding 5 is the worst by blast radius. Embed mode strips the editing chrome but leaves the canvas editable, and every edit reached the same-origin sim_autosave key. A visitor who so much as dragged a node in someone's embedded diagram had their own saved work silently replaced by it.

Finding 11 was already documented as the intended rule. _acceptable says in a comment that a trader never holds resources and refuses it as a flow target. The trader's own canAccept rejected only sources and registers, so a trader used as another trader's partner took the payment and kept it invisibly: node cli.js on the reported model printed 10,80,10,0, with the missing 20 stored on a node whose displayed count, chart value and history entry all read 0.

Measurements

  • Monte Carlo progress callbacks over 1000 runs x 300 steps: 76 before PR Round-4 review fixes: pipeline/count desync, persistence, CLI, analysis, export #64, 0 on main, 85 now.
  • Delay retype: pool arrivals [0,0,1,1,12,...] on main, [0,0,12,12,...] now, identical to no edit at all.
  • Trader leak: 10,80,10,0 on main, 10,100,0,0 now.
  • Gate with all weights 0: Split emptied 30 units into its outputs, Random and All held them; all three hold now.

Testing

  • node test/run.js — 255 passed (was 251 on main)
  • npm run smoke — passed, no console or page errors
  • Every new test baselined against the unpatched code: each fails without its fix.

One note on the review itself

The refute stage returned its first two refutations in five rounds, both substantive. One of them varied a delay's output rate across 0, 1, 3, 5 and 100 and got identical output every time, refuting the finding's premise. That implies something the review did not file: a delay's sole-output rate appears to be inert at every value. Not addressed here.


Generated by Claude Code

claude added 6 commits August 31, 2026 02:24
Both were introduced by round-4 fixes and neither existing test caught them.

Monte Carlo progress stopped updating entirely. The async driver drains yields
for a 14ms chunk and then inspects the last one, and it reported progress only
when that last yield ended a trial. Once the generator began yielding after
every step (the round-4 responsiveness fix), well under 1% of yields ended a
trial, so a chunk almost never stopped on one: the dialog sat at 'Running...'
with a 0% bar for the whole batch, sweeps and sensitivity runs included.
Measured against the pre-PR engine on the same model: 100x200 gave 8 progress
callbacks before and 0 after, 1000x300 gave 76 before and 0 after. Every yield
already carries the completed-trial count, so the chunk's last yield is always
current: report it whether or not it ended a trial. Restored to 8 and 85.

Typing into a Delay's or Queue's live Amount field scrambled its schedule.
_field commits on the input event, once per keystroke, which was harmless when
the handler was a plain setCount but is destructive now that it routes to
SimEngine.setLiveCount: typing '12' committed 1 first, physically discarding
the units and timers the other 11 stood for, then re-added 11 on a fresh full
delay. Measured through the real UI: a batch of 12 due at step 5 arrived split
as 1 at step 5 and 11 at step 8. _field takes a commitOn event now, and the
amount row passes 'change' for a Delay or Queue so the edit applies once the
user has finished typing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
Round-5 findings 3 and 4, which compound into one unrecoverable loss.

Snapshots carry the module-level id counter, and loadJSON only ever raises it
so that ids handed out since a snapshot cannot collide with it. Restoring a
snapshot therefore never reproduces its own text, so _commit's 'did anything
actually change?' test always reported a change once an undo had happened, and
the next commit, even one that changed nothing at all, pushed an undo entry and
cleared the redo stack. Snapshots are now compared with the counter excluded.

Arrow-key nudges are coalesced for 400ms so a held key is a single undo step,
but the commit was only ever armed on a timer. A Ctrl+Z inside that window
stepped straight past the nudge and undid the edit before it: place two pools,
nudge, Ctrl+Z, and the second pool disappears. The pending commit then landed
and, through the bug above, wiped the redo stack, so it could not be brought
back at all. Undo and redo now flush a pending nudge first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
…other leaks

Round-5 findings 5, 6 and 7.

Embed mode strips the editing chrome but leaves the canvas fully editable, and
every edit reached _persistAutosave, which writes the same-origin sim_autosave
key. A visitor who so much as dragged a node in someone's embedded diagram had
their own saved diagram silently replaced by it, and found the embed's content
waiting for them on their next visit with no undo stack left. _persistAutosave
is now a no-op in embed mode.

The body.embed rules hide the palette, properties panel, Setup rail and the
file and analysis controls, but not the overflow menu button, which a
max-width: 768px media query shows. Most iframes are narrower than that, so the
menu handed back every control those rules had just removed, New diagram and
Open file included. It is hidden in embed mode now.

The 'open in Simulations' link stripped the embed marker only from the query
string, so for the #embed hash form the knowledge base documents, the link
pointed straight back at the embed. It now strips the marker from the hash too,
including the #d=...&embed shape a shared embed actually has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
…s everything, and a lost baseline

Round-5 findings 8, 11 and 12.

_acceptable already refuses a trader as a flow target, on the grounds that a
trader never holds resources, but the trader's own canAccept rejected only
sources and registers. A trader used as another trader's partner therefore took
the payment and kept it forever, invisibly: its canvas number, chart value and
history entry all report the trade count rather than what it holds, so the
units simply left the economy. Measured on the reported model, Gold fell from
100 to 80 over ten steps with the 20 sitting on the partner as a hidden holding
that a mid-run save wrote into the file.

A gate output weight of 0 means off, which the panel says in as many words and
shows as 0%. Deterministic Split routed through _proportionalShares, whose
zero-total fallback spreads the amount evenly. That is right for a delay
handing a matured batch to its outputs, and the opposite of what a gate's
weights mean: a gate with every weight at 0 emptied itself into every output at
once, while Random and All correctly held everything. Split now holds too.

run() captures the reset baseline at step 0 and the first doStep() captured it
again, so anything that changed state between them was recorded as the
diagram's authored starting amount. Clicking an interactive node in the gap
before the first tick therefore rewrote its starting amount permanently: Reset
returned to the post-click value and a save carried it. The baseline is now
captured once per run, and an interactive fire at step 0 takes it first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
…rking

Round-5 findings 9 and 10.

An artificial-player rule keeps its target node id, but the node can be deleted
or have its activation switched away from interactive. The dropdown then fell
back to displaying the first interactive node in the diagram, so the panel
claimed the rule was wired to a node it has nothing to do with while the rule
silently never fired. It now shows '(node deleted)' and says the rule will not
fire until another node is picked.

Forking from a checkpoint did not leave history-scrub mode. Scrub paints a past
step's values over the live model, so the canvas kept showing the replayed
step's numbers under the checkpoint's step label, with the slider still
pointing into a history the fork had just discarded. Measured: replaying step 2
of a run and forking a step-5 checkpoint left the canvas reading 10 under a
'Step 5' label. _forkFrom now exits scrub first and refreshes the scrubber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
…names

Round-5 findings 13, 14 and 15.

Keyboard selection moves inside the SVG, so DOM focus never leaves the canvas
and assistive technology was told nothing at all: tabbing through a diagram was
completely silent, and the properties panel that repaints is neither focused
nor a live region. Tab and Shift+Tab now write what they landed on to a
visually hidden polite live region: node type, label, current value, and
position in the reading order.

The zoom readout's visible text is updated live but its accessible name was
static markup, so it announced 'Current zoom. Click to reset to 100%' at every
zoom level. It now carries the live percentage.

The timeline's replay button swaps its icon and its tooltip when replay starts
but its aria-label was static markup too, so it announced 'Replay the run'
while it was the Pause control. The name now follows the tooltip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
@zntznt
zntznt merged commit 9d4292b into main Aug 31, 2026
1 check passed
@zntznt
zntznt deleted the claude/ui-ux-run-4qmy5r branch August 31, 2026 03:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants