Skip to content

UI/UX run: repair the first-run path, and 20-odd fixes across the app - #52

Open
zntznt wants to merge 11 commits into
mainfrom
claude/ui-ux-run-4qmy5r
Open

UI/UX run: repair the first-run path, and 20-odd fixes across the app#52
zntznt wants to merge 11 commits into
mainfrom
claude/ui-ux-run-4qmy5r

Conversation

@zntznt

@zntznt zntznt commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Drove the real app in Chromium across viewports, measured layouts, and reproduced every defect before fixing it. 11 commits, 16 files. 230/230 unit tests, smoke passing, zero console errors.

The headline: the first run was broken

Two faults sat on the exact path a new user takes, and either alone made the app look broken.

The tour dead-ended on step 3. The coach card is placed beside its target, so when the target is a palette tool it lands on the canvas just right of the strip, which is where a first diagram naturally goes. The card took the pointer, so it swallowed the drag its own step was asking for. That step advances by watching for the edit and shows no Next button, so the tour simply stopped at "drag from the Source to the Pool" with nothing but "Skip tour" left. The card is click-through now; only its buttons take the pointer.

Behind it, the first simulation threw on every tick. SimEngine seeded its flow accumulator in reset() but not in the constructor. Every path that loads something (templates, autosave, share links, the Reset button) resets first, so this only showed on the one path that does not: boot on an empty canvas, draw a Source into a Pool, press Run. The first transfer threw, and since that happens before _record and onStep, the step counter stayed at 0 and the timeline, scrubber and sparklines stayed empty while the numbers on the canvas kept climbing. Six uncaught errors per run.

The unit-test helper resets before stepping, which is why nothing covered it. The new regression test steps a fresh engine deliberately.

Data that was invisible or wrong

  • Negative series were never drawn. The linear y domain was hardcoded 0..max, so a register holding -20 mapped below the plot floor: the legend showed a chip for a series with no line anywhere. The domain now spans [min, max] with min floored at 0, so all-positive charts keep their zero baseline and shape.
  • Editing a pool's amount wiped its resource type. setCount defaults to untyped grey, so retyping Rabbits from 80 to 90 turned the node grey on canvas and switched "Holdings by type" from "Rabbits" to a raw hex.
  • The hero readout and holdings did not follow the edit, leaving "CURRENT 80" directly above "Starting amount 90" for the same node.
  • A mid-run edit rewrote the reset baseline, so Reset came back to a number typed mid-run instead of the run's start, while the label promised a transient nudge.
  • A Trader's End/goal got overwritten with its resource count on the first step, because the per-step refresh wrote into the first number input it found.

Things you could not reach

  • Nine Setup panels and Reset had no entry point on touch. Below 768px the rail is display:none and Reset is stripped from the topbar, but the ☰ menu offered neither. It now lists all of them, built from the rail's own buttons so the lists cannot drift apart.
  • The properties sheet covered the tool strip with no way out but knowing that a tap on bare canvas dismisses it. Added a close button.
  • At 320px the ☰ was pushed off screen — 15px of a 44px target — and it is the only route to Library, File, Help and the Setup panels.
  • The welcome modal's primary CTA sat entirely outside the viewport on phones; at 320px the second button went with it.

Keyboard leaked through open dialogs

Clicking a dialog's own chrome drops focus to <body>, and from there the window-level handlers still fired at the canvas behind it: Ctrl+A selected every node, Ctrl+V pasted, Delete deleted, S/D/R/T swapped tools, all invisibly. Escape had the opposite problem: _modalize binds to the overlay, so once focus left it the dialog could not be dismissed by keyboard at all. Also fixed a focus trap built in reverse DOM order that let Tab walk out of the confirm dialog, a guard that clobbered the shared return-focus field, and dropdowns that reset focus to <body> on close.

The rest

Palette and Setup rail scroll affordances (5 of 13 tools sit below the fold at 1366×768 behind an invisible overlay scrollbar); round chart axis ticks instead of raw quarters of the data max; hover tooltips that printed 16-digit floats and ran off the canvas; the compare panel that wanted 416px in a 92px drawer; a 29-series legend that squeezed the plot to 68px; a retina-blurry canvas; Escape now disarms the Delete tool; and a copy pass for one spelling, sentence case, and a panel that told users to use a "Filter" node that does not exist.

Testing

npm test 230/230 (one new regression test), npm run smoke passing, no console or page errors. Every fix was reproduced in the browser first and re-verified after; a parallel audit independently found the engine bug with the same root cause.

Deliberately left

  • The palette still overflows. Made discoverable with a fade and caret rather than refitted; making it actually fit needs a two-column rail at short heights, which changes the app's signature layout.
  • The canvas is role="application" and tabbable with no keyboard operations. Honouring that means building node-cursor navigation, a feature rather than a fix.

Generated by Claude Code

claude added 11 commits August 13, 2026 21:09
Seven fixes found by driving the app and measuring the real layout.

Palette: the tool strip is 1086px tall, so at 1366x768 five node tools sit
below the fold and at 1024x768 six do, behind an overlay scrollbar that
reserves no width and stays hidden until you scroll. Added edge fades driven
from the scroll position, the bottom one carrying a caret, and gave the
scrollbar thumb a colour that reads on the panel.

Timeline scrub row: a chip reading "Live" sat directly beside a disabled
button reading "Live", which looked like one control rendered twice. The chip
is now purely a position readout and the button only appears while scrubbing,
relabelled "Back to live". Also drops the lowercase "live" variant.

Properties panel: the amount field flipped its own label between the reset
baseline and the live count, but the panel is never rebuilt mid-run, so it
went on reading "Starting amount" while showing a mid-run value. The label is
now patched per step alongside the value, from a shared pair of constants.

Reset destroyed the selected node's sparkline and nothing rebuilt it, so the
chart stayed blank through every later run until the node was reselected.
Reset now re-renders the panel.

The sparkline also drew an empty box and a stray midline before a run had two
points, leaving a broken-looking chart above every node's fields. It stays
collapsed until there is a line to draw.

Property labels were title-cased in CSS over strings already written in
sentence case, so the guide's "Amount (live)" rendered as "Amount (Live)" and
the inline condition lead read "Stop When Value" mid-sentence. Dropped the
transform and matched the one Title Case string in source.

Timeline y-axis: grid lines sat at raw quarters of the data max, giving
labels like 23.5 / 47 / 70.5. They now snap to a 1/2/5 ladder. Tick
formatting also gains precision below 0.1, where every label used to
collapse to "0.0". The value mapping is unchanged.

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

Negative values were invisible. The linear y domain was hardcoded to 0..max,
so a register holding -20 was mapped below the plot floor and never painted:
the legend showed a chip for a series with no line anywhere on the chart. The
domain now spans [min, max] with min floored at 0, so all-positive charts keep
their familiar zero baseline and unchanged shape.

Hover tooltip printed raw doubles ("sunFactor: 1.12202099292274") and grew a
box taller than the canvas, so on the Food Web template only the first four of
thirty rows were readable and the last was sliced through. Values now share
the compare panel's two-decimal formatter, and rows are capped to what fits
with the remainder counted on the last line.

The canvas ignored devicePixelRatio, so every line and label was drawn at half
resolution on a 2x display and upscaled. The bitmap is now sized in device
pixels with the drawing transform left in CSS pixels, which keeps the
pixel-to-step math the hover and brush hit tests rely on exactly as it was.

29 legend chips wrapped onto three rows, and since neither the head nor the
scrubber shrink, every row came out of the plot: the chart collapsed to 68px.
The legend is capped at two rows and scrolls beyond that, and the canvas has a
min-height, which puts that case back to 92px.

Guide-line count now follows the drawer height rather than always being five,
so a squeezed chart thins its labels instead of stacking them into a smear.

The "not live" amber label had a CSS rule but nothing ever added the class to
#tl-scrub, so the cue never fired while scrubbing. It does now.

The Setup rail clips Loops and Watch as soon as the timeline opens, with no
hint that it scrolls, so it reuses the palette's edge fades.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
Below 768px the Setup rail is display:none and Reset is stripped from the
topbar, but the overflow menu that is meant to hold "everything the collapsed
topbar hides" offered neither. That put nine features out of reach on a phone
outright: time mode, parameters, custom variables, resource types, the
artificial player, scenario branches, design tests, feedback loops and the
live-variable watch. The menu now lists all of them plus Reset, built from the
rail's own buttons so the two lists cannot drift apart as features are added.

The properties sheet is pinned to the bottom above the tool strip, and since
placing a node selects it, the strip was covered the moment you used it. The
only way back was knowing that a tap on bare canvas dismisses the sheet, which
nothing says. Added a close button to the sheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
Clicking a dialog's own chrome (its title, a section heading, the Monte Carlo
results area) drops focus to <body>. From there the window-level shortcut
handlers still fired at the canvas behind the dialog: Ctrl+A selected every
node, Ctrl+V pasted, Ctrl+Z undid, Delete deleted the selection and S/D/R/T
swapped tools, none of it visible under the overlay. Escape had the opposite
problem: _modalize binds to the overlay element, so once focus left it the
dialog could no longer be dismissed by keyboard at all.

App now owns the list of overlays that count as modal. Both handlers consult
it, the editor through a callback seam so it stays the host's decision, and
Escape closes the topmost dialog from anywhere. Space keyup still clears
unconditionally, or a swallowed keydown would strand the canvas in pan mode.

The confirm dialog's focus trap listed its buttons in reverse DOM order, so
Tab off "Discard & continue" was never intercepted and walked out of the
alertdialog into the page behind it. It also stored its return target in the
field _showModal uses, so a guard raised over the Library wiped the Library's
own restore. Fixed the order and gave the guard its own field, and moved its
key handler to the document so Escape works wherever focus sits.

Closing a dropdown hid a popup that still held focus, resetting the browser to
<body> so Tab restarted at the top of the page. Focus goes back to the trigger
now. Dialogs opened from a menu stored the menu item as their return target,
which is hidden by the time the dialog closes, so _hideModal falls back to the
owning trigger when the stored element is gone.

Also: aria-labels on the parameter, resource-type and canvas-background inputs,
which had no visible label and announced as anonymous blank fields, and
aria-expanded on the touch overflow menu.

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

The first-run canvas hint was one 612px-wide SVG line, and SVG text does not
wrap, so it was chopped at both ends on any canvas narrower than that: at
1024x768 it overhung both edges, and by 820px it started off-screen to the
left. Split into three short lines, with the vertical centring derived from the
line count instead of hardcoded for three.

At 320px the touch topbar wanted 359px and, since it does not wrap and the page
does not scroll, pushed the ☰ button off the right edge, leaving 15px of a 44px
target. That is the only way to Library, File, Help and the Setup panels on a
phone, so it made a small screen close to unusable. Recovered the width from
the Run button and the step chip rather than dropping a control.

The empty-chart message told a one-series diagram to click a "Show all" chip
that is only rendered from two series up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
Three faults in one field, all reproduced against the Predator & Prey template.

Editing a pool's amount wiped its resource type. setCount defaults to the
untyped grey, so retyping Rabbits from 80 to 90 rewrote colorMap from
{#7cb342: 80} to {#9e9e9e: 90}: the node greyed out on the canvas and the
panel's own "Holdings by type" row switched from "Rabbits" to a raw hex. The
field now passes the node's current colour, as the Source stock field already
did, and the +/- steppers do the same.

The hero readout and the holdings rows did not follow the edit. Typing 90 left
"CURRENT 80" sitting directly above "Starting amount 90" for the same node,
with no way to tell which was real. Both readouts refresh with the field now.

Typing into the field mid-run rewrote the reset baseline. The label reads
"Amount (live)" there and the steppers beside it are genuinely transient, but
setCount also moves the baseline, so Reset came back to a number typed mid-run
instead of the run's start. Mid-run edits now restore the baseline, matching
what the label promises.

Separately, a Trader has no amount row, so the per-step refresh was writing its
resource count into the first number input it found, which is the End/goal
value: a goal of 7 dropped to 0 on the first step. The refresh now shares the
builder's exclusion list.

Also: accessible names for the condition-row operator and bounds and for colour
pickers, all of which announced as anonymous controls, and a formula hint that
began its second sentence lowercase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
The resource-types panel mixed both inside a single sentence ("maps a name to
a color ... pick a type from the colour fields"), and two of the aria-labels
added in this pass had picked up the other one. The visible field labels all
read "color", so everything follows that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
Two faults sat on the same path, the one a new user takes first.

The tour's coach card is placed beside its target, so when the target is a
palette tool it lands on the canvas just right of the strip, which is where a
first diagram naturally goes. The card took the pointer, so it swallowed the
drag its own step was asking for. Those steps advance by watching for the edit
and show no Next button, so the tour simply stopped at "drag from the Source to
the Pool" with nothing but "Skip tour" left. The card is click-through now and
only its buttons take the pointer.

Behind that, SimEngine seeded its flow accumulator in reset() but not in the
constructor. Every path that loads something (templates, autosave, share links,
the Reset button) resets first, so this only showed on the one path that does
not: boot on an empty canvas, draw a Source into a Pool, press Run. The first
transfer threw, and since that happens before _record and onStep, the step
counter stayed at 0 and the timeline, scrubber and sparklines stayed empty
while the numbers on the canvas kept climbing. Six uncaught errors per run,
and the first simulation anyone builds by hand looked half broken.

The test helper resets before stepping, which is why no unit test covered it;
the new one steps a fresh engine deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
The Resource types panel told users to "pick a type from the colour fields on
sources, converters, and filters". There is no Filter node in the palette, so
that sent a first-time user hunting for something that does not exist. It now
names the two node fields and the connection's color filter, which are the
real surfaces.

The welcome modal's three buttons were a non-wrapping row needing about 468px,
so on a phone the row overflowed leftwards and pushed the primary "Take a quick
tour" off screen entirely, with "Explore the demo" following it at 320px. That
is the first screen a new user sees. The row wraps now.

Two guide articles were both titled "Feedback loops", so searching the guide
returned two identical-looking rows. The analysis one is now "Loop detection",
matching what it documents. Its id is unchanged, so the rail's deep link still
resolves.

The rest is one voice: American spellings throughout ("summarize", "colored"),
sentence case on the rail headings and their add buttons so they agree with the
guide articles they link to, palette tooltips that name tools the way the
shortcuts dialog does, one quote style across the Library and component save
toasts, and a demo note whose second sentence began lowercase mid-canvas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
The Delete tool draws the same crosshair as every other tool, so it is easy to
leave armed, and Escape, the obvious way out, cleared the selection and any
half-drawn connection but left the tool exactly where it was. The next click on
the canvas then removed a node. Escape now returns to Select from Delete, with
a toast saying so. Only from Delete: cancelling a half-drawn connection should
not also put the Resource tool away. It lives in the app's key handler rather
than the editor's because clicking a palette button leaves focus on that
button, and the editor ignores keys aimed at one.

The tour's "change the Rate" step compared each connection against a snapshot
taken when the step was entered and skipped any connection missing from it.
Delete the connection there and draw a new one and no edit could ever satisfy
the step, which has no Next button to leave by. New connections are adopted
into the baseline now, so a later edit to them counts.

The spotlight also went stale whenever its target vanished. _positionTour has a
fallback for a missing target but only ran on render and resize, so deselecting
during the Rate step left the cut-out sitting over whatever took those pixels.
The properties panel repositions it after a rebuild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
The brush-to-compare readout had the same unbounded height as the hover
tooltip, and only the tooltip got capped. On the Food Web template it wanted
416px of panel in a 92px drawer: 5 of 28 A→B rows were visible and the last was
sliced through the glyphs, so the readout the whole compare gesture exists for
was mostly unreadable. It now keeps the rows that fit and counts the rest, the
same way the tooltip does. Small diagrams are unaffected: a three-series
comparison still lists all three.

Also the Barter Economy blurb in the Library still read "watch the colours
mix", and the CLI's batch summary joined its two clauses with an em dash.

Found by the audit's adversarial pass, then reproduced here before fixing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
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