Add a ToolsPanel with entity-info toggle/import string/export string/export image/undo/redo UI actions - #242
Conversation
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.
…show checkbox" This reverts commit e924856.
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.
…eprints" This reverts commit adce7a8.
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.
…o wires-panel-actions
…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.
|
PR recreated after the repo detach (was #221). Ready for review — waiting on any fixes/comments. |
wormeyman
left a comment
There was a problem hiding this comment.
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:
ExportDialog's pre-selection never fires (ExportDialog.ts:43).select()runs in a microtask while the textarea is stilldisplay: none, soCtrl+Cfalls through the documentcopylistener tonavigator.clipboard.writeText- the exact API the dialog exists to bypass.exportGuardResultdoesn'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_testis 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.
…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.
wormeyman
left a comment
There was a problem hiding this comment.
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.
…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
left a comment
There was a problem hiding this comment.
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.
…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.
… 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>
…t' into wires-panel-actions
- 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.
|
@claude review |
|
Claude finished @wormeyman's task in 3m 7s —— View job Review complete
Reviewed Verified fixed (checked against the code, not the commit message)
One remaining item, only partially 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)
The sibling 150px test's fix works precisely because its target ( 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. Still open from earlier rounds (non-blocking, convention only)
OverallNine 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. |
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
|
Re-reviewed at Three things left. One is user-facing. Closing either dialog leaves Ctrl+C and Ctrl+V silently deadBoth handlers open with 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 The empty-field test cannot fail for its stated reason
One comment is wrong in a way that invites a real bug
Take or leave: the panel is a noticeably larger permanent dead zone over the canvas than |
wormeyman
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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 intests/. The in-code comment says "in the package" and is correct. - The mutation table is in the commit message.
paste-placement.spec.tsandsplitter-wires.spec.tsboth 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, whileopenImportDialoghas 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>
9aee2ac
into
FactoryGameFan:wormeyman-space-age-support
…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>
Re-submission of #221, recreated after the repo detach.
Summary
navigator.clipboardor a string that needs to be seen/edited.Layout notes
.toasts-container(position: fixed; right: 0), confirmed by clicking a widened-panel slot and finding the click reached the toast instead of the button.G.bp.history.undo()/redo()directly (same as their keybinds in Editor.ts); the four clipboard/file actions go through a newQuickActionsinterface injected viaEditor.init(), mirroring the existingLoggerinjection.import_slot/export_slot) and thesignal-anticlockwise-circle-arrow/signal-clockwise-circle-arrowvirtual signals for Undo/Redo — the only undo/redo-shaped icons anywhere in vanilla Factorio's data.Test plan
vp check/vp testpass