Skip to content

feat: rework button grid interactions - #4434

Open
Julusian wants to merge 39 commits into
mainfrom
claude/button-editor-grid-ux-s0a9j0
Open

feat: rework button grid interactions#4434
Julusian wants to merge 39 commits into
mainfrom
claude/button-editor-grid-ux-s0a9j0

Conversation

@Julusian

@Julusian Julusian commented Aug 25, 2026

Copy link
Copy Markdown
Member

Multiselect, drag and drop etc

Closes #2834, closes #1124

Screenshot From 2026-08-22 11-59-30

Summary by CodeRabbit

  • New Features

    • Added a grid toolbar with selection, arrange, press, delete, copy, move, and swap tools.
    • Added multi-button and marquee selection, drag-and-drop previews, panning, zooming, and cross-page transfers.
    • Added page actions for editing, exporting, navigation recreation, and clearing buttons with confirmation.
    • Added keyboard shortcuts, clipboard operations, and remembered page navigation.
  • Bug Fixes

    • Improved handling and feedback for invalid, off-grid, canceled, and overwriting drops.
  • Tests

    • Expanded coverage across grid interactions, transfers, navigation, menus, drag-and-drop, keyboard controls, and selection.

claude added 30 commits August 23, 2026 13:42
The grid cell fired its click on pointerdown, so a touch that was meant to
scroll the grid opened the editor before the finger had moved. On the
single-panel layout that is a full-screen context switch to back out of.
`.button-control` also sets `touch-action: none`, which left the grid barely
scrollable by touch at all, since buttons cover nearly the whole canvas.

Add a grid-specific cell component. A tap now commits on release, and only when
the pointer stayed put; the browser is allowed to pan, and the pointercancel it
fires when it takes a gesture over is read as "that was not a tap".

ButtonPreview is deliberately left alone rather than parameterised. It emulates
a physical surface, where a touch is always a press and must never be
reinterpreted, and it carries a set of hard-won Android workarounds. The markup
and CSS are shared; only the gestures differ.

Press mode keeps firing on pointerdown - press and release map to real
button-down/up on the control - and holds `touch-action: none` so a scroll
cannot steal a press.

The gesture handlers reach the cell through a new per-grid-instance context
rather than being threaded down as props, which is the seam the rest of the
interaction rework hangs off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
The grid had three mechanisms racing to interpret a click: an imperative
interceptor from the action bar under the grid, a global "is shift held" flag,
and the default select path. Which one won depended on state spread across three
components, and shift - the one modifier every grid UI uses for range-select -
was spent on firing buttons for real.

Replace all three with one active tool that alone decides what a tap means.
Tools are objects rather than a flag, so each owns its own phases: "back" means
unwind one step of a half-finished copy, or drop the selection, depending on
which tool is asking. Escape is now "one step back, then out" rather than a jump
to nothing.

Copy/move/swap/delete keep the two-tap flow and its wording exactly - it is
still the best way to do this on a touchscreen - but they now stay armed
afterwards, so repeated work no longer means re-arming between each one. They
also capture their sources rather than reading the selection as they go, which
is what lets a transfer survive changing page, while a selection (which belongs
to one page) is dropped.

Chrome goes down, not up. The bar under the grid, the heading, the paragraph of
explanation and the permanent keyboard-hints alert are all gone; the tools move
into a palette above the grid, and wipe page / recreate navigation buttons move
into a page menu, out of reach but not hidden. What is left is one toolbar plus
a context bar that keeps its height whether or not it has anything to say.

Press mode gets a banner in the space the heading used to occupy, since a dull
tint on the grid was far too easy to miss for something that runs real actions
on real hardware, and it is never restored on load.

Selection lives in a per-grid store rather than component state, built on
useSyncExternalStore. Each cell subscribes to one boolean about itself, so
changing the selection re-renders the two cells that changed rather than all of
them. Being per-instance rather than app-wide is also what would let two grids
sit side by side later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
Selection was one button, full stop, so rearranging a page meant repeating the
same two-tap dance for every button in it.

Add the three gestures everyone already knows: shift-click extends a rectangle,
ctrl/cmd-click toggles one cell, and dragging across the grid rubber-bands. Shift
is only free to mean this because hot pressing moved to its own tool. The arrow
keys grow the same way - shift extends from the anchor, ctrl walks the focus
without disturbing the selection, space picks out the focused cell - which is
also why focus and selection are tracked separately rather than as one thing.

Arrow navigation now scrolls the focus into view. It never did, which was
survivable when the selection was a single button you had just clicked, and is
not once the keyboard is the way you build a selection.

The transfer tools already worked in terms of "the sources", so they generalise
to regions for free: a copied block keeps its shape, anchored at the tapped cell,
and goes out as one atomic request.

With more than one button selected the right-hand panel shows the region as the
shape it actually is, gaps left as holes, rather than a list of coordinates
nobody can picture. It only takes over the panel on a wide screen, where the grid
stays visible - on a narrow one it would replace the grid mid-gesture.

Marquee is left to the mouse. On touch a drag scrolls the grid, which matters far
more than rubber-banding with a finger.

Also replaces the page-number fixup that reassigned a variable mid-render (and
navigated from a setTimeout) with a resolved value plus an effect, since the
keyboard handler now needs to know which page it is looking at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
Nothing on the grid could be dragged: cells were drop targets for presets and
otherwise inert, so rearranging a page meant the pick-then-place tools even when
the buttons were right next to each other.

Buttons now drag. Which ones depends on the tool: in select only an
already-selected button drags, so dragging anywhere else can still rubber-band,
and the new arrange tool lets any button drag for when dragging should only ever
mean dragging. Press mode allows none of it - a drag must never swallow a press
that is about to fire real actions.

The cell the drag started from lands under the cursor and the rest of the
selection keeps its offset from it, so there is no anchoring rule to learn: what
the ghost shows under the pointer is where it goes. Dropping one button onto
another trades places, the way rearranging icons does everywhere else; a region
cannot do that without scattering what it displaced, so it overwrites and asks
first. A region that would hang off the edge is refused outright rather than
dropping the buttons that happen to fit.

The ghost draws the region with its gaps left as holes. It costs nothing beyond
the elements, because image subscriptions are already shared with the grid.

Also adds middle-drag to pan and ctrl+wheel to zoom, both of which the grid
wanted long before it could be dragged on.

Alt-to-copy is deliberately absent. It would need a global modifier listener,
which is exactly what hot pressing used to be and exactly why it was moved; copy
stays an explicit tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
They are two views of the same store, so the thing worth checking is that
picking a tool in one shows up in the other - the hint appearing and following
the tool to its next step, cancel unwinding one step before leaving, and the
selection bar arming the matching tool.

Also gives the tool buttons an explicit aria-label. Their text label collapses
when the toolbar is narrow, which left them as icons with no accessible name.

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

Ctrl-clicking builds up a scattered selection and rubber-banding is faster
still, but neither is available to a finger: a touch drag has to stay as
scrolling the grid, and there is no ctrl key. So multi-select becomes a mode you
enter, where every plain tap adds or removes a button - which works the same way
with a mouse, for anyone who would rather not hold a chord. It keeps whatever is
already selected when entered, so it can be reached for part-way through picking
things out, and shift still extends a range.

That made an eighth tool, and running the real thing showed what the tests could
not: the palette was already wrapping onto a second row, and the selection bar
onto a third. Four bands of chrome above the grid, which is exactly what this
rework was meant to remove.

Two fixes. The palette's collapse threshold was guessed too low, so the labels
"fit" by wrapping - it is now measured from how wide the labelled row actually
gets, and the active tool keeps its label even when the rest collapse, so which
mode the grid is in is never left to be inferred from an icon.

And the selection bar no longer repeats copy/move/swap. Those are the tools in
the palette directly above, which already pick up the selection - that is the
whole point of the tool model, so a second copy of them was pure chrome. Delete
stays, because it is the exception: it has no destination to pick, so the
palette's delete tool works tap-by-tap and clearing a whole selection belongs
next to the count it acts on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
The previous fix swapped one guessed pixel threshold for another. A fixed number
in JS cannot answer this: it is measured at one font size and one zoom level, so
a reader with larger text gets labels the row no longer has room for, and it says
nothing about widths below the icons-only row either.

Hand the decision to CSS, where it belongs. A container query on the toolbar
asks about the toolbar's own width - the panel it sits in does not track the
viewport, so a media query would be answering a different question - and the
threshold is in rem, so it moves with the reader's font size instead of
promising room that a larger font has taken away. Where container queries are
unsupported the labels simply stay collapsed, which is the safe direction to
fail.

Below that, the active tool keeps its label so the current mode is still named;
below that again even it goes, since it is the one thing tipping a ~440px panel
into a second row, and the active tool is already the only coloured one.

The group divider is gone. It was a flex item, so it stranded itself at whatever
width the row happened to wrap at; a wider gap between the groups says the same
thing and cannot dangle. It also means a wrap lands on the group boundary, so a
phone gets modes on one row and tools on the next rather than an arbitrary split.

Measured across panel widths from 277px to 894px: one row everywhere except a
phone, where eight full-size tap targets cannot fit one row and two tidy rows is
the right answer. No horizontal overflow at any width.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
The grid's tool palette was a row of ordinary Buttons, which read as a row of
buttons rather than as a toolbar. The layered button editor already had the
right visual language for this - a bordered strip of flat icon buttons with the
active one in the accent colour - but it was private to that component, so
matching it by eye would have meant a second copy of the same rules, free to
drift.

Extract it instead. Toolbar.Root/Button/Separator/Group in Components, with the
CSS that used to live in EditButton.css, parameterised by orientation and size:
the style editor keeps its vertical 'sm' rail unchanged, and the grid gets the
same thing horizontally at a size that can still be hit with a thumb. All that
is left in EditButton.css is where the rail sits relative to the canvas.

Toolbar.Group exists for the wrapping case: without it a toolbar narrow enough
to wrap breaks between arbitrary buttons, which on a phone left a lone delete
button on a second row. With it the break falls between the modes and the
pick-then-place tools.

Tone is a property of the button rather than a colour chosen at the call site,
so press and delete stay red for the reason that they are live or irreversible.

Verified in a browser: the style editor's rail is unchanged at 34x288, and the
grid toolbar is a single row at every panel width measured from 337px up, with
the phone the only two-row case.

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

Copy and move were off by one step. Clicking a button to look at it selects it,
and the transfer tools took any selection as their source, so arming Copy went
straight to "where do you want it?" - and the tap the user meant as the source
became the destination, copying whatever they had last looked at. Only a
deliberate selection of several buttons is a statement of intent now; one
selected button is just the one you were looking at. Delete gained the mirror of
that rule, so a multiple selection can be cleared by touch, where there is no
delete key.

The rest is what running it showed:

The toolbar spans the panel and carries its own status line, so it reads as a
toolbar rather than a huddle of buttons, and the row below is no longer an empty
band that looks like stray padding. Idle it names the current mode rather than
sitting blank; cancel keeps its place, disabled, rather than appearing and
reflowing the bar.

Press mode's warning moved into that status. As a banner above the toolbar it
pushed the whole grid down the page the moment it appeared, which is a strange
thing for a warning to do. The toolbar is 42px in every state now.

Per-button labels are gone rather than shown only for the active tool, which
read as an odd one-off. They also could not earn their place: all eight need
883px, which leaves nothing for a status message on any real panel. The status
names the active tool at every width instead, which is what the labels were for.

Buttons were 38px around a 12px icon, which is why they looked over-padded -
32px around a 16px icon reads as a toolbar button. The group divider no longer
stretches the full height, where it looked like the bar had been padded out.

The page menu had none of the app's dropdown styling, because all of it is
scoped to two specific positioner classes and I had used neither.

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

Dragging with the move tool armed drew a selection box. The marquee was offered
by the grid regardless of which tool was active, so a drag that meant "nothing
here" still rubber-banded, and the box outlived the gesture. Only the selecting
tools offer it now. The same pointerdown also started a marquee behind a button
being dragged, so a drop both moved the button and selected the region it
crossed; a drag in progress now cancels it.

Restore the heading and the paragraph above the grid. Removing them was my call
and it was wrong - it left this page not matching the others, and orphaned the
help icon into the page-picker row where it had no business being. It goes back
beside the heading, which is what it was styled for.

The right-click menu now acts on the selection when the click lands inside one,
naming the count so it is clear what is about to happen. Clicking outside a
selection is still about the button under the cursor and leaves the selection
alone. Press and abort stay per-button either way - firing five buttons from a
menu entry is not what anyone means by it.

The selection preview drew its buttons as a fraction of the panel width, so
selecting a column produced four enormous soft tiles and a scrollbar. They cap
at 72px now, the size the grid itself draws at and the size the previews are
rendered for.

The toolbar's status drops to its own row when the bar is too narrow to hold it
beside the buttons, rather than shrinking until it needs an ellipsis. Its basis
is fixed rather than sized from its content, so which row it lands on depends on
the width available and not on how long the current message happens to be.

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

Dragging marked only the cell under the cursor, which says nothing about where
the rest of a region ends up or whether the drop will be allowed at all - both
worth knowing before letting go. The whole landing region now lights up while
the drag is in flight, in red when it would be refused for hanging off the grid.
The preview and the drop resolve the drag through the same call, so the two
cannot disagree about where the buttons were going.

planGridDrop describes a refused drop rather than returning null for it, since
the preview needs to draw the thing it is refusing. Dropping a preset still
marks every cell as a target, because that is news; dragging a button around the
grid no longer does, because every cell is a target and saying so buried the
part that mattered.

After a move the old positions stayed selected, pointing at where the buttons
used to be. The selection follows them to where they landed now. That lives in
the transfer action rather than in the tool, so it holds for dragging, for the
tap-tap flow and for pasting alike - the drag path had never touched the
selection at all, which is where this was most obvious.

The selection preview was capped at 72px, which the panel had room to beat. It
fills the width available up to twice the size the grid draws at, and the cap is
on the whole grid rather than each column, so it can never want a scrollbar.

The status and the cancel button are kept together, so a bar too narrow to hold
them beside the tools moves both to the next row instead of leaving the message
on one row and a lone button stranded on the next. Checked from 360px to 2560px:
they share a row at every width.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
The group divider was sized as a percentage of the toolbar's height, which is
the thing the toolbar is still working out. It settled on 40px against a 32px
row, so the first line came out taller than the height the toolbar had reserved
for it, and everything after that hung a few pixels below the bar - the status
background and the cancel button most visibly.

Size it from the button instead. Nothing then depends on a height that depends
on it, and the toolbar grows to hold however many lines it needs: on a 267px
panel that is three, with the tools on two rows and the message on a third,
rather than two rows and an overflow.

Cancel is enabled in press mode. Escape already left press mode, so the button
that means the same thing had no business being greyed out.

Measured from 280px to 600px: no overflow at any width, and every row sits
inside the bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
Arming a transfer tool with several buttons selected captured them as its
sources but left them selected as well - two copies of the same thing. Anything
that cleared one left the other behind: deselecting from the right-hand panel
emptied the selection while the tool carried on holding the same buttons and
asking where to put them.

One owner at a time. Picking them up now clears the selection, so the panel and
its deselect button go with it and there is nothing left to half-clear. Backing
out hands them back rather than dropping them, since misclicking a tool should
not cost you the selection you built up to use it.

That tab only exists while it has something to show, so it could be the tab you
were on and then stop existing - which left the panel blank. It now falls back
to Pages when the last of its content goes away. Clearing a multiple selection
any other way had the same hole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
The drop preview outlined the cells a drag would land on, which tells you
something is arriving but not what. Lining a large block up means seeing which
button lands where, so each destination now draws that button's own image over
the cell. A swap describes both ends, since it displaces something as well as
placing something - showing only half of that is showing the easy half.

Pasting could put buttons outside the grid, where nothing can reach them, with
no warning at all. Both paste paths now go through one place that checks first:
a paste that would not fit is refused and says so, and one that would replace
something asks and names the count. Refusing rather than pasting the part that
fits is deliberate - a cut whose destination was dropped would take the source
with it and leave nothing behind.

The backend checks the bounds too. The UI owns the geometry, but a location off
the grid is not something to take on trust from arithmetic done elsewhere.

Clicking an empty cell with delete armed asked whether to clear nothing. It is
ignored now, and so is picking an empty source to copy or move - there is
nothing to carry, and it would quietly wipe whatever it was dropped on. Swap
still takes either end, since trading with an empty cell is how a button gets
moved into one.

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

Ctrl+C and Ctrl+X marked their buttons on the grid and then said nothing about
it. The toolbar reported the current tool, cancel sat greyed out, and escape did
not touch it - so mixing the keyboard with the toolbar could leave buttons
marked with no way to find out why or to clear them.

The clipboard is now part of the same in-progress state as everything else. The
toolbar says what is held and what to do with it, cancel is offered while it is,
and escape unwinds it - after any selection, so each press does one visible
thing rather than clearing everything at once.

It is reported ahead of the selection count on purpose. A pending cut is an
operation waiting to happen where a selection is only a highlight, and it is the
half that was invisible, which is how this went unnoticed in the first place.

The clipboard is deliberately still not the same thing as an armed tool: it
survives a page change, it stays put so a copy can be pasted more than once, and
the right-click menu pastes from it. Sharing the state would cost all three.
Sharing the way out of it was the actual problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
Picking the source for a copy, move or swap meant tapping one button, so acting
on a block meant selecting it first with a different tool and then arming the
one you wanted. Dragging a box now picks the sources directly, and under delete
it picks what to clear.

Whether a box means anything is the tool's business rather than a flag on it,
because for a transfer it depends on where the tool has got to: a box while it
is asking what to take, and none once it is asking where to put them, where one
would mean nothing and leave a stray rectangle behind. Press mode still offers
none at all.

A box over nothing but empty cells is a stray drag rather than a choice, so it
is ignored - the gaps inside a region are still part of its shape and travel
with it. Delete counts only the cells that hold something, so the confirmation
names the number of buttons going rather than the size of the box.

The rectangle geometry moves out of the store, so the tools can reach it without
importing the store that owns them.

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

Arming Copy/Move/Swap and picking a source told you "Where do you want it?"
and nothing else. The buttons are anchored by the top-left of what was picked
up, which is impossible to work out from a box dragged bottom-right to
top-left: you have to click the new home of a button that isn't the one you
started the box from. The click that commits was the first sign of what it
actually did.

The grid now reports the hovered cell, and a transfer tool holding buttons
draws them there through the same ghost machinery a drag uses - real button
images, both ends of a swap, and a red outline when the region would hang off
the grid. It also means an armed tool is visibly armed while the pointer is
over the grid, rather than being mistaken for one you had already left.

Placing off the grid is now refused rather than putting the part that fits
somewhere and losing the rest, matching what dropping a region off the edge
and pasting past the edge already do. The tool keeps hold of the buttons, so
the cost is one more tap somewhere with more room.

`buildTransferPairs` and `previewPlacements` move to GridGeometry, which is
where the tools can reach them without a cycle back through the store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
… ask before overwriting

Two ways to lose a button without being asked.

Copying a region with gaps in it stamped the gaps down too, clearing whatever
they landed on. The gaps in a region are its shape, not its contents: they
decide where the buttons around them land, and then have nothing of their own
to place. Copying a hole over a button is not a way to delete that button, so
they now leave what they pass over alone. A swap is unchanged - trading with
an empty cell is how a button is moved into one, and it destroys nothing
either way. The backend applies the same rule, so no caller can ask for the
old behaviour.

Overwriting was confirmed when dropping a region and when pasting, but not
when placing with the toolbar tools - the flow most likely to be carrying
several buttons at once. Rather than adding a third copy of the question,
every way of moving buttons about now goes through one `transfer` that drops
the empty sources, refuses a placement that would fall off the grid, and asks
before replacing anything. It reports back only once the transfer has actually
been sent, so a tool that was cancelled at the prompt is still holding its
buttons, and a cut is only spent on a paste that happened.

The count in the prompt is of buttons actually being replaced, so the gaps in
a region are not counted, and neither is a cell the region is vacating anyway.

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

Two things about placing a region.

Getting the selection wrong meant starting the tool again. Shift and ctrl
build a selection, so they now revise one that has been picked up: ctrl takes
a button in or out, shift extends a rectangle from wherever the pick started,
and a shift/ctrl box adds another region to what is in hand. Taking the last
button away goes back to asking for a source rather than holding nothing while
still claiming to be waiting for a destination. While a modifier is held the
landing spot is not drawn, since the next click revises rather than places.

A plain box still means nothing while a tool is holding buttons, so a stray
drag cannot replace the selection built up to be placed. That gate moves from
a boolean the grid subscribes to into `allowsMarquee(additive)`, asked as the
drag starts - so a box that would mean nothing is never drawn, and the grid
stops re-rendering every time a tool changes phase.

The region also anchored by its top-left, which put the cursor at a corner of
whatever it was carrying and everything else below and right of it. It now
centres on the cursor: "put it here" needs no rule, where "its top-left corner
goes here" does. Pasting keeps the top-left - its destination is named rather
than pointed at, there is no ghost to show which cell the answer was measured
from, and centring a 3x3 on the top-left cell of the grid would need a row
above the grid and be refused. The two agree exactly for a single button.

Also: a cell that cannot be dragged no longer reports itself as
`aria-disabled`. dnd-kit marks whatever holds a disabled draggable's ref that
way, and in select mode that is every unselected button on the grid - all of
them perfectly clickable. The ref is withheld instead of passed disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
A transfer tool declines to take a single selected button as its source - the
one you last looked at is not a choice, and taking it would silently make your
first tap the destination. It left that button selected, though, so the grid
showed a highlighted button the tool was not holding: it looks picked up and
behaves as though it is not.

A modifier click then made no sense. Meaning "and this one too", it had
nothing to add to, so it ignored both the modifier and the highlighted button
and picked up whatever was clicked - reading as the tool trying to place
rather than extend. So: arming clears a selection it does not take, and a
modifier click with nothing in hand takes the selection as its starting point
before applying the modifier, which is the state Escape hands back. Picking up
clears the selection too, so the two are never both non-empty.

The landing spot also blanked whenever shift or ctrl went down, on the grounds
that the next click would revise rather than place. That is true, but it means
no ghost at all for the whole time a selection is being built up with the
keyboard - which reads as the tool having lost its grip on the buttons. It
stays put now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
…cross a tool switch

Restores the landing spot going quiet while shift or ctrl is held - it was
right, and the report it looked broken in turned out to be a stale bundle. But
"nothing" is a poor answer for shift, which is the case where what the click
does is least obvious: a rectangle measured from an anchor that is not drawn
anywhere. So shift now previews the buttons it would take, dashed in the same
colour that marks what is already in hand - the same fix as the landing ghost,
applied to the other half of what a click can mean. Ctrl still shows nothing,
since the cell under it is as likely to be leaving the set as joining it.

Switching between copy, move and swap also let go of everything, so deciding a
copy should have been a move meant picking the buttons out again. What a tool
had picked up is now handed to the one replacing it, which decides what that
means: another transfer keeps holding them, however few - the choice was
deliberate and the switch is not undoing it - delete asks about them the way it
asks about a selection, and press mode puts them down, since a highlight there
only misleads. Anything else hands them back as a selection rather than
dropping them, so no tool switch quietly costs you the set you built up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
…fore the drag ghost

Shift previewed the buttons it would take; ctrl showed nothing, on the grounds
that the cell under it is as likely to be leaving the held set as joining it.
That is a reason to draw two things, not none - so the preview is now a diff
against what is held, and each cell it would change says which way. Joining is
dashed in the picked-up colour against the solid outline of what is already
held; leaving is the same dashed outline, dimmed. Shift gets the same
treatment for free: pulling the rectangle back towards its anchor now shows
the buttons that would drop out of it, which it did not before.

What the click would leave in hand is worked out in one place for both the
preview and the click itself, so the two cannot disagree about it.

Also: dragging a button showed dnd-kit's own clone of the cell for a couple of
frames before the DragOverlay mounted and replaced it with the region ghost -
long enough to see the plain button flash under the cursor first. The clone
carries the position collision detection reads, so turning the feedback off
outright stops drops resolving at all; it is hidden instead. A preset, whose
clone *is* its feedback, is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
…wap whole regions on drop

Dropping one button onto another traded places; dropping a region overwrote,
on the grounds that the displaced buttons had nowhere sensible to go. They do:
where the dragged ones came from, exactly as for a single button. Each cell
trades with the one it landed on, so a region drop now swaps too, with nothing
lost and nothing to confirm. The exception is a region nudged onto part of
itself, where a cell would be both something's source and something else's
destination and cannot be both ends of a trade - that still moves, and
overwrites the cells beyond the region it lands on.

The shift/ctrl preview also only existed for the transfer tools. It now covers
select, multi-select and arrange, drawn against the selection instead of the
buttons in hand: shift shows the rectangle it would reach, ctrl the one button
it would add or drop. Select stays quiet without a modifier, since a plain
click replaces the selection outright and lighting up everything about to go
says more about what is being left behind than what is being chosen;
multi-select speaks up for every hover, because every tap there does something.

The outline takes the colour of the set it would join - the selection's, or
the picked-up colour of what a transfer holds - so it reads against the solid
outline already on the buttons that are in that set.

The preview redraws for the cell just clicked rather than clearing, so "one
more click would undo this" is visible without moving the pointer first; and
what a click would leave selected is worked out in one place for both the
preview and the click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
…ap that empties

The ghosts were drawn at 75% over the cell they land on, so what you saw was
the two blended into a third thing that was neither. Solid now: what is drawn
is what you would get.

That also gives a swap somewhere to draw its other half. Trading with an empty
cell is how a button is moved into one, and half of that is a cell becoming
empty - which was invisible, because a ghost with no image was not drawn at
all and the cell went on showing the button that was about to leave it. A
ghost is now drawn for any cell something is heading to, and with no image it
paints an empty button.

Copy and move need nothing here. An empty source carries nothing and is
dropped before it reaches the preview, so no destination of theirs ever
empties; a move's own sources do empty, but those are the buttons in hand and
are already marked as such - blanking them would make the grid look like the
move had already happened.

Also restores the drag ghost's styles, which an over-broad edit to this file
deleted several commits ago: it had been rendering as an unstyled block, so a
region dragged around the grid was not laid out as a region at all. Back, minus
its own transparency, and centred on the pointer rather than up and to the left
of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
…nge opening the editor

Escape only worked while the focus was inside the grid. Switching to a tab on
the right - which is a normal thing to do mid-transfer, to go and look
something up - left it doing nothing until you clicked back near the grid, and
being stuck holding a set of buttons is exactly the state it is there to get
you out of. Something you press to be sure you are not about to do anything has
to work when you press it.

It now listens for the whole page, stepping aside for anything with a nearer
claim on the key: a text field, and an open dialog or menu, which is what
Escape closes while one is up. The tools stay armed through a change of focus
rather than being torn down by one - going to fetch a preset and coming back to
find the buttons put down would be its own trap, and the toolbar says what is
armed the whole time.

Also, tapping in the Arrange tool opened the button in the editor, since it
inherits the default tool's tap. A tap there is for picking out what to drag,
and being taken to the editor by a stray one is a long way to go - on a
single-column layout it replaces the grid being rearranged with the button that
was not meant to be tapped. It selects and stays put now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
The Arrange tool lets any button be dragged, which included the ones that are
not there: an empty cell picked up and followed the cursor, and dropping it did
nothing, because there was nothing to put down. The same held for an empty cell
that happened to be selected.

Nothing to pick up, so nothing to drag. The cell is no longer registered as a
draggable at all, so there is no gesture to start rather than one that goes
nowhere - and a drag begun on empty space still rubber-bands, as it did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
The rules that stop a transfer quietly destroying something - drop the gaps in
a region, refuse anything that would land off the grid, ask before replacing -
were decided inline in a 650-line component, so nothing tested them. Every one
of them was reported as a bug before it was written; none of them had a
regression test.

They move into `planGridTransferRequest`, which reports what a transfer would
do and leaves applying, refusing and asking to the caller. `transfer` and the
paste path both ask it now, rather than each working out the off-grid case for
itself.

Also covers the droppable id round-trip, which had none, and found that a
malformed id parsed as page 0 rather than being rejected: `Number('')` is 0 and
`Number.isInteger(0)` is true. Every droppable in the app passes through the
same drag monitor, so it is matched whole now instead of split and coerced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
The parts of this that decide what a gesture means were mostly covered but not
entirely, and the gaps were all the same shape: the states nothing reaches by
accident. Moving the focus with nothing focused, clearing an empty clipboard,
wrapping round the edge of the grid, a box drawn over nothing, a gesture a tool
has no use for, a second finger moving while the first is held, the end of a
swap that empties.

The context hooks had no tests at all, which mattered more than the number
suggested: every cell reads the store through them, and the reason they are one
hook per answer is so an unaffected cell skips the re-render. That is now
asserted rather than assumed.

Three things turned out to be untestable because they were unreachable, and are
gone rather than covered: two store getters nothing read, and the argument that
a tool holding no buttons might ask what a click would do to them. The toolbar's
"no such tool" fallback goes the same way - every id has an entry.

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

Brings the rest of the grid's own components under test: the selection
preview and the actions it shares with the bar above the grid, the page
menu's confirmations, and what the drag ghost draws for a region with
holes.

Includes the mutation continuations that were previously only asserted
not to throw - that a press is released again, and that a failure is
reported rather than escaping as an unhandled rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
Pulls the canvas geometry out of ButtonInfiniteGrid - which cell a point
is in, how far to scroll to reveal one, and which cells are worth drawing
- so the fiddly parts are testable on their own, and collapses the two
copies of the reveal maths into one function.

Covers the rest through the component: the rubber-band's threshold and
its modifiers, middle-button panning, what is reported under the cursor,
and which cells offer a drag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
claude and others added 7 commits August 23, 2026 15:03
The buttons page had grown to hold the tool actions, the keyboard, the
drag-drop monitor and the page-number resolution alongside its layout,
none of which could be tested without rendering the whole page - editor,
router and all.

Each is now its own hook or module, tested directly: what a transfer is
allowed to do quietly, what a paste anchors to and explains, every key
the grid answers to, and what a drag released over it does.

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

Covers what the panel wires up: the tools it hands a dragged box and a
hovered cell to, keeping the focused cell in view, page stepping, and
ctrl+wheel zoom.

Writing that turned up setSelection notifying before it moved the focus,
so the grid never scrolled to a selection it was given - after a
transfer, a drop, or Escape handing one back.

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

Covers which page the URL resolves to, where the grid goes at each width,
and which of the editor and the selection panel answers for what is
selected - with the grid, the editor and the other tabs stood in for.

Two things fell out of writing it. The store took every setViewPage as a
page change, so a re-render of the page around it dropped the selection
under the select tool; it now only passes on an actual change. And the
page could never resolve to no page at all, so the states written for
that are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
It only ever redrew what the grid beside it was already showing, and
carried a second copy of actions the toolbar already offers - so
selecting a few buttons swapped out the editor for something that
explained less than the grid did.

A multi-selection no longer touches the right panel at all: the editor
keeps showing whichever button you opened, and what is selected stays in
the bar above the grid. The slot is free for something that earns it -
editing style across the whole selection is the obvious candidate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
The editor edits one button, and the one it was opened on is not
necessarily even in the selection - so leaving it up beside a
highlighted block invites the idea that what you type there lands on all
of them.

The tab goes for the length of a multiple selection and comes back on
the same button afterwards, so passing through a selection costs you
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iVPGapnD6w1ii3Gor6kQg
@Julusian Julusian added this to the v5.1 milestone Aug 25, 2026
@github-project-automation github-project-automation Bot moved this to In Progress in Companion Plan Aug 25, 2026
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4b335b1f-3bce-4f05-b6e9-a3d42c5bd243

📥 Commits

Reviewing files that changed from the base of the PR and between d892593 and 3ade88b.

📒 Files selected for processing (15)
  • webui/src/Buttons/ButtonGridPageMenu.tsx
  • webui/src/Buttons/ButtonInfiniteGrid.tsx
  • webui/src/Buttons/GridButtonIcons.tsx
  • webui/src/Buttons/GridButtonPreview.tsx
  • webui/src/Buttons/GridPageNavigation.ts
  • webui/src/Buttons/GridTools/TransferTool.ts
  • webui/src/Buttons/__tests__/ButtonGridStore.test.ts
  • webui/src/Buttons/__tests__/ButtonInfiniteGrid.test.tsx
  • webui/src/Buttons/__tests__/GridButtonDroppableId.test.ts
  • webui/src/Buttons/__tests__/GridButtonIcons.test.tsx
  • webui/src/Buttons/__tests__/GridButtonPreview.test.tsx
  • webui/src/Buttons/index.tsx
  • webui/src/Buttons/useGridKeyboard.ts
  • webui/src/Components/ButtonPreview.css
  • webui/src/Helpers/SafeStorage.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • webui/src/Components/ButtonPreview.css

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

This PR replaces local button-grid interaction state with a store-driven editing flow. It adds multi-selection, transfer tools, drag previews, keyboard handling, page actions, and grid rendering hooks. It also removes the old hot-press store path.

Changes

Button grid editing flow

Layer / File(s) Summary
Grid state, tools, and transfer planning
webui/src/Buttons/ButtonGridStore.ts, webui/src/Buttons/GridTools/*, webui/src/Buttons/GridGeometry.ts, webui/src/Buttons/GridDragDrop.ts, webui/src/Buttons/GridTransferRequest.ts, webui/src/Buttons/GridCanvasGeometry.ts, webui/src/Buttons/GridPageNavigation.ts
Adds store-based selection, clipboard state, grid tools, transfer planning, geometry helpers, drag metadata, droppable parsing, and viewed-page persistence.
Grid rendering and pointer interactions
webui/src/Buttons/ButtonGridViewContext.tsx, webui/src/Buttons/GridButtonPreview.tsx, webui/src/Buttons/GridButtonIcons.tsx, webui/src/Buttons/ButtonInfiniteGrid.tsx, webui/src/Buttons/GridButtonDragOverlay.tsx, webui/src/Buttons/ButtonGridPanel.css, webui/src/Components/ButtonPreview.css
Adds context-backed rendering, marquee selection, hover handling, middle-button panning, location revealing, drag ghosts, and pending or invalid drop states.
Toolbar, page actions, and page wiring
webui/src/Buttons/ButtonGridToolbar.tsx, webui/src/Buttons/ButtonGridPageMenu.tsx, webui/src/Buttons/useGridToolActions.ts, webui/src/Buttons/useGridKeyboard.ts, webui/src/Buttons/useGridDropMonitor.ts, webui/src/Buttons/useButtonContextMenu.tsx, webui/src/Buttons/ButtonGridPanel.tsx, webui/src/Buttons/index.tsx
Adds toolbar tools, page actions, centralized mutations, keyboard shortcuts, drag monitoring, context-menu operations, and shared store wiring.
Validation and supporting updates
webui/src/Buttons/__tests__/*, webui/src/Buttons/ActionRecorder/ButtonPicker.tsx, webui/src/ImportExport/Import/Page.tsx, webui/src/Components/ContextMenu.css, webui/src/App.tsx, webui/src/ContextData.tsx, webui/src/Stores/RootAppStore.tsx, webui/src/tailwind.css, webui/src/Helpers/SafeStorage.ts
Adds coverage for grid state, gestures, transfers, page actions, drops, keyboard behavior, and context menus. Updates secondary grid consumers, shared styling, safe storage access, and removes old hot-press wiring.

Poem

A grid now gathers cells in a row,
Tools guide transfers where buttons should go,
Ghosts show shapes across the pane,
Keys move focus through the frame,
Pages remember where they were,
And old hot-press paths disappear.

Merge Risk: 🟡 Moderate · up to 3ade8

This PR reworks button-grid multiselect, keyboard navigation, and transfer/clear interactions, but the current behavior can silently hide rejected operations, scroll the browser unexpectedly, or clear selections during page navigation. These bounded correctness and usability risks should be fixed or explicitly accepted before merging.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: a rework of button grid interactions, including multi-selection, tool-based editing, and drag-and-drop workflows.
Linked Issues check ✅ Passed The changes satisfy the linked objectives [#2834] and [#1124]. The new grid store and tools support multi-button selection, copy, cut, paste, delete, rectangle selection, and group transfers across pa…
Out of Scope Changes check ✅ Passed The changes are within scope. The new grid state, interaction tools, drag-and-drop behavior, page actions, context-menu integration, keyboard handling, styling, and focused tests directly support the …
Full details: Linked Issues check

Explanation

The changes satisfy the linked objectives [#2834] and [#1124]. The new grid store and tools support multi-button selection, copy, cut, paste, delete, rectangle selection, and group transfers across pages. Drag-and-drop planning and transfer handling support copying or moving rows, columns, and button groups while preserving existing single-button workflows.

Full details: Out of Scope Changes check

Explanation

The changes are within scope. The new grid state, interaction tools, drag-and-drop behavior, page actions, context-menu integration, keyboard handling, styling, and focused tests directly support the button grid interaction rework and linked issue objectives.

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (10)
webui/src/Buttons/index.tsx (1)

72-72: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Two sources for the page count — probably worth picking one.

Line 72 uses pages.pageCount to resolve the viewed page, and line 176 passes pages.data.length to useGridKeyboard. If those two ever disagree, PageUp/PageDown can wrap to a page number that resolveViewedPage then corrects, which reads as a skipped page. Reusing the pageCount variable on line 176 keeps both in step.

♻️ Suggested tidy-up
-		pageCount: pages.data.length,
+		pageCount,

Also applies to: 176-176

webui/src/Buttons/ButtonGridToolbar.tsx (1)

195-197: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a compile-time exhaustiveness check for tool definitions

GridToolId currently matches all entries in NAVIGATION_TOOLS and TRANSFER_TOOLS. A future ID can still make find return undefined, causing activeTool.label to throw during render. A Record<GridToolId, ToolDefinition> object or satisfies check would enforce the keys; casting Object.fromEntries(...) as Record<...> would not.

webui/src/Buttons/useGridToolActions.ts (1)

62-74: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Optional: consider surfacing transfer failures to the user.

Thanks for pulling every path through one place — this reads really nicely. One small thought: apply() updates the selection, calls onGridChanged(), and calls onApplied() before the mutation settles, and a rejection only reaches console.error. If the server refuses a move, the user sees the selection jump to cells that never received anything, with no message. A toast or a confirmRef message on failure would close that gap. Same idea applies to clearButtons at Line 147.

Happy to leave this for a follow-up if you prefer to keep this PR focused.

webui/src/Buttons/useGridKeyboard.ts (1)

108-123: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Small suggestion: call e.preventDefault() for PageUp/PageDown.

The arrow keys and space already claim their keys, but these two do not. The grid panel is a scrolling container, so the browser can page-scroll it at the same time as the handler changes the page. Adding e.preventDefault() once the focus check passes would keep the two from fighting.

✨ Suggested tweak
 				case 'PageUp': {
 					const focus = store.focus
 					if (!focus) return
+					e.preventDefault()
 					const newPageNumber = focus.pageNumber >= pageCount ? 1 : focus.pageNumber + 1
 					setPageNumber(newPageNumber)
 					store.moveFocusToPage(newPageNumber)
 					return
 				}
 				case 'PageDown': {
 					const focus = store.focus
 					if (!focus) return
+					e.preventDefault()
 					const newPageNumber = focus.pageNumber <= 1 ? pageCount : focus.pageNumber - 1
 					setPageNumber(newPageNumber)
 					store.moveFocusToPage(newPageNumber)
 					return
 				}
webui/src/Buttons/__tests__/ButtonGridStore.test.ts (1)

19-31: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Thanks for the thorough coverage here — one small consolidation idea.

makeActions duplicates makeGridActions in webui/src/Buttons/__tests__/gridViewTestHelpers.tsx (lines 19-31), down to the same defaults and comment. If GridToolActions gains a member later, both stubs need the same edit. Reusing the shared helper would keep the stubbed contract in one place.

♻️ Optional: reuse the shared helper
-import type { GridToolActions } from '../GridTools/index.js'
+import type { GridToolActions } from '../GridTools/index.js'
+import { makeGridActions } from './gridViewTestHelpers.js'
@@
-function makeActions(): GridToolActions {
-	return {
-		openEditor: vi.fn(),
-		press: vi.fn(),
-		// The real one asks before replacing anything, and only reports back once it has happened
-		transfer: vi.fn((_operation, _pairs, onApplied: () => void) => onApplied()),
-		clearButtons: vi.fn(),
-		// Tests act on a grid where every cell holds a button unless they say otherwise
-		isOccupied: vi.fn(() => true),
-		pasteAt: vi.fn(),
-		fitsOnGrid: vi.fn(() => true),
-	}
-}
+const makeActions = (): GridToolActions => makeGridActions()

Note the helper file is .tsx, so importing it into a .ts test pulls in the React dependency. Splitting makeGridActions into a plain .ts module would avoid that if you would rather keep this test free of JSX imports.

webui/src/Buttons/GridPageNavigation.ts (1)

11-17: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a safe session-storage getter

SafeStorage.ts exports only setters. Add a guarded getter and use it in getLastViewedPage(). Direct access to window.sessionStorage can throw when storage is blocked and interrupt page resolution.

webui/src/Buttons/GridButtonIcons.tsx (2)

57-67: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider dropping image from ButtonGridIcon's props.

ButtonGridIcon resolves the image itself on line 60 and overwrites whatever the caller passed on line 66. The prop is still required, so callers must supply a value that is always discarded — the test on line 268 of GridButtonIcons.test.tsx passes image={null} purely for that reason.

Also, both memo wrappers use the inner name ButtonGridIcon, which makes the two hard to tell apart in React DevTools.

♻️ Suggested tidy-up
-type ButtonGridIconProps = ButtonGridIconBaseProps
+type ButtonGridIconProps = Omit<ButtonGridIconBaseProps, 'image'>
 
-export const ButtonGridIcon = memo(function ButtonGridIcon({ ...props }: ButtonGridIconProps) {
+export const ButtonGridIcon = memo(function ButtonGridIconWithImage({ ...props }: ButtonGridIconProps) {
 	const { image, isUsed } = useButtonImageForLocation({
-		pageNumber: Number(props.pageNumber),
+		pageNumber: props.pageNumber,
 		column: props.column,
 		row: props.row,
 	})

The memo(function ButtonGridIcon on line 32 could become ButtonGridIconBase too.


21-29: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Align the newly added optional parameters with the surrounding API convention. ButtonGridIcon leaves callback props optional while its sibling interface models absence explicitly, and the test helpers default overrides instead of requiring an explicit none case. Making these parameters required and passing undefined or null at call sites would keep the contracts consistent.

Source: Coding guidelines

webui/src/Components/ButtonPreview.css (1)

78-85: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two comments describe the same rule here.

The first block (lines 78-80) talks about blurring the dragged preset, but it now sits above .grid-drag-source; the preset rule moved down to line 87 with its own comment on 81-82. Removing the stale one would keep the file readable.

webui/src/Buttons/__tests__/GridButtonDroppableId.test.ts (1)

36-40: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

One more case worth pinning: negative rows and columns.

The parser's doc comment says rows and columns may be negative because a grid's bounds need not start at zero, and the regex allows -?. Nothing here locks that in, so a future tightening of the pattern would pass the suite and silently break offset grids.

🧪 Suggested extra test
 	it('refuses an id whose parts are not whole numbers', () => {
 		expect(parseGridButtonDroppableId('gridbtn:1:x:3')).toBeNull()
 		expect(parseGridButtonDroppableId('gridbtn:1:2:3.5')).toBeNull()
 		expect(parseGridButtonDroppableId('gridbtn::2:3')).toBeNull()
 	})
+
+	it('accepts cells left of and above the origin, since a grid need not start at 0/0', () => {
+		expect(parseGridButtonDroppableId(makeGridButtonDroppableId(1, -3, -2))).toEqual({
+			pageNumber: 1,
+			column: -3,
+			row: -2,
+		})
+	})

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7c7be3dc-69bc-45ce-abb3-e1a1712e6dc0

📥 Commits

Reviewing files that changed from the base of the PR and between d821f63 and d892593.

📒 Files selected for processing (61)
  • webui/src/App.tsx
  • webui/src/Buttons/ActionRecorder/ButtonPicker.tsx
  • webui/src/Buttons/ButtonGridActions.tsx
  • webui/src/Buttons/ButtonGridPageMenu.tsx
  • webui/src/Buttons/ButtonGridPanel.css
  • webui/src/Buttons/ButtonGridPanel.tsx
  • webui/src/Buttons/ButtonGridStore.ts
  • webui/src/Buttons/ButtonGridToolbar.tsx
  • webui/src/Buttons/ButtonGridViewContext.tsx
  • webui/src/Buttons/ButtonInfiniteGrid.tsx
  • webui/src/Buttons/GridButtonDragItem.ts
  • webui/src/Buttons/GridButtonDragOverlay.tsx
  • webui/src/Buttons/GridButtonDroppableId.ts
  • webui/src/Buttons/GridButtonIcons.tsx
  • webui/src/Buttons/GridButtonPreview.tsx
  • webui/src/Buttons/GridCanvasGeometry.ts
  • webui/src/Buttons/GridDragDrop.ts
  • webui/src/Buttons/GridGeometry.ts
  • webui/src/Buttons/GridPageNavigation.ts
  • webui/src/Buttons/GridTools/ArrangeTool.ts
  • webui/src/Buttons/GridTools/DeleteTool.ts
  • webui/src/Buttons/GridTools/MultiSelectTool.ts
  • webui/src/Buttons/GridTools/PressTool.ts
  • webui/src/Buttons/GridTools/SelectTool.ts
  • webui/src/Buttons/GridTools/TransferTool.ts
  • webui/src/Buttons/GridTools/index.ts
  • webui/src/Buttons/GridTools/types.ts
  • webui/src/Buttons/GridTransferRequest.ts
  • webui/src/Buttons/__tests__/ButtonGridPageMenu.test.tsx
  • webui/src/Buttons/__tests__/ButtonGridPanel.test.tsx
  • webui/src/Buttons/__tests__/ButtonGridStore.test.ts
  • webui/src/Buttons/__tests__/ButtonGridToolbar.test.tsx
  • webui/src/Buttons/__tests__/ButtonGridViewContext.test.tsx
  • webui/src/Buttons/__tests__/ButtonInfiniteGrid.test.tsx
  • webui/src/Buttons/__tests__/ButtonsPage.test.tsx
  • webui/src/Buttons/__tests__/GridButtonDragOverlay.test.tsx
  • webui/src/Buttons/__tests__/GridButtonDroppableId.test.ts
  • webui/src/Buttons/__tests__/GridButtonIcons.test.tsx
  • webui/src/Buttons/__tests__/GridButtonPreview.test.tsx
  • webui/src/Buttons/__tests__/GridCanvasGeometry.test.ts
  • webui/src/Buttons/__tests__/GridDragDrop.test.ts
  • webui/src/Buttons/__tests__/GridPageNavigation.test.ts
  • webui/src/Buttons/__tests__/GridTransferRequest.test.ts
  • webui/src/Buttons/__tests__/gridViewTestHelpers.tsx
  • webui/src/Buttons/__tests__/useButtonContextMenu.test.tsx
  • webui/src/Buttons/__tests__/useGridDropMonitor.test.tsx
  • webui/src/Buttons/__tests__/useGridKeyboard.test.tsx
  • webui/src/Buttons/__tests__/useGridToolActions.test.tsx
  • webui/src/Buttons/index.tsx
  • webui/src/Buttons/useButtonContextMenu.tsx
  • webui/src/Buttons/useGridDropMonitor.ts
  • webui/src/Buttons/useGridKeyboard.ts
  • webui/src/Buttons/useGridToolActions.ts
  • webui/src/Components/ButtonPreview.css
  • webui/src/Components/ButtonPreview.tsx
  • webui/src/Components/ContextMenu.css
  • webui/src/ContextData.tsx
  • webui/src/ImportExport/Import/Page.tsx
  • webui/src/Stores/RootAppStore.tsx
  • webui/src/Stores/ViewControlStore.tsx
  • webui/src/tailwind.css
💤 Files with no reviewable changes (4)
  • webui/src/Stores/ViewControlStore.tsx
  • webui/src/ContextData.tsx
  • webui/src/Buttons/ButtonGridActions.tsx
  • webui/src/Stores/RootAppStore.tsx

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Comment thread webui/src/Buttons/ButtonGridPageMenu.tsx
Comment thread webui/src/Buttons/ButtonGridStore.ts
Comment thread webui/src/Buttons/ButtonInfiniteGrid.tsx
Comment thread webui/src/Buttons/ButtonInfiniteGrid.tsx
Comment thread webui/src/Buttons/GridButtonPreview.tsx
Comment thread webui/src/Buttons/GridTools/TransferTool.ts Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Multi-button select for copy/paste/delete Copy Row/Column/Group of buttons

2 participants