Skip to content

diagram: interactive-editing burndown (Cancel, cloud drags, offscreen open, sim specs, selection state)#834

Merged
bpowers merged 8 commits into
mainfrom
interactive-editing-burndown
Jul 3, 2026
Merged

diagram: interactive-editing burndown (Cancel, cloud drags, offscreen open, sim specs, selection state)#834
bpowers merged 8 commits into
mainfrom
interactive-editing-burndown

Conversation

@bpowers

@bpowers bpowers commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Seven fixes for users interactively creating and editing models, picked from the #733 backlog index plus three issues discovered (and fixed) along the way. Each fix was implemented by a dedicated agent, adversarially reviewed by an independent agent, and iterated until the review had no material findings; each landed as its own commit.

Fixes #86
Fixes #53
Fixes #52
Fixes #55
Fixes #529
Fixes #832

What changed

  • Cancel/Escape discard equation edits ('Cancel' not prevent equation from being edited #86, 59c780fd). All three details-panel editors save on blur, and clicking Cancel blurred first — so Cancel saved the edit it was discarding. Blur now commits only when focus leaves the panel; Cancel/Save/Delete preventDefault on pointer-down; Escape discards on all three fields; the discard resets the live Slate documents and clears their undo history so Cmd+Z can't resurrect the abandoned text (found by the adversarial review). The load-bearing canvas-blur-commit is regression-tested. Browser-verified on the real macOS mouse path.
  • Flow valve/label stability during cloud drags (when moving a cloud, the flow label jumps around #53 b8031d52, diagram: adjustFlows valve-mirroring is non-idempotent for off-center valves on the min-coordinate endpoint #832 91718548, follow-up d4199189). Browser-reproduced: dragging a cloud perpendicular so the flow bends into an L teleported the valve+label onto the stock corner (~100px, every frame) and committed it there. Root cause was closest-segment clamping against the fixed pre-drag valve; the valve now rides the segment the drag is growing. The review of that fix surfaced two more defects in the same code, both fixed here rather than deferred: grabbing a source cloud reflected an off-center valve ~22px on the first frame (diagram: adjustFlows valve-mirroring is non-idempotent for off-center valves on the min-coordinate endpoint #832, the old mirror math was only idempotent for sink drags — replaced with signed fraction preservation), and a diagonal drag that traveled along-axis before bending snapped the cloud back ~100-165px at the bend (the reroute discarded accumulated parallel travel — it now applies the full delta). Continuity is pinned by step-sweep tests (same-topology steps <12px, topology-change hop <15px, measured 8px).
  • Center the diagram when a saved view is offscreen (if diagram is completely(ish) offscreen on current viewport (on didMount) center it #52, a780ee96). Once per mount, after the first real svg measurement, idle-only, never in embedded mode; commits through the no-undo viewport path. The adversarial review caught that the first-draft visibility metric would have yanked every large/zoomed-in model to center on open (fraction-of-bbox can never reach the threshold when the bbox dwarfs the viewport); the metric now normalizes by the smaller of bbox and viewport area, so a viewport full of diagram reads fully visible.
  • Selection-emptying edits no longer strand the details panel (diagram: selection-mutating Editor handlers bypass handleSelection, skipping showDetails reset and onSelectionChanged #529, 370200b5). Most of diagram: selection-mutating Editor handlers bypass handleSelection, skipping showDetails reset and onSelectionChanged #529 was already fixed by the React refactor (onSelectionChanged fires from a post-commit effect for every path). The residue: delete/create/flow-attach/link-attach (and a no-match search) left showDetails: 'variable' over an empty selection. They now route through one pure helper. Decision: the model-level errors panel is deliberately exempt — deleting a variable mid-error-triage keeps the error list open; empty-canvas click/Escape keep their broader dismiss-everything behavior.
  • Sim-specs edits commit on settle (every character change in sim specs shouldn't result in a model save #55, c011e15e). Every keystroke in start/stop/dt/time-units fired an engine patch, an undo entry (typing "1900" evicted most of the 5-deep undo buffer), and a save — and clearing a field patched Number('') === 0 into the model. Fields now hold a local draft and commit once on blur/Enter through a pure resolver that rejects empty/non-finite values and non-positive dt; Escape reverts the field without dismissing the drawer. Browser-verified.

Issues handled without code

Verification

Every fix has fail-then-pass unit coverage (+~1,050 net test lines; the diagram suite grew from 1,395 to 1,475 tests). #86, #53, #110, and #55 were additionally verified in a real browser against the dev stack; #52's mount path is covered by integration tests with real resize sequencing plus the reviewer's code-trace (synthetic pointer events can't drive the canvas viewport, so no browser E2E for that one). Pre-commit hooks (Rust + TS + Python) passed for every commit.

One known cosmetic behavior deliberately left alone: the perpendicular catch-up hop when a straight flow first bends (the flow is axis-pinned below the bend threshold, so the arrowhead catches up at the bend). It predates this work, is strictly smaller than before, and smoothing it is a reroute-threshold redesign rather than a bug fix.

bpowers added 7 commits July 2, 2026 23:07
The variable details inspector saves the equation/units/notes fields on
blur, which is load-bearing: a canvas-driven edit blurs the panel editor
to commit its in-progress text. But clicking Cancel or pressing Escape
blurred the focused editor first, so the edit committed before the
discard ran. handleEquationCancel also only reset React state, while the
Slate document is uncontrolled after mount, so the visible editor kept
showing the abandoned text.

Blur now commits only when focus actually leaves the panel (relatedTarget
outside the card), so tabbing or clicking toward the panel's own
Cancel/Save/Delete buttons or another field no longer save-on-blur; a
blur to the canvas or nowhere still commits. A within-panel blur also
keeps the raw equation editor open (rather than collapsing to the
last-committed preview) so a pending edit stays visible. The action
buttons additionally preventDefault on pointer-down to keep focus on the
editor for mouse/touch, since macOS does not focus a button on click and
a plain blur would otherwise fire before the click.

Escape now discards on all three fields, and the shared discard path
resets the live Slate documents (not just React state) so the visible
content reverts. Because the editors are withHistory, the discard also
clears each editor's undo/redo history -- otherwise a single Cmd+Z would
invert the revert and resurrect the abandoned text, which a later blur
would then commit.
When a cloud endpoint drag crosses the perpendicular threshold and
UpdateCloudAndFlow rebuilds the flow as an L, the valve was placed with
clampValveToClosestSegment against the fixed pre-drag valve position. As
the L deepened the closest segment flipped from the cloud-adjacent
segment onto the perpendicular riser and the clamp pinned the valve to
the riser's stock-adjacent end -- a ~100px same-topology teleport of the
valve and its label onto the stock's corner every frame, which the
commit path (reattachEndpoint routes through the same branch) then
persisted.

The valve now lands on the cloud-adjacent segment -- the segment the
drag is actively growing, which tracks the cursor continuously --
mirroring the Z-shape precedent of landing the valve on the segment the
drag created. New growEndpointDrag continuity tests sweep perpendicular
drags (sink/source/vertical/reverse) as small steps re-routed from the
original flow, asserting bounded per-step valve movement, no segment
flip-flop, and an interior final valve. The source-cloud case's
topology-change bound is temporarily 30px because the pre-existing
adjustFlows valve-mirroring defect (#832) leaks into that one step.
adjustFlows' 2-point valve tail re-placed the valve as min(otherEnd,
movedEnd) plus an absolute scaled offset, which is only idempotent when
the fixed end is the smaller coordinate (sink drags). Grabbing a SOURCE
cloud reflected an off-center valve across the segment midpoint on the
very first frame -- a ~22px visible jump of the valve and label before
the drag even moved.

Replace the mirror math with signed fraction preservation anchored to
the fixed end, per axis (the same idea preserveValveFraction uses for
multi-segment flows): frac = clamp01((valve-other)/(origMoved-other)),
newCoord = other + frac*(newMoved-other). The oldLen==0 branch keeps
the #818 divide-by-zero guard, and the degenerate-creation sub-case
(oldLen==0, newLen!=0) centers the valve to match the old fallback; the
#720 self-loop early-return is untouched. Sink-side behavior is
algebraically identical for in-segment valves, and the reviewer verified
the one perpendicular-axis difference (valve exactly on otherEnd no
longer jumps to the midpoint) is strictly more continuous.

With the reflection gone, the source-cloud straight-to-L continuity hop
drops from ~24px to ~8px, so the temporary 30px test bound tightens to
the 15px default.
Several element-mutation paths (delete, create, flow attach, link
attach) cleared state.selection directly, leaving showDetails at
'variable' over an empty selection -- inconsistent state that only the
render guard in getVariableDetails masked, and (via handleSearchChange)
a no-match search could strand a details panel the same way. Issue
#529's other half, the onSelectionChanged host notification, was already
covered by the post-commit selection effect from the React refactor.

Route those four mutation paths through one pure helper,
selectionStatePatch(selection, currentShowDetails): an emptied selection
closes the selection-tied 'variable' panel and resets the details tab,
but deliberately preserves an open model-level 'errors' panel so
deleting a variable mid-error-triage does not close the error list.
handleSelection's empty-click/Escape dismiss and the navigation
handlers keep their broader explicit close-all: clicking empty canvas
is a dismiss gesture, and navigation closes panels even for a non-empty
restored selection.
A saved viewBox/zoom can leave a model's diagram entirely outside the
visible canvas, so opening the project greets the user with a blank
surface and no hint of where the model went. On mount -- once per
Canvas instance, after the first real svg measurement, when no gesture
owns the viewport, and never in embedded mode (viewport-inert) -- the
Canvas now measures the visible fraction of the diagram and, below a
10% threshold, re-centers the diagram at the current zoom through the
same onViewBoxChange path a settle commit uses (queueViewUpdate: view
refresh, no undo entry).

The visibility metric intersects the diagram's screen-space bbox with
the viewport and normalizes by the SMALLER of the two areas: for models
smaller than the viewport that is fraction-of-diagram-visible, while
for models larger than the viewport it is fraction-of-viewport-filled,
so a large or zoomed-in model that fills the screen reads fully visible
and a legitimately saved framing is never yanked to bbox-center. Module
navigation reuses the Canvas instance and manages its own viewport, so
the once-per-mount latch deliberately does not re-run on drill-in.
UpdateCloudAndFlow's reroute branch moved the cloud endpoint by only the
perpendicular component of the drag, pinning its parallel coordinate to
the original position. Because each live-drag frame re-routes from the
original flow, a drag that had already traveled along the flow's axis
snapped the cloud back the instant the perpendicular component crossed
the bend threshold -- a one-frame teleport of 100px or more, which the
commit path then persisted.

The four near-identical per-endpoint/per-axis sub-branches collapse
into one: the cloud endpoint moves by the full drag delta on both axes
while the corner stays a riser on the fixed end's coordinate, so the
segments remain orthogonal and pure-perpendicular drags are unchanged
(verified point-for-point against the old branches). Dragging the cloud
exactly back onto the fixed end's axis now degenerates the L to a
straight flow, so the points are normalized before valve placement to
drop the zero-length corner. The straight (axis-constrained) branch
already applied the full parallel delta, so the two regimes now agree
at the threshold and the parallel coordinate is continuous across the
bend. The perpendicular catch-up hop at the bend itself is pre-existing
axis-pinning behavior and is strictly smaller than before.
Each character typed in the drawer's start/stop/dt/time-units fields
fired an engine patch, recorded an undo entry (typing a four-digit year
evicted most of the 5-deep undo buffer), and scheduled a save. Worse,
intermediate input states patched garbage into the model: clearing a
field to retype patched Number('') === 0.

Each field now holds a local draft while focused and commits once on
blur or Enter, through a pure resolver that rejects empty/non-finite
numbers, non-positive dt, and unchanged values (start<stop stays the
engine's job; time units is a free string, empty allowed). Escape
reverts the field to the model value without committing and without
dismissing the drawer. An engine refresh updates idle fields but never
clobbers a focused draft; a just-committed draft stays visible until
the model prop catches up, with a ref guard making the commit
idempotent if a sibling-field focus re-blurs the field during that lag.
Closing the drawer commits a pending edit via the Drawer's
focus-restoration on close (it CSS-hides rather than unmounts, so the
blur is what fires). The drawer's per-field onChange props collapse
into one onSimSpecCommit(field, value).

The full-payload read-modify-write in applySimSpecChange (a second
field committing while the first's round-trip is in flight could carry
stale values) predates this change and is now tracked separately.
@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.87%. Comparing base (fdce053) to head (c6bced7).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #834   +/-   ##
=======================================
  Coverage   90.87%   90.87%           
=======================================
  Files         224      224           
  Lines      143133   143133           
=======================================
+ Hits       130070   130072    +2     
+ Misses      13063    13061    -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c011e15e17

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +401 to +405
const focusLeftPanel = (e: React.FocusEvent): boolean => {
const next = e.relatedTarget as Node | null;
const card = cardRef.current;
return !next || !card || !card.contains(next);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Commit pending edits before hiding the editor tab

When a pending equation/units/docs edit blurs to the "Lookup Function" tab, relatedTarget is a tab trigger inside cardRef, so this returns false and skips handleEquationSave; the tab switch then unmounts the editors. If the user selects another variable or navigates away without returning to the Equation tab and pressing Save, the edit is dropped even though leaving the field used to commit it. Treat tab changes as a leave/commit path, or save before changing activeTab.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review

Reviewed the seven fixes across Editor.tsx, ModelPropertiesDrawer.tsx, VariableDetails.tsx, Canvas.tsx, Flow.tsx, viewport.ts, and sim-spec-draft.ts, plus the accompanying test coverage.

Every fix traces through cleanly:

  • selectionStatePatch correctly distinguishes empty/non-empty selections and preserves the errors panel while closing the variable panel; the four routing sites (delete, create, flow-attach, link-attach) and the intentional close-everything paths (nav-reset, empty-click, navigation handlers) are consistently annotated.
  • SimSpecDraftField — the per-field draft, committedDraftRef idempotency, focus-guarded [value] release, and Escape's skipNextCommitRef + stopPropagation cooperate correctly across the covered scenarios (typing, blur, Enter, Escape, prop refresh mid-edit, unchanged value, non-positive dt, empty numeric).
  • VariableDetails discard — resetEditorDocument (deselect → non-normalizing swap → history wipe), focusLeftPanel/handleFieldBlur, and the button onMouseDown preventDefault together prevent the pre-fix Cancel-commits-then-discards race. initialEquationContents/etc. correctly re-highlight errors on revert.
  • Flow.tsx valve preservation — the new preserveAxis with signed newLen anchored to otherEnd is symmetric for source and sink clouds; the degenerate-flow (creation) branch is preserved. The L-reroute uses the full accumulated delta (both perpendicular and parallel components), inserts an orthogonal corner, normalizes to drop redundant corners, and anchors the valve to the cloud-adjacent segment.
  • Offscreen re-center — the one-shot latch, embedded/liveViewport gates, and viewport-area-min normalization in visibleDiagramFraction are correct. The big-box tests explicitly guard against the box-area-normalization regression.
  • Editor sim-spec commit wiring — one applyPatch per settled edit routes to the correct field.

No blocking issues found.

Overall correctness: Correct.

Codex review of the Cancel-discard change caught a hole the intra-panel
blur gate opened: clicking to the Lookup Function tab blurs the equation
editor toward a target inside the card, so the blur deliberately does
not commit -- but the tab switch then unmounts the editors, and the
Lookup tab renders no Save/Cancel, so the pending edit sits stranded and
invisible until the next keyed remount (selecting another variable, any
content edit) silently drops it. Before the Cancel fix that blur
committed, so tab switching never lost an edit.

Treat a tab change as leaving the editing context: handleTabChange now
commits pending edits (and collapses back to the preview when no error
pins the editor open) before switching, matching blur-away semantics.
@bpowers

bpowers commented Jul 3, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: c6bced7319

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review

I reviewed the seven fixes across VariableDetails (Cancel/Escape discard), Flow.tsx (cloud drag valve placement, valve fraction preservation, along-axis travel preservation), Editor.tsx (selectionStatePatch), Canvas/viewport (offscreen recenter), and ModelPropertiesDrawer (SimSpecDraftField).

I didn't find any correctness bugs introduced by this PR. A few spots I looked at closely:

  • SimSpecDraftField commit idempotencecommittedDraftRef correctly prevents duplicate commits when the same retained draft is re-blurred (e.g. focus moving to a sibling field re-blurs this one before the model prop catches up).
  • Escape stopPropagation vs the Drawer's document-level Escape — Verified this is intentional and covered by a test that asserts onDrawerToggle is not called.
  • L-shape reroute after normalizationnormalizeFlowPoints correctly handles the degenerate case where the cloud is dragged onto the fixed end's axis (both cloudIsFirst ? [0] and [length-1] resolve to the same single segment after collapse), and clampToSegment handles zero-length segments via the mid-point fallback.
  • preserveAxis degenerate branch — Both oldLen === 0, newLen === 0 (perpendicular axis) and oldLen === 0, newLen !== 0 (creation flow) cases match the previous formula's behavior.
  • Offscreen recenter latchr.offscreenChecked is set only after the guards pass, so an early return (during a live gesture, or before svgSize is measured) doesn't consume the shot.
  • visibleDiagramFraction min-area normalization — Correctly reads 1.0 for a viewport-full-of-diagram regardless of bbox size, preventing large models from being yanked to bbox-center on open.
  • selectionStatePatch errors-panel exemption — The currentShowDetails === 'variable' gate is correct; delete/create/attach paths preserve an open errors panel, while handleSelection's empty-click and the navigation handlers correctly bypass the helper to close everything.
  • Button onMouseDown preventDefault — Correctly keeps focus on the editor for mouse activation, and the keyboard path is handled by focusLeftPanel gating the blur commit (tabbing to a button inside the card is intra-panel).

Overall correctness verdict: correct. No blocking issues found.

@bpowers bpowers merged commit a980b82 into main Jul 3, 2026
12 of 15 checks passed
@bpowers bpowers deleted the interactive-editing-burndown branch July 3, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment