Skip to content

Add a ToolsPanel with entity-info toggle/import string/export string/export image/undo/redo UI actions - #242

Merged
wormeyman merged 35 commits into
FactoryGameFan:wormeyman-space-age-supportfrom
koenigstag:wires-panel-actions
Aug 30, 2026
Merged

Add a ToolsPanel with entity-info toggle/import string/export string/export image/undo/redo UI actions#242
wormeyman merged 35 commits into
FactoryGameFan:wormeyman-space-age-supportfrom
koenigstag:wires-panel-actions

Conversation

@koenigstag

@koenigstag koenigstag commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Re-submission of #221, recreated after the repo detach.

Summary

  • Expose import-replace, import-append, export-to-string and export-to-image — actions that only ever existed as keyboard shortcuts (paste, Ctrl+Shift+V, copy, Ctrl+S) — as buttons, plus Undo/Redo (Ctrl+Z/Ctrl+Y).
  • Added ImportDialog (textarea + Replace/Append buttons with descriptions) and ExportDialog (pre-filled, pre-selected textarea) as a clipboard-free alternative, for browsers that block navigator.clipboard or a string that needs to be seen/edited.
  • Renamed WiresPanel to ToolsPanel, since it now holds import/export/undo/redo alongside the wire slots.
image image

Layout notes

  • The panel wraps into a column-major grid instead of a single wide row — a wide single row runs part of it under .toasts-container (position: fixed; right: 0), confirmed by clicking a widened-panel slot and finding the click reached the toast instead of the button.
  • Undo/Redo call G.bp.history.undo()/redo() directly (same as their keybinds in Editor.ts); the four clipboard/file actions go through a new QuickActions interface injected via Editor.init(), mirroring the existing Logger injection.
  • Icons: game GUI sprites (import_slot/export_slot) and the signal-anticlockwise-circle-arrow/signal-clockwise-circle-arrow virtual signals for Undo/Redo — the only undo/redo-shaped icons anywhere in vanilla Factorio's data.

Test plan

  • vp check / vp test pass
  • Manually verified all quick actions, both dialogs, and Undo/Redo end-to-end against the dev server

claude and others added 23 commits August 11, 2026 09:01
Import-replace, import-append, export-to-string and export-to-image
existed only as keyboard shortcuts (paste, Ctrl+Shift+V, copy, Ctrl+S)
with no way to discover or trigger them from the UI. Exposes all four
as buttons next to the wire slots, using the game's own import/export/
add GUI icons via a new F.CreateUtilitySpriteIcon helper.

The editor package has no DOM/clipboard/FileSaver access, so the
buttons reach the website's existing clipboard/file logic through a
new QuickActions interface injected via Editor.init(), mirroring the
existing Logger injection pattern. The four website-side handlers were
extracted into named functions shared by the keybinds, the copy/paste
listeners and the new buttons.

Laid out as 3 columns (wire row + two action rows) rather than a wider
single row, since widening the panel rightward puts new buttons under
the fixed bottom-right toast container - confirmed by clicking a
right-most action slot in a widened layout and finding the click
landed on the toast, not the canvas.
Replaces the 3-wide "wire row, then two action rows" layout with a
single grid, column-major: import-replace/copper-wire,
import-append/red-wire, export-string/green-wire, export-image alone
in the fourth column. Pairs each quick action with the wire it sits
above, and keeps the panel to 2 rows instead of 3.
A textarea fallback for import/export, for browsers that block
navigator.clipboard or a string that needs to be seen or edited rather
than round-tripped through the clipboard whole. Opens from a new
button in WiresPanel's previously-empty grid cell.

Replace and Append both read the same textarea but do very different
things to the loaded blueprint, so each gets its own description line
rather than trusting the button label alone: "Replaces the whole
blueprint" vs "Adds on top of current blueprint". Export fills the
textarea with the current blueprint's string so it can be read or
copied out manually.

QuickActions.importReplace/importAppend now take an optional source
string, defaulting to the OS clipboard as before - the dialog is the
only caller that passes one. TextInput gained multiline/height
parameters to back the textarea.
ImportDialog keeps the two buttons (Replace/Append) with their
description lines; ExportDialog is a read-focused dialog that
pre-fills and pre-selects the current blueprint's string on open, so a
plain Ctrl/Cmd+C is enough once it's up.

WiresPanel's dialog toggle button becomes two - one per dialog -
sharing the row-column-major grid with the wires. The now-redundant
raw import-replace/import-append/export-string quick-action buttons
are dropped in favor of opening the matching dialog, since both
dialogs cover the same actions with clearer labeling; export-image
stays a direct one-click action since it produces a PNG rather than a
string either dialog would have anything to show. Net effect: the
panel goes from 4 columns down to 3, undoing the growth that pushed
part of it under the toast notification area.

The shared button+description row layout moves into a small
`DescribedButton` helper so both dialogs build it the same way.
Undo and Redo (Ctrl+Z/Ctrl+Y) get a fourth column, calling
G.bp.history.undo()/redo() directly - unlike the clipboard/file
actions, undoing a change needs nothing outside the editor package,
so this skips QuickActions entirely. Icons are the core left/right
arrow sprites, since Factorio's own utility sprites have no dedicated
undo/redo icon.

Renamed WiresPanel to ToolsPanel (and the wiresPanel/toolsPanel field
in UIContainer) now that the panel is import/export/undo/redo plus the
three wire slots, not just wires. Updated every reference and doc
comment, including a couple of stale ImportExportDialog mentions left
over from the dialog split.
…rrows

signal-anticlockwise-circle-arrow/signal-clockwise-circle-arrow (Space
Age virtual signals) are the only undo/redo-shaped icons anywhere in
vanilla Factorio's data - checked items, recipes, entities and utility
sprites, all mip-format or otherwise unrelated. Reads as "undo"/"redo"
immediately where the previous plain left/right arrows read as
"back"/"forward" navigation. Also drops the utility-sprite path for
these two, since F.CreateIcon already resolves signal names directly.
Paste fills the textarea from the clipboard without acting on it, set
apart from Replace/Append by a wider gap since it's a field operation
rather than a blueprint one. The textarea now uses the game's own
beige import-field colour instead of the shared textbox background.
…ckbox

Nothing in vanilla Factorio's sprites spells out a key name - the game
draws its own hints as text - so this synthesizes a small badge in the
game's own key-hint style (dark rounded box, light border, bold text)
rather than reaching for an icon that doesn't exist.
Alt calls overlayContainer.toggleEntityInfoVisibility(), the same
action AltLeft triggers in Editor.ts's keybinds, so alt-mode is
reachable without a keyboard. New layout: ALT/Import/Export/Undo/
export-image on top, copper/green/red-wire/Redo below.
Polls G.BPC.overlayContainer.entityInfoVisible from the ticker rather
than listening for an event, since G.BPC is a fresh instance on every
loadBlueprint - a listener bound to today's overlayContainer would go
silent after the next reload, while reading G.BPC fresh each frame
can't. Reflects a toggle from the AltLeft keybind the same as one from
the button itself.
…t icon

CreateUtilitySpriteIcon now falls back to a mip sprite's size field
when width/height are absent, so it can render downloading.png -
which reads as "save this out" more clearly than the generic
blueprint icon it replaced.
The only way to switch entries was a bare "BP Book Index" number field
in the settings pane, with no names, icons, or indication of nesting.
BookDialog walks the book's raw entries directly (not the flattened
index space Book.selectBlueprint reads) and renders them as an
always-expanded tree - nested books get a header row and indented
children, planners get a dimmed placeholder row, and a depth guard of
10 stops runaway recursion on a malformed book. Rows scroll with the
same mask+wheel+thumb pattern InventoryDialog already uses.

A new ToolsPanel button (Book) opens it, visible only while a book is
loaded - QuickActions grows getCurrentBook/selectBookEntry so the
editor package can reach that state and trigger a switch without
owning it itself.

Also collapses three copies of the same index-switching logic
(the settings pane callback, testApi.selectBookIndex, and now
QuickActions.selectBookEntry) into one function.
quickActions/logger as trailing positional parameters meant the
required one had to sit before the optional one - fragile for callers
and for adding a third option later. A named options object sidesteps
both.
…icker leak, dupe import, add tests

QuickActions.exportString/exportImage were typed as () => void but the
website implementation returns a boolean guard (false, and a no-op,
when the loaded blueprint is empty) - the types now say so.

ToolsPanel.generateSlots() added a ticker callback on every call with
no way to remove it; it now tracks and clears the previous one before
adding a new one, since the method is public and re-callable in
principle even though nothing currently calls it twice.

Merged two separate `import ... from 'factorio:prototype'` statements
in controls/functions.ts into one.

Adds tests/quick-actions.spec.ts, the first coverage of QuickActions:
importReplace/importAppend driven through ImportDialog's real Replace/
Append buttons, and the empty-blueprint guard shared by exportString/
exportImage/encodeCurrent. Both export actions stay untested past that
guard, since a non-empty blueprint would reach the OS clipboard or a
real file save that a headless run can't safely assert on - flagged as
a follow-up in the PR description rather than worked around here.
- ImportDialog/ExportDialog's blueprint-string field had a hardcoded
  2**20 (1 MiB) maxLength, which silently truncates on paste (the DOM
  maxlength attribute only constrains user input, not the programmatic
  .text = assignment ExportDialog uses) - the corpus's largest
  blueprint is 2.4 MB. TextInput's maxLength is now optional; both
  dialogs pass none, since a blueprint string has no honest cap.
- ToolsPanel.setPosition ran the panel off the right edge of the
  screen below ~866px viewport width; now clamped to the screen's own
  edge underneath its usual flush-against-the-quickbar position.
- generateSlots() is re-callable in name only until now - it never
  cleared slotsContainer, so a second call would have piled a second
  set of slots on top of the first. Split into a pure buildCells()
  (also removing the hardcoded CELL_COUNT, now cells.length) and
  placeCells(), which clears and destroys the previous set first.
- The ticker callback added for the Alt highlight was removed before
  a re-generate but never on the panel's own destroy(); added an
  override that does.
- Wire order was copper/green/red with no explanation, where the
  pre-existing single-row WiresPanel this replaced drew them
  copper/red/green; restored that order rather than leaving an
  undocumented deviation.
- QuickActions.exportString was implemented and wired into the
  interface but nothing in the editor package ever called it -
  ToolsPanel's Export slot opens ExportDialog instead. Removed it;
  the website's own Ctrl+C handler already calls its local copy
  directly and never went through the interface.
- Icon construction (F.CreateIcon/CreateUtilitySpriteIcon) throws for
  a name FD lacks and nothing above generateSlots() caught it, so one
  missing sprite would have taken the whole panel down. Wrapped in a
  safeIcon() helper that logs a warning and falls back to a blank
  slot instead.
…ale comment, test coverage

- WireSlot's constructor called F.CreateIcon(wireName) uncaught, unlike
  every other icon build in this file - wrapped in the same safeIcon()
  helper ActionSlot's icons already use.
- The ROWS comment's justification for the two-row layout was stale:
  it described toasts intercepting clicks meant for whatever they
  covered, which issue FactoryGameFan#228 (merged from the base branch just now)
  already fixed at the source - .toasts-container and its toasts are
  pointer-events: none, bar the infinite-timeout exception. Rewrote it
  to describe the current state: click-through works regardless of row
  count now, visibility (a toast still paints over five of nine slots
  for its lifetime) is the part FactoryGameFan#228 did not touch, and the two-row
  layout's remaining justification is width alone.
- Added tests/tools-panel.spec.ts: ImportDialog's textarea carries no
  maxlength attribute and accepts the corpus's largest blueprint (2.4
  MB) without truncation, that blueprint actually loads through
  Replace rather than only filling the field, and ToolsPanel stays
  within a narrow (800px) viewport rather than running off the right
  edge. Exposed UIContainer/Editor.toolsPanelBounds for the last one,
  mirroring topDialogBounds' existing shape.

Also merges origin/wormeyman-space-age-support (issue FactoryGameFan#228's toast
pointer-events fix, zoom level test adjustments, CI workflow changes) -
no conflicts.
@koenigstag

Copy link
Copy Markdown
Contributor Author

PR recreated after the repo detach (was #221). Ready for review — waiting on any fixes/comments.

@wormeyman wormeyman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at f90e865. Fourteen inline findings below, plus one convention note.

Two of them I'd treat as blocking, and both surface on first use of the feature - consistent with the test plan's manual-verification box still being unchecked:

  1. ExportDialog's pre-selection never fires (ExportDialog.ts:43). select() runs in a microtask while the textarea is still display: none, so Ctrl+C falls through the document copy listener to navigator.clipboard.writeText - the exact API the dialog exists to bypass.
  2. exportGuardResult doesn't report a guard, it runs both actions (packages/website/src/index.ts:339) - a real clipboard write and a real PNG download - and __fbe_test is assigned unconditionally, so it ships in production builds.

Four more are reachable UX defects: Escape can't close either dialog once the field has focus, Replace/Append destroy the user's hand-edited text before the async import can fail, the Alt highlight is an opaque fill inserted above the hover and active sprites, and the export field is editable and silently stale. The rest are docs that don't match their code, one dead function, and test-side duplication.

Convention (repo CLAUDE.md, Git and PR Conventions): the PR title carries #221, so this squashes to ...UI actions- #221 (#242) - the rule is to keep the issue number out of the subject and put Closes #N in the body. The body says "Re-submission of #221" but has no Closes #221, so merging won't close the issue. Separately, the title's "show details" doesn't name anything in the diff - that slot is labelled ALT and toggles entity info overlays.

Nice work on the comment density throughout - the reasoning left at ROWS, maxLength and WireSlot is the right shape for this repo. Several findings below are that the prose and the code drifted apart, not that the prose was unwelcome.

Comment thread packages/editor/src/UI/ExportDialog.ts Outdated
Comment thread packages/editor/src/UI/ExportDialog.ts
Comment thread packages/editor/src/UI/ImportDialog.ts
Comment thread packages/editor/src/UI/ImportDialog.ts Outdated
Comment thread packages/editor/src/UI/ToolsPanel.ts Outdated
Comment thread packages/editor/src/Editor.ts Outdated
Comment thread packages/website/src/index.ts Outdated
Comment thread tests/tools-panel.spec.ts Outdated
Comment thread tests/tools-panel.spec.ts Outdated
Comment thread tests/tools-panel.spec.ts Outdated
…ects, and test debt

Blocking:
- ExportDialog's select() ran in the same microtask as encodeCurrent()'s
  resolution, before any render tick showed the textarea (display: none
  until _updateDOMInput runs) - focus()/select() were no-ops, so Ctrl/Cmd+C
  fell through to navigator.clipboard.writeText instead of a hand-select.
  Deferred to a tick after render, at UPDATE_PRIORITY.UTILITY (below
  Application's own LOW-priority render step).
- exportGuardResult ran the real exportString()/exportImage() and reported
  whatever they returned - a real clipboard write and PNG download on every
  call against a loaded blueprint, reachable from any script on the
  deployed site since __fbe_test ships unconditionally. Now reports
  !bp.isEmpty() directly, matching the guard both functions open with.

UX:
- ExportDialog's field was freely editable and encoded once at open, so it
  could show a stale string with nothing indicating it. Now read-only, and
  re-encoded on an interval (not every render tick) while the dialog stays
  open.
- Escape couldn't close either dialog once its textarea had focus -
  Editor.ts's own keydown listener drops every key targeting an
  <input>/<textarea>. Added a keydown listener on each dialog's own field.
- ImportDialog's Replace/Append closed synchronously before the async
  import could fail, taking a hand-edited string down with a bad paste;
  an empty field reached `new URL('https://')` and threw "Invalid URL".
  importReplace/importAppend now resolve a success boolean, the dialog
  closes only on success, and an empty field is guarded up front with a
  clear message.
- ToolsPanel's Alt highlight inserted above active/hover (children.length -
  1 landed on content's own index, not just below it), so the button drew
  no press/hover feedback while toggled on. Now inserted at index 1, sized
  off the slot's own drawn bounds instead of a third hardcoded 36x36.

Other:
- quickActions is now optional on EditorInitOptions; a missing one falls
  back to a QuickActions that reports a clear, named error through
  G.logger instead of crashing "Cannot read properties of undefined" deep
  inside a click handler.
- tools-panel.spec.ts's LARGEST_BLUEPRINT_FILE double-stat'd every file and
  threw uninformatively on an empty corpus; now maps once and names the
  spec in its error.
- The `cssText !== ''` textarea filter matched every TextInput equally and
  could not tell ImportDialog's field from ExportDialog's now that both can
  be open at once - tests/helpers/dialog-textareas.ts keys on each dialog's
  own placeholder instead, imported from the source files rather than
  duplicated.
- ImportDialog's REPLACE_Y/PADDING and DescribedButton's
  ROW_BUTTON_WIDTH/HEIGHT are exported and imported by both spec files
  instead of being hand-copied a third time.

New coverage: the empty-field guard, a bad string leaving the dialog open,
Escape on both dialogs, both dialogs open at once, ExportDialog's
read-only/pre-selected field, and exportGuardResult provably not touching
the clipboard or starting a download on a loaded blueprint. The two
blocking fixes were mutation-checked by reverting each in isolation - both
new tests failed against the reverted code and pass against the fix.
@koenigstag koenigstag changed the title Add a ToolsPanel with show details/import string/export string/export image/undo/redo UI actions- #221 Add a ToolsPanel with show details/import string/export string/export image/undo/redo UI actions Aug 22, 2026

@wormeyman wormeyman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed 7cd1e36e against the fourteen findings from my last pass. Ten are fixed. I checked each one against the code rather than against the commit message. Four are untouched with no note either way, and one of the fixes introduced a new problem that I measured.

The two blocking ones are properly fixed

select() now defers to UPDATE_PRIORITY.UTILITY, which runs below Application's own LOW-priority render step, so _updateDOMInput has shown the textarea by the time it fires.

exportGuardResult is now !bp.isEmpty(). I checked that against both functions: it is the exact condition exportString and exportImage each open with, so it reports the guard without being able to perform either action.

Both spec files ran in CI rather than being collected and skipped (shards 3 and 4).

New: the freshness fix costs 310 ms every 500 ms

I asked for this one, so the cost is on me, but it is worth fixing before merge.

ExportDialog re-encodes on a 500 ms interval now, and encode() (bpString.ts:249) is fully synchronous inside its Promise executor. serialize(), JSON.stringify, pako.deflate and base64 all run on the main thread before resolve() is called.

Measured on the corpus's own largest blueprint, test-blueprints/EARN/pocket-base-space-age-v22.1.2.txt, a 2.42 MB string holding 29.95 MB of JSON:

stringify + deflate + base64: median 310 ms (min 300, max 329, 7 runs)

That is a lower bound. It leaves out Blueprint.serialize(), which I cannot run outside a browser. And m_LastEncodeAt is stamped at the top of refreshText, before the work rather than after it, so the interval is start-to-start: about 190 ms of idle between 310 ms stalls. Call it 62% of the main thread and 19 dropped frames per cycle.

The dialog deliberately does not block the canvas, which is why the field could go stale at all. So this makes the editor stutter during the one activity the freshness fix exists to support.

History already keeps a historyIndex that moves on every commit, undo and redo (History.ts:198). It is private today, but exposing it, or any monotonic counter, turns the tick into an integer comparison that re-encodes only when something actually changed. That costs nothing while the user is reading rather than editing.

Four findings unaddressed, with nothing said either way

# Site State
6 ToolsPanel.ts:151 The doc still promises "a blank square the same size as a real icon". The code still returns a childless new Container(), which draws nothing.
8 ToolsPanel.ts:314 Math.min still has no lower bound, so it goes negative below about 212px, which is the failure it was added to prevent, mirrored. The doc still calls the quickbar overlap "click through" when UIContainer adds ToolsPanel after the quickbar.
9 controls/functions.ts:406 File untouched. Still scale.set(maxSize / width), so height never reaches the scale, and data.width ?? size still passes a literal 0 through the === undefined guard to Infinity.
7 ToolsPanel.ts:262 Half done. placeCells is extracted and called now, but generateSlots still has no call site, and the comment's factual error survives word for word: Container.destroy() with no options does not destroy children either.

I am not asking for all four before merge. 6 and 7 are comment corrections. 8 and 9 are each a one-liner, and 9 sits in a helper exported on F for general use.

Smaller

noopQuickActions.readClipboardText both logs through G.logger and rejects, where the other four log and resolve to a falsy value. ImportDialog's Paste button catches the rejection and logs it again, so a missing quickActions raises two toasts for that one action and one for each of the others.

Convention: the - #221 is gone from the title, thank you. The body still has no Closes #221, and that is the half GitHub reads to close the issue. The title's "show details" also still names nothing in the diff. That slot is ALT.

claude added 2 commits August 25, 2026 14:24
…d interval

The field used to re-encode on a plain 500 ms interval the whole time the
dialog stayed open, regardless of whether the blueprint had changed - a full
serialize + deflate of the largest corpus blueprint measured at a 310 ms
median, ~62% of the main thread and 19 dropped frames every cycle, purely
idle. History now exposes a cheap `revision` counter (±1 on every commit,
undo or redo); ExportDialog polls it every frame instead and only starts a
debounced re-encode once it actually moves, collapsing a burst of edits to
one re-encode after it settles rather than one per transaction.

Closes the performance finding from the FactoryGameFan#242 review follow-up.
…sPanel/functions.ts

- safeIcon's doc comment claimed the fallback is a drawn blank square; it's
  an empty childless Container with no graphic at all
- generateSlots' doc comment claimed destroy() releases GPU resources a
  re-generated panel would leak; bare destroy() with no options does not
  cascade to a child's own children, so each slot's icon sprite/texture is
  not released by this
- ToolsPanel's setPosition only clamped the right edge - below ~212px
  (the panel's own width) the unclamped left bound went negative and
  pushed it off the left edge instead of merely overlapping the quickbar
- CreateUtilitySpriteIcon scaled only against width, which overflows
  maxSize on the taller axis for a non-square mip icon; and its guard
  checked `=== undefined` rather than falsiness, so a literal 0 width or
  height reached `maxSize / 0` instead of being treated as missing

@wormeyman wormeyman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed c8f505ba and 98f09316 together. Nine of the fourteen findings from my earlier passes are now fixed and I checked each against the code rather than the commit message. The performance fix is right, and all four of the older ToolsPanel/functions.ts findings are genuinely closed.

I verified everything locally, because CI has never run either commit - see the first section.

Two things need fixing before merge, and one of them I found by running the suite rather than reading it.

CI is red repo-wide, and it is not this PR's fault

All four Playwright shards and checks fail in 14-29s at Set up Vite+:

sha256sum: WARNING: 1 computed checksum did NOT match

vite-plus 0.3.0 shipped on 2026-08-24 and rewrote install.sh, so the pinned hash stopped matching under an unchanged VP_VERSION. That is the pin working. #260 fixes it and is already green on checks. Until it lands, a red Set up Vite+ here says nothing about this PR.

Verified fixed

c8f505ba - ExportDialog polls History.revision (an integer compare) and debounces a re-encode 500 ms after the blueprint stops changing. The idle 310 ms stall every 500 ms is gone. I also went looking for the staleness hole this approach could open and it is not reachable: loadBlueprint calls Dialog.closeAll() before anything can observe a new blueprint's reset historyIndex, and history.reset() runs only in the Blueprint constructor.

98f09316 - all four older findings, checked individually:

# Fix Verified
6 safeIcon doc now says "empty, childless Container" Matches the code
7 generateSlots doc no longer claims destroy() cascades Correct, and placeCells really does child.destroy() on each removed child, so the new claim holds too
8 Math.max(0, Math.min(...)) Mutation-checked - removing it gives x = -62 at 150px
9 !width || !height, and maxSize / Math.max(width, height) Catches the literal 0, and fits both axes

Local gates on 98f09316: vp check clean (0 lint/type errors), vp test 180/180, full Playwright 212 passed, 1 failed - and that one failure is the subject of the next section.

Blocking: the new ToolsPanel test is flaky, and so is the one it was modelled on

tools-panel.spec.ts:155 failed in a clean full-suite run on unmutated code, with Received: 861.

Measured in isolation:

Test Result
:155 "does not run off the left edge" (new in 98f09316) 2 failures in 5 runs
:140 "stays on screen at a narrow viewport width" (pre-existing) 3 failures in 6 runs

So this is a pre-existing flake that the new test copied, not one it introduced - but neither has ever run in CI, so both would land and then fail on shard 4 roughly 40% of the time, in a spec nobody touched.

The mechanism, and the fix I measured, are inline.

Blocking: the ExportDialog test does not guard the debounce, and says it does

Deleting the debounce entirely leaves the test passing, in 2.1s. Inline.

Two doc comments overstate their code

History.revision and ExportDialog.encodeCount. Comment-only, both measured, both inline. revision is new public surface on a core class, which is why it is worth getting exactly right.

Still open from earlier passes

noopQuickActions.readClipboardText rejects where its four siblings resolve to a falsy value, so a missing quickActions raises two toasts for that one action and one for each of the others. And the body still has no Closes #221, which is the half GitHub reads to close the issue.

Comment thread tests/tools-panel.spec.ts Outdated
Comment thread tests/quick-actions.spec.ts
Comment thread tests/quick-actions.spec.ts Outdated
Comment thread packages/editor/src/core/History.ts Outdated
Comment thread packages/editor/src/UI/ExportDialog.ts Outdated
claude and others added 2 commits August 25, 2026 16:57
…p follow-up

- History.revision's doc comment claimed ±1 on every commitTransaction;
  measured wrong in three ways - only the outermost commit of a nested
  pair moves it, an empty transaction does not move it at all, and
  crossing MAX_HISTORY_LENGTH jumps it backward by roughly the trim size
  instead of forward by one. None of that affects ExportDialog's own
  change-detection use, which the comment now says explicitly.
- ExportDialog.encodeCount's doc comment claimed it counts completed
  serialize+deflate calls; it actually counts refreshText calls, which
  bump it synchronously before the async encode resolves - also means a
  caller does not need to poll for the value right after opening the
  dialog, since it is already set by the time openExportDialog() returns.
- tests/tools-panel.spec.ts's two viewport-width tests read
  toolsPanelBounds() once right after setViewportSize, racing the resize
  event pixi needs to actually re-run setPosition - polled instead.
- tests/quick-actions.spec.ts's debounce test only proved a second
  re-encode eventually happens, which also passes with the debounce
  removed entirely; added a second test putting two revision-changing
  operations inside one debounce window and asserting the count settles
  at exactly 2, not 3.
- noopQuickActions.readClipboardText rejected where its four sibling
  methods resolve to a falsy value - ImportDialog's Paste button both
  .thens a success and .catches a failure into G.logger, so the reject
  produced two toasts for one click where every sibling logs once.
wormeyman added a commit that referenced this pull request Aug 25, 2026
… notes (#263)

* Skip the Claude review workflow on pull requests from forks

GitHub withholds repository secrets from a `pull_request` event raised by a
fork, so `secrets.CLAUDE_CODE_OAUTH_TOKEN` resolves to an empty string and the
action fails every time. Measured across the open backlog: `claude-review`
failed on all five fork PRs (#227, #242, #243, #258, and #249 before it merged)
and passed on both in-repo ones (#257, #260). That is the whole pattern - it is
not a misconfiguration the workflow can fix, it is what the event is for.

The failure blocks nothing, which is the problem. Every fork PR opens with a red
X, and a check that is always red is a check nobody reads - so a real failure in
it would be missed. A job-level `if` turns it grey instead.

The alternative is `pull_request_target`, which does get fork PRs reviewed but
hands base-repo secrets to a fork's code. Every outside contribution here
arrives from a fork, so that trade is not available. The comment at the guard
says so, since the next person to notice the skipped runs will reach for it.

In-repo branches, Renovate's included, still run.

Also corrects a stale note in CLAUDE.md: the `ajv` entry still described
`ModdedBlueprintError` and `TrainBlueprintError` as declared-but-never-thrown,
and #262 deleted both. The point it was making survives - ajv is ~100 kB and
nothing branches on its result - so the entry keeps that and records what went.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N3pm7fQQDv6HTVz1TEpmE

* Correct CLAUDE.md's vite-plus entries against what the repo actually pins

Three corrections, each measured rather than read off the file.

The documented local-install command did not set the version at all. It read
`VP_VERSION=0.2.8 VP_NODE_MANAGER=yes curl -fsSL https://vite.plus | bash`, and
an assignment ahead of a command applies to that command alone - `curl` got the
variables and the `bash` on the far side of the pipe read an empty string.
Measured against a stub script, which printed `VP_VERSION=[]`. The installer
then falls back to `VP_VERSION="${VP_VERSION:-latest}"`, read off the script
itself, so anyone following that line installed `latest` rather than the pin.
That is the "green, and wrong" split the same file warns about one section
down, with a local toolchain silently different from the lockfile's and CI's.
The command now downloads the script and runs it with the variables ahead of
`bash`, matching setup-vp/action.yml, and sets VP_HOME for the layout reason
#260 established. Syntax-checked with `fish -n`, since it is a fish block.

The pin is 0.2.9 everywhere in the repo - root, editor and website
package.json, the root overrides alias, and VP_VERSION in setup-vp/action.yml -
while the file still said 0.2.8 in three places. It also claimed 0.2.8 was
`latest` as of 2026-08-11; `npm view vite-plus dist-tags` gives 0.3.0 today.
That entry has now gone stale twice, which is its own best argument, so it says
so and points at the command to re-measure with.

And the installer-checksum note said the hash did not move across 0.2.6 ->
0.2.8, so a bump usually leaves it alone. True when written, and it is the
reassurance that made 2026-08-24 expensive: the script rotated with VP_VERSION
untouched and every job on every branch failed at `Set up Vite+`. The note now
records that a hash can move with no bump at all. Re-fetched today and the
current sha256 still matches the pin, so nothing in CI needs changing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N3pm7fQQDv6HTVz1TEpmE

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman and others added 4 commits August 25, 2026 11:28
- The base branch's spec-modifier-keys.test.ts gate (added after this
  branch diverged) flagged quick-actions.spec.ts's Control+z: it reaches
  the app's own undo keybind through actions.ts, not a focused text
  field, so it belongs on Control rather than ControlOrMeta - changed to
  the KeyZ spelling the existing chest-filters.spec.ts entry uses and
  added a matching ALLOWLIST entry with its own reason.
- The debounce burst test's intermediate "still 1 after 200ms" check
  raced the very 500ms debounce it was trying to observe: reproduced
  reliably running the full CI shard (54 tests) but never in isolation,
  since page.waitForTimeout's wall-clock wait has no ceiling on how much
  slack a loaded shard adds before the following page.evaluate actually
  runs. Removed - the final "settles at 2, not 3" checks already catch
  the debounce being removed entirely without depending on wall-clock
  precision, confirmed by re-running the mutation.
@koenigstag
koenigstag requested a review from wormeyman August 26, 2026 12:53
@wormeyman

Copy link
Copy Markdown
Collaborator

@claude review

@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown

Claude finished @wormeyman's task in 3m 7s —— View job


Review complete

  • Gather context (PR body, prior review threads, CLAUDE.md conventions)
  • Check what has changed since the last reviewed commit (98f09316)
  • Verify status of previously-flagged unresolved items
  • Review the new commits for fresh issues
  • Post final review

Reviewed c99645b and ea5984d, the two commits landed since the last review round (98f09316). CI is now green end-to-end on the current HEAD (4e9a9a1) — checks, both Rust exporter jobs, and all 4 Playwright shards passed (run 32891939061), so #260's vite-plus checksum fix did land and this branch is being tested for real now.

Verified fixed (checked against the code, not the commit message)

# Finding Fix
History.revision doc Claimed "exactly ±1 on every commit" Now correctly documents the nested-transaction, empty-transaction, and trim-boundary exceptions, and explicitly scopes the guarantee to ExportDialog's !== use
ExportDialog.encodeCount doc Claimed it counts completed serialize+deflate Now correctly says it counts refreshText calls, which bump synchronously before the async encode resolves
Debounce test only proved "eventually re-encodes" Passed even with the debounce deleted New test asserts a delete + undo inside one 500ms window settles at exactly 2 encodes, not 3 — verified this actually fails under the debounce-removed mutation
noopQuickActions.readClipboardText rejected, siblings resolve Double-toast on a missing quickActions Now resolves to '' like its siblings, with the reasoning for why an empty string is a safe stand-in written down
Flaky tools-panel.spec.ts:155 (150px, off-left-edge) 2/5 failures, stale pre-resize read Now polls toolsPanelBounds().x for toBe(0) — this does force a real wait, since the stale (pre-resize) x is 861, not 0
Flaky debounce-burst test in CI shards page.waitForTimeout(200) raced the same 500ms debounce it was observing, only reproduced in a loaded 54-test shard Intermediate "still 1" check removed; the final "settles at 2" checks already catch the same mutation without depending on wall-clock precision under load
Control+z in quick-actions.spec.ts Flagged by the (newly-merged) spec-modifier-keys.test.ts gate Changed to Control+KeyZ with an ALLOWLIST entry matching the existing precedent

One remaining item, only partially fixed

tools-panel.spec.ts's other viewport test (ToolsPanel stays on screen at a narrow viewport width, 800px) still has the race it claims to have fixed.

await page.setViewportSize({ width: 800, height: 720 })

await expect
    .poll(() => page.evaluate(() => window.__fbe_test.toolsPanelBounds().x))
    .toBeGreaterThanOrEqual(0)

const bounds = await page.evaluate(() => window.__fbe_test.toolsPanelBounds())
expect(bounds.x + bounds.width).toBeLessThanOrEqual(800)

setPosition() is x = max(0, min(screen.width/2 + 221, screen.width - this.width)). At the stale pre-resize width (1280, the default viewport), x = max(0, min(861, 1068)) = 861 — which already satisfies .toBeGreaterThanOrEqual(0). So this poll's condition is true whether or not the resize has actually landed, meaning it resolves on its very first check and adds no real wait. The un-polled read right after it (bounds.x + bounds.width <= 800) is exposed to exactly the same race the original single-read version had — the one originally measured at 3 failures in 6 runs.

The sibling 150px test's fix works precisely because its target (x === 0) is not true in the stale state (861 ≠ 0), so the poll is forced to actually retry until the resize lands. This test needs the same shape — poll the joint condition, not just half of it:

await expect
    .poll(() =>
        page.evaluate(() => {
            const b = window.__fbe_test.toolsPanelBounds()
            return b.x >= 0 && b.x + b.width <= 800
        })
    )
    .toBe(true)

This was actually the exact fix suggested in the prior review round for this test; the fix that landed applied it to the other (150px) test but not this one. It's low severity — a test-only flake with roughly a coin-flip failure rate under load, same class as the one already fixed — but worth closing since it's a one-line change and the mechanism is now well understood.

Fix this →

Still open from earlier rounds (non-blocking, convention only)

Overall

Nine of the ten items from the last review pass are cleanly fixed and verified against the code. The tenth (the 800px viewport test) got half the intended fix — the mechanism is right, just applied to the wrong assertion shape on that particular test. Given everything else here has already been through three rounds of measured, verified fixes, this is close to mergeable pending that one-liner and the two convention notes above.
· Branch

The poll added in the last round waited on `toolsPanelBounds().x >= 0`,
which is already true before the resize lands: at the stale 1280px
viewport `setPosition` puts the panel at `max(0, min(861, 1068))` = 861.
So the poll resolved on its first check and added no wait at all, leaving
the un-polled `bounds.x + bounds.width <= 800` read after it exposed to
exactly the race the poll was added for - the one originally measured at
3 failures in 6 runs.

The sibling 150px test works because its target (`x === 0`) is false in
that stale state, so its poll is forced to retry. This test needs the
same property, which means asserting both halves inside the poll rather
than only the half that cannot distinguish the two viewports.

Measured against the running editor rather than reasoned: the panel is
212px wide, the stale 1280px state reads x=861 / right=1073, and the
settled 800px state reads x=588 / right=800. Against the stale bounds the
old predicate answers true and the joint one answers false, so the new
poll is the one that actually has to wait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6iqKrtATwtEWaeb7JBjw3
@koenigstag koenigstag changed the title Add a ToolsPanel with show details/import string/export string/export image/undo/redo UI actions Add a ToolsPanel with entity-info toggle/import string/export string/export image/undo/redo UI actions Aug 29, 2026
@wormeyman

Copy link
Copy Markdown
Collaborator

Re-reviewed at 9338da48. Almost everything from the previous three rounds is fixed with the mechanism removed rather than the wording satisfied, which is the harder and better way to do it. The newest commit is a genuine deterministic fix, not a settle-wait: x >= 0 alone was already true at the stale 1280px layout, so the old poll resolved on its first check and added no wait at all. The joint condition can actually fail. That is the right shape.

Three things left. One is user-facing.

Closing either dialog leaves Ctrl+C and Ctrl+V silently dead

Both handlers open with if (document.activeElement !== CANVAS) return (packages/website/src/index.ts:305 and :311). When a dialog is destroyed, TextInput._onRemoved removes the focused <input> from document.body, which resets document.activeElement to <body>. Nothing in packages/ ever focuses the canvas back. The only .focus() calls in the whole tree are TextInput.ts:195 and :203.

So: open Export, which focuses the textarea by design, press Escape, then press Ctrl+C. Nothing happens. No toast, no error. The user gets it back only by clicking the canvas.

The canvas already has tabindex="1", so a CANVAS.focus() on dialog close is the whole fix. quick-actions.spec.ts:242 presses Escape and then asserts only openDialogCount() === 0, so no test can see this today.

The empty-field test cannot fail for its stated reason

quick-actions.spec.ts:185 is written to cover runImport's up-front empty guard. Delete that guard and every assertion still passes. importReplace('') falls through to new URL('https://') and throws, importReplace's own .catch swallows it, so there is no pageerror; the dialog stays open; the blueprint is untouched. The only observable difference is the toast text. Asserting that would close it.

One comment is wrong in a way that invites a real bug

ToolsPanel.ts:319 says the quickbar overlap below ~866px is "the same trade-off a real user can still click through". UIContainer.ts:40 adds the panel after the quickbar, so it draws on top, and pixi hit-tests children in reverse. Those slots are dead, not click-through. Someone reading that comment and widening the panel or relaxing the clamp would make it worse, and nothing in tests/ hit-tests over the quickbar, so it would land green.

Take or leave: the panel is a noticeably larger permanent dead zone over the canvas than WiresPanel was, and it is added after dialogsContainer, so at short viewport heights it covers the bottom of a centred dialog.

@wormeyman wormeyman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Details in the comment above. Short version: almost everything from the previous rounds is fixed with the mechanism removed rather than the wording satisfied, and the newest commit is a real deterministic fix rather than a settle-wait.

Three things left. The one worth doing before merge is the focus bug: closing either dialog leaves Ctrl+C and Ctrl+V silently dead, because nothing focuses the canvas back. CANVAS.focus() on close is the whole fix.

Ctrl+C and Ctrl+V went silently dead - no toast, no error - from the
moment either dialog had been open, and stayed dead until the user
clicked the canvas. Open Export, press Escape, press Ctrl+C: nothing
happens.

Both clipboard listeners in packages/website/src/index.ts open with
`if (document.activeElement !== CANVAS) return`. `TextInput._onRemoved`
takes the focused <input> out of document.body as the dialog is
destroyed, and removing the focused element resets
`document.activeElement` to <body>. Nothing in packages/ focused the
canvas back - TextInput's own two calls are the only `.focus()` in the
tree - so that guard held from then on. The canvas already carries
`tabindex="1"`, so the call is the whole fix.

Conditional on nothing else being open, which is a reachable case
rather than a theoretical one: Import and Export can be open together
(UIContainer tracks them separately), and while a field is on screen
the keyboard belongs to it - a Ctrl+C there has to copy the selected
text rather than reach the listener that encodes the whole blueprint.

`Dialog.close()` is the one place every path goes through: Escape,
clicking the ToolsPanel slot again, `closeAll()` on a blueprint load,
and the entity editors, which carry TextInputs of their own and had
the same dead-clipboard symptom.

Both halves are mutation-checked against the running editor rather
than reasoned about. Removing the focus() call fails the first new
test with `Received: inactive`; removing only the `anyOpen()` guard
fails the second with `Received: focused`. The pre-existing Escape
test at quick-actions.spec.ts:242 sees neither - it asserts
openDialogCount() alone, which is why this needed its own coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@wormeyman wormeyman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I drove this one rather than only reading it: a worktree at the PR head, both dev servers up, and mutations applied one at a time.

The mechanism is right. The canvas carries tabindex="1" (packages/website/index.html:90) and G.app.canvas is that same element, so it is the one the clipboard guards compare against. close() filters s_openDialogs on its first line, so anyOpen() after destroy() is not off by one. destroy() removes the <input> synchronously, so nothing can undo the focus() afterwards. #editor has outline: none, so no focus ring appears.

The two tests carry their weight. I measured the mutation table rather than reasoning about it:

mutation test 1 (last dialog) test 2 (one still open)
remove G.app.canvas.focus() fails passes
drop the !anyOpen() guard passes fails

Each kills exactly one. The end-to-end claim holds too: after opening and closing ExportDialog with Escape, a real Ctrl+C raises the "copied to clipboard" toast with no canvas click first.

One regression

With a dialog open, the BP Book Index arrow keys stop working after one press.

settingsPane.ts:47 hangs its own keydown handler on that input. ArrowUp calls changeBookIndex -> editor.loadBlueprint -> Dialog.closeAll() (Editor.ts:415) -> the last close -> G.app.canvas.focus(). The live <input> loses focus, so the second arrow reaches the editor's keybinds instead of the box.

focus after 1st arrow index after 1st / 2nd
this PR CANVAS 1 / 1
commit reverted INPUT 1 / 2
no dialog open (control) INPUT 1 / 2

Nothing in the suite can see it. git grep ArrowUp tests/ is empty.

Fix

Only claim focus that is genuinely orphaned:

-        if (!Dialog.anyOpen()) G.app.canvas.focus()
+        if (!Dialog.anyOpen() && document.activeElement === document.body) {
+            G.app.canvas.focus()
+        }

Reading document.body separates "the field that had the focus was just destroyed" from "something else still holds it". The first is the case the hand-back is for, and the only one it should touch. The comment above the guard needs the second half explained; I can hand over my wording if that helps.

With this applied, vp check, vp test (188) and the full Playwright suite (218) all pass. Reverting just the activeElement half fails the second test in the spec below and nothing else.

tests/settings-pane-book-index.spec.ts (new file)
import { test, expect } from '@playwright/test'
import {
    encodeBlueprintBook as encodeBook,
    packVersion as version,
} from './helpers/encode-blueprint'
import { loadBlueprint, waitForEditor } from './helpers/fbe-test-api'

/*
    The settings pane's BP Book Index box, and the first coverage its arrow
    keys have ever had - `git grep ArrowUp tests/` was empty before this file.

    `packages/website/src/settingsPane.ts` hangs its own `keydown` listener on
    that dat.gui `<input>` so ArrowUp/ArrowDown step the index. Stepping is
    the whole reason the listener exists, and stepping means the *second*
    press has to work as well as the first - which is what makes this
    reachable at all, and what nothing else here can see.

    Written for a regression the #242 focus hand-back introduced (see
    `Dialog.close()`). Each step calls `changeBookIndex` ->
    `editor.loadBlueprint` -> `Dialog.closeAll()`, so with any dialog open the
    first arrow closes it, and an unconditional `G.app.canvas.focus()` on that
    close pulled the focus off the live <input>. The second arrow then reached
    the editor's keybinds instead of the box. Measured 0 -> 1 -> 1 against the
    0 -> 1 -> 2 the control gives.

    Two things this spec is shaped by.

    The pair is a real pair, not a test and a restatement. Only the second
    test fails against the unconditional guard; the first passes there, and
    both pass with the focus hand-back deleted outright. So the control says
    the arrows work at all and the treatment says a dialog does not break
    them, and neither one alone distinguishes the three states.

    It does not call `suppressOverlays`. That helper sets `pointer-events:
    none` on `.dg.main`, which is this pane - the very thing the test has to
    click. The pane is bottom *left* and the toasts are bottom *right*, so
    there is nothing here for the suppression to protect against anyway.

    The book entries carry a different entity count each, and that is the
    synchronisation. `setValue` writes the box synchronously and only then
    starts an async load, so waiting on the box's own value proves nothing
    about whether the load - and the `closeAll` inside it - has run yet.
    Pressing the second arrow into that gap passes whatever the guard does.
    `entityContainerCount()` moves only when a load finishes, and it names
    *which* entry finished.
*/

const VERSION = version(2, 0, 55)

const chests = (n: number): Record<string, unknown>[] =>
    Array.from({ length: n }, (_, i) => ({
        entity_number: i + 1,
        name: 'wooden-chest',
        position: { x: i + 0.5, y: 0.5 },
    }))

/** Entry i holds i+1 chests, so entityContainerCount() names the loaded entry. */
const BOOK = encodeBook({
    item: 'blueprint_book',
    version: VERSION,
    active_index: 0,
    blueprints: [0, 1, 2].map(index => ({
        index,
        blueprint: { item: 'blueprint', version: VERSION, entities: chests(index + 1) },
    })),
})

type Page = import('@playwright/test').Page

const bpIndexInput = (page: Page) =>
    page.locator('.dg .property-name:has-text("BP Book Index")').locator('..').locator('input')

const activeTag = (page: Page): Promise<string | undefined> =>
    page.evaluate(() => document.activeElement?.tagName)

/** Waits for the load the arrow started to finish, by the entry it landed on. */
const waitForEntry = (page: Page, index: number): Promise<unknown> =>
    page.waitForFunction(n => window.__fbe_test.entityContainerCount() === n, index + 1)

test.beforeEach(async ({ page }) => {
    await waitForEditor(page)
})

test('CONTROL: the arrow keys step the book index twice over, with no dialog involved', async ({
    page,
}) => {
    await loadBlueprint(page, BOOK)
    const input = bpIndexInput(page)
    await input.click()
    expect(await activeTag(page)).toBe('INPUT')

    await page.keyboard.press('ArrowUp')
    await waitForEntry(page, 1)
    expect(await activeTag(page)).toBe('INPUT')

    await page.keyboard.press('ArrowUp')
    await waitForEntry(page, 2)

    await expect(input).toHaveValue('2')
})

test('a dialog closing on the first arrow does not steal the focus off the index box', async ({
    page,
}) => {
    await loadBlueprint(page, BOOK)
    await page.evaluate(() => window.__fbe_test.openExportDialog())
    expect(await page.evaluate(() => window.__fbe_test.openDialogCount())).toBe(1)

    const input = bpIndexInput(page)
    await input.click()
    expect(await activeTag(page)).toBe('INPUT')

    await page.keyboard.press('ArrowUp')
    await waitForEntry(page, 1)

    // Load-bearing: without this the test could pass on a dialog that never
    // closed, which is the one state that cannot exercise the guard at all.
    expect(await page.evaluate(() => window.__fbe_test.openDialogCount())).toBe(0)
    expect(await activeTag(page)).toBe('INPUT')

    await page.keyboard.press('ArrowUp')
    await waitForEntry(page, 2)

    await expect(input).toHaveValue('2')
})

The guard's stated reason is not true

The comment says that while another dialog is open, "its field is what the keyboard should be talking to". It is not. Only ExportDialog focuses its own field (ExportDialog.ts:168); ImportDialog never does.

I drove the exact case the second test sets up, open Import, open Export, Escape, and document.activeElement.tagName is BODY, with the Import textarea on screen and unfocused. Typing reached no field at all. The keys went to the editor's keybinds, and the E in my test string opened the inventory dialog.

That state has the same dead keyboard the commit removes elsewhere. It predates the commit, so it is not a regression. But the second test pins it as intended behaviour, and the comment justifies it with something the code does not do. Either correct the comment or focus the surviving field.

Minor

  • The commit message says TextInput's two calls are "the only .focus() in the tree". Two more live in tests/. The in-code comment says "in the package" and is correct.
  • The mutation table is in the commit message. paste-placement.spec.ts and splitter-wires.spec.ts both keep theirs in the file header, which is what someone reads before deleting a test that looks like it always passes.
  • page.evaluate(() => window.__fbe_test.openExportDialog()) now appears 7 times raw, while openImportDialog has a helper.

One more that I could not verify: s_openDialogs leaks if a dialog subclass constructor throws, which would leave anyOpen() permanently true and disable the hand-back for the rest of the session. I found no reachable path to it.

bd85731's hand-back ran on any close that was the last one, and that
took the focus off live elements.

The settings pane's BP Book Index box steps on ArrowUp, and each step
runs `changeBookIndex` -> `Editor.loadBlueprint` -> `Dialog.closeAll()`.
So with any dialog open, the first arrow closed it and the hand-back
pulled the focus off the <input> still being used; the second arrow
then reached the editor's keybinds instead of the box. Measured
0 -> 1 -> 1, against the 0 -> 1 -> 2 with no dialog open. Reported in
the FactoryGameFan#242 review, with the spec landing here as
tests/settings-pane-book-index.spec.ts - the first coverage those
arrow keys have ever had (`git grep ArrowUp tests/` was empty).

Reading `document.activeElement === document.body` separates "the
field that held the focus was just destroyed" from "something else
still holds it". Only the first is what this exists for.

The `!Dialog.anyOpen()` half is gone rather than kept alongside it,
because the reason written down for it was not true of this code: it
said a surviving dialog's field is what the keyboard should be talking
to, and only ExportDialog focuses its own field - ImportDialog never
does. Closing the topmost of two therefore left `activeElement` on
<body> with a field on screen and nothing focused, which is the same
dead keyboard bd85731 removes everywhere else, only with a dialog
still open. The body check handles that case on its own terms: a field
that genuinely holds the focus is not <body>.

The spec that pinned the wrong half of this now pins the right one -
focus parked in Import's field, Export closed out from under it, and
the field keeps the keyboard.

Measured against the running editor, and in the spec header rather
than only here, since that is what someone reads before deleting a
test that looks like it always passes:

| hand-back           | last-dialog | field-keeps-it | book-index |
| ------------------- | ----------- | -------------- | ---------- |
| removed outright    | FAIL        | pass           | pass       |
| no condition        | pass        | FAIL           | FAIL       |
| earlier !anyOpen()  | pass        | pass           | FAIL       |

Only the book-index test separates the last two rows, and the guard it
drives lives outside the editor package entirely.

Also from the same review: `openExportDialog` has a helper now, the
way its Import sibling already did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wormeyman
wormeyman merged commit 9aee2ac into FactoryGameFan:wormeyman-space-age-support Aug 30, 2026
9 checks passed
wormeyman added a commit that referenced this pull request Aug 30, 2026
…s that overstate the code (#284)

Closes #281.

Four follow-ups from #242. Only the first adds coverage; the rest are a wrong
fixture and wording that says more than the code does.

The untested case is the point. `Dialog.close()`'s comment argues at length for
why the focus hand-back is deliberately not also conditional on this being the
last dialog, and nothing pinned that - every existing test passes under
`!Dialog.anyOpen() && activeElement === document.body` too, so re-adding that
half would have gone green everywhere a person would look. The new test opens
Import, opens Export, waits for Export's field to take focus, closes Export
through its toggle, and asserts one dialog is still open and the canvas is
focused. Measured, it is the only test in either file that mutation fails.

The mutation table for all three guard variants is now in the spec's file
header, where someone looks before deleting a test that seems to always pass.

The book fixture said `blueprint_book` where the schema pins `blueprint-book`,
and fixing that character left it still warning - `definitions/blueprint` also
requires `icons`, which the fixture had none of. Confirmed clean against the
committed fixture afterwards. This is repo-wide rather than local: quick-actions'
own ONE_CHEST and book-serialize's book warn for the same reason, and are left
for their own pass.

Also restores a dropped `toBeFocused()` barrier that let ExportDialog's deferred
`select()` race a click, and fixes three comments plus `Dialog.close()`'s own.
That one opened with "only when this close is what orphaned it", which the check
cannot see - it reads whether anything holds the focus right now, and a third
answer is "nothing held it to begin with". Dragging the BP Book Index slider
leaves <body> focused, and an InventoryDialog owns no <input> at all. The guard
is fine; the sentence was not. It also stops quoting the clipboard listeners'
condition verbatim, since #279 gave it a second half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

3 participants