Skip to content

feat: add accessibility and keyboard editing to the react BPMN editor - #46

Merged
kumilingus merged 60 commits into
mainfrom
feat/bpmn-editor-react-accessibility
Sep 2, 2026
Merged

feat: add accessibility and keyboard editing to the react BPMN editor#46
kumilingus merged 60 commits into
mainfrom
feat/bpmn-editor-react-accessibility

Conversation

@samuelgja

@samuelgja samuelgja commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Brings the react BPMN editor to the same accessibility level as the data modeling and AI workflow builder demos, with the accessibilitychecker.org badge in the bottom-left corner, and makes the diagram itself editable from the keyboard. Along the way it repaints the diagram palette, lets the inspector edit a whole selection at once, and reorganises dnd/, actions/ and utils/ along what each of them is for.

Accessibility

  • Accessible names for every icon-only control and aria-pressed on toggles (theme, minimap, fullscreen).
  • Landmarks (header/main/aside) with a visually hidden h1; inspector heading order fixed.
  • Keyboard stencil support: the arrows aim at a pool or a lane, Enter/Space drops the shape there and selects it. Pools/lanes go through the same semantics as a pointer drop, including undo — the placement is batched, so a pool no longer takes seven undo presses to remove and a lane six (the pointer drop was already atomic, because the stencil batches it).
  • Keyboard-focusable canvas (role=application, visible focus ring); outlined keyboard highlight for menu/select items.
  • Muted text tokens now clear WCAG AA 4.5:1 in both themes.
  • Decorative surfaces hidden from AT; tooltips/file menu portaled inside their landmarks, file menu non-modal.
  • New "Check accessibility" pill linking the page to the accessibilitychecker.org audit.

Keyboard editing

  • The arrows navigate: a directional Tab, stepping to the nearest shape in the direction pressed, and scrolling to it only when it is not fully in view. With nothing selected they pan the paper as before. Movement moved to Shift and an arrow — one grid step, swimlanes excluded, boundary events sliding along their activity's border, and pools growing to keep containing what moved (the pool-grow logic is shared between the pointer-drag and keyboard paths). Plain arrows meaning "move" cost a keyboard user any way to get around the diagram, which Tab alone does in document order rather than in the direction they are looking.
  • Navigation falls back when the direction is empty: a 90° cone first, then 180° from the side, so an arrow finds the shape beside it rather than nothing. Links are part of the walk — with one selected the arrows step on rather than panning the paper away from it.
  • Alt and an arrow resize anything the free transform can — a pool, a lane, a group, a comment — growing from the far border and shrinking back, so the plain arrows are left to mean one thing. A lane resizes both ways: across the pool the size is its own, along the pool it is the pool's, since every lane spans it. Both go through changeSwimlaneSize(), the route the free transform takes for the same drag. A resized shape can outgrow its lane exactly as a moved one can leave it, so the pool grows either way.
  • A group is never embedded, whichever way it is added. It is a BPMN artifact — nothing contains it, and it is meant to span pools and lanes — so Group.validateEmbedding() refuses, and the keyboard drop now asks rather than carrying its own copy of the placement. (An annotation does belong to a lane, and still does.)
  • Shrinking a lane stops at its content rather than cutting through it. The constraint the free transform applies to the same border drag lives in its internal swimlaneMinSize(), which is not part of the widget's typed API, so this mirrors it from the public pool and lane methods it is built on (getMinimumLaneSize, getSwimlanePadding, getElementsBBox, getContentMargin). Keyboard and pointer stop at the same size — 352px for a lane with content, 62px for an empty one. At the limit the arrow is consumed instead of falling through to the paper scroller.
  • Enter/F2 opens the inline label editor over the single selected cell (elements, links, pools and lanes), the keyboard equivalent of a double click. Focus returns to the canvas when the editor closes, so renaming and moving chain together.
  • Escape now cancels the edit. The text was written to the cell on blur regardless of how the editor closed, and Escape closed it by blurring — so it saved the edit it was meant to discard. The closing key no longer reaches the document-level shortcuts either, where Enter would have reopened the editor it just closed and Escape would have cleared the selection.
  • The shortcuts that act on the diagram (rename, move, delete) now require a focused canvas. They are bound on document, so they also fired while the stencil or the toolbar had the focus, where Enter drops a shape and the arrow keys move the roving focus.
  • Cmd/Ctrl+Enter adds a lane beside the selection — appended to a selected pool, or inserted directly after a selected lane, a position a stencil drop cannot express. The target is explicit instead of whichever pool graph.getElements().find(isPool) returns first, and the new lane is selected so it can be named straight away (cmd+enter, enter, type, enter).
  • Cmd/Ctrl+C, X and V drive the built-in clipboard (<Diagram clipboard>), bound by the app so the paste can finish the job and the keys get the same focused-canvas guard. A copy keeps its place: util.cloneCells() only remaps the embedding of parents that were copied too, so a task copied out of a swimlane pasted loose — the copy now records the parent it came from and the paste puts the clone back into it, growing the pool to fit.
  • Copying a container takes its contents. A pool clones whole (its lanes and shapes are all in the copied set); a lane is inserted with pool.addSwimlane() rather than embedded, so the pool lays itself out again and the lane's shapes travel with it, landing below the lane it was copied from. Cutting a lane copies it instead of removing it, since a pool must keep a lane.
  • A keyboard drop had no target, so it added the shape at the middle of the view with addCell() and left it outside every pool — a state validateUnembedding refuses for a drag, and one that exports with the task outside any participant. The palette now holds an explicit target that the arrows step, with the view following via scrollToElement(): up/down walk every lane in the diagram as one run (carrying into the next pool at the end of one), left/right skip a pool, and both cycle. For a lane the target is the gap between lanes, so it can be inserted anywhere in the stack rather than only appended.
  • The target is shown while a palette button has the focus, reusing the effects a drag already shows — the lane highlight for a shape, the insertion line for a lane — and named through aria-describedby ("Adds to Customer, position 1 of 3"), since a highlight is no use to a screen reader. The aim is seeded from the selection and then from what is on screen: the canvas selection survives focus moving to the palette, so it is the one signal of where the user was working.
  • Selecting a cell already moved the focus to it, but a cell selected as soon as it was added — from the stencil, a paste, cmd+enter — had no view yet, since the paper renders asynchronously, so the focus was silently dropped. requireView() renders it on demand, and the add → rename → move chain now works without tabbing back to the canvas.
  • Cmd/Ctrl+Enter adds what fits beside the selection — a connected shape from a shape, a shape inside a lane, a lane inside a pool — and Cmd/Ctrl+Arrow does the same with the side chosen. Shift+Cmd/Ctrl+Enter links the selection to a shape or pool that already exists, or adds a lane when a lane is selected (a lane cannot be linked: its validateConnection() refuses).
  • What is offered comes from the model rather than a second list. The connect targets are the shape's own halo handles, so an end event offers nothing to flow into; the link targets are whatever validateConnection() accepts, pools included, since a message flow runs between participants. Link types are resolved from the endpoints by prepareLinkReplacement().
  • The shape list is portalled out of the paper and positioned in screen coordinates — inside it, it rode along with the canvas, including the scroll it triggers itself while previewing a target. It anchors to the shape it was opened from, except for a lane, whose rect spans the whole pool: that anchors to the point where the shape will land.
  • Alt+Enter moves the focus to the inspector for the selected element or link, and Escape brings it back to the shape — the panel was reachable only by tabbing out of the canvas and through the toolbar.
  • A toolbar button (or F1) opens a dialog listing every shortcut, grouped, with the modifier rendered as the platform expects — the bindings were otherwise undiscoverable. Focus returns to the button on close.

Editing a selection at once

  • The inspector edited exactly one cell: selecting three tasks showed the empty state, and recolouring five shapes meant clicking each one. It now shows what the selection has in common — a shared value, or nothing selected where they differ, written as -- in a select.
  • The fields work across shape types, which store the same colour in different places: fill is attrs/background/fill on a task but attrs/body/fill on a gateway, and a pool wears its colour on its header. Each colour field declares the role it paints (fill, outline, text, and the font roles), so a new shape says where its colour lives and nothing central needs editing.
  • Connectors are their own section, since a link has a line and a label rather than a body — a mixed selection reads as "2 shapes / 3 connectors". Cmd+A is staged to match: elements first, then the links too when the elements are already selected.
  • One swatch click recolours the whole selection in a single undo step, batched — an unbatched loop over five shapes would take five undo presses.

Appearance

  • The diagram palette is repainted and its tokens are named for what they do (tint-a, ink, start, decision) rather than for a hue, so a repaint moves the colours without the names going stale. ink clears WCAG AA 4.5:1 on the surface and on either tint, and 3:1 on the paper.
  • A shape's icon stays readable against its own body. The icon is an <image> with the colour baked into a data URI, so it cannot follow the theme the way a fill can — the library's #333333 disappeared on a dark body. The ink is now chosen per shape from the body's luminance, at birth and on every recolour, theme switch and diagram load, and kept off the undo stack with skipHistory since it is a consequence of an edit rather than one itself.
  • The lane an element is about to be dropped into is visible again: the highlight was mixed from a token that sat 1.02:1 against a pale lane. Dashed links follow the theme, borders are drawn in a colour the inspector's picker actually offers (an unset-looking swatch otherwise), and a new lane is created big enough to hold a task with its margin.

Selection

  • A selected connector is highlighted. The selection frames mask a shape's body and a link has none, so the class the frame list puts on each selected cell carries it: the invisible 10px wrapper path becomes a halo along the line, leaving the line its own colour.
  • Links and elements can no longer be held in one selection — the order decided whether it worked at all, and no tool acts on both.
  • A cherry-picked selection may span pools: scoping the selection to one pool is right for a rubber-band region, not for cmd-clicking two shapes deliberately. The translate is withheld for such a selection instead, which is the part that misbehaved.

Fixes

  • The label editor edits plain text. It was a rich-text contenteditable, so pasting from a document carried the formatting into a BPMN label.

  • A click on a palette item is separated from a drag, so clicking one no longer starts aiming, and the item keeps a visible focus ring while it does.

  • The canvas focus ring is drawn in full: it was clipped on the right and bottom by the scrolling container.

  • The stencil's drag paper is shown in the top layer. It declares popover="manual" but is never opened, so it stacked under the stencil container (clientIO/joint-plus#803); the app shows it and the workaround is marked for removal.

  • A new neighbour placed beside a shape is blocked only by what shares its lane: another lane's shapes slid it past empty space, and a pool it would overlap occupies that ground whatever we do.

  • A group is never embedded, and no longer wears the connector halo — it has a wrapper node of its own, so the rule is scoped to links.

  • The shortcuts dialog's list takes the focus itself. It scrolls when the window is short, and a scrollable region no keyboard can reach holds content a keyboard user cannot read — the one violation the re-run of the audit turned up after the repaint.

Three issues came out of this work: #801 (<PaperScroller> drops HTML attributes), #803 (the drag paper is never shown as a popover) and #804 (a threshold-aware companion to startCellDrag).

Verified: axe-core 0 violations across UI states (default, selection, both inspector views, the bulk form, both themes, export dialog, open menu/tooltip, and the shortcuts dialog), Lighthouse accessibility 100, keyboard walkthrough, tsc + eslint + vite build clean. The keyboard work is covered by a 72-check Playwright walkthrough: rename open/save/cancel for elements, links and lanes; focus return; the stencil and toolbar focus guards; blur-still-saves; resizing both lane orientations with the pool reflow, the content limit (matched against the pointer drag) and undo; lane insert position, orientation and no-ops; single-step undo/redo for every stencil drop; copy/cut/paste including the preserved embedding (checked by dragging the pool and watching the copy follow), lane and pool copies with their contents, and the clipboard's behaviour on an empty selection; the stencil aim (stepping across pools, cycling, insertion position, the highlight matching where the shape lands, and focus following a new cell); the dialog's open/close paths; and the keyboard creation — the halo-sourced offers, link validity (an end event offering only cross-pool targets), pool-to-pool links, all four directions, the pool growing rather than clamping a neighbour, the list surviving a canvas scroll, and its dismissal on a stencil drag or click.

The appearance and selection work is covered by its own suites: the shared/differing/-- states across shape types and both sections (18 checks), fonts and sizes, the connector highlight and the masks that stay on shapes only (12), the inspector shortcut and focus return (9), directional navigation including the 180° fallback and links (20), the rebound arrows (5), icon contrast in both themes and after a diagram load (9), the first pool wrapping the content and a second one leaving it alone (7), the pointer drags for pools and lanes with their previews and invalid marks (11), the link picker's targets, ordering and validity (8), file import by drop for both formats (6), lane sizing (4), cross-pool drag (4) and neighbour placement (3).

Internals worth a look: createShape()/getShapeMeta() replace four copies of graph.getTypeConstructor(type)! and two casts for the palette metadata; getLabelText() moves onto the pool and lane classes (a lane falls back to its pool's name, since lanes are commonly unnamed); and isSwimlane() now narrows to the app's own classes as isPool() already did, via BpmnPool/BpmnSwimlane (the App* prefix said nothing about this editor).

The last few commits sort the modules by what they hold rather than what they grew from. dnd/ is the drag callbacks: each reads as a few named steps, with the SVG preview in effects/ beside the ghost and lane previews it matches, the pool geometry in utils/ and the placement in actions/dnd/pools.ts went from 334 lines to 53. actions/ holds commands the user invokes, so the graph-loading half of the file import moved in and the drag-listener wiring moved out into the effect that runs it. utils/ holds the queries: the pool and lane lookups the keyboard aiming needs, and the link targets a source accepts. Names that misdescribed their function are fixed in passing — checkSwimlaneDrop performs the drop, positionInSwimlane returns a point.

Bring the react BPMN editor to the same accessibility level as the data
modeling and AI workflow builder demos, with the accessibilitychecker.org
badge in the bottom-left corner.

- accessible names for every icon-only control (toolbar, file dropdown,
  navigator) and aria-pressed on the theme/minimap/fullscreen toggles
- landmarks (header/main/aside) with a visually hidden h1; heading order
  fixed in the inspector (h3 -> h2)
- keyboard support for the stencil: Enter/Space places the shape at the
  center of the visible paper and selects it; pools and lanes go through
  the same semantics as a pointer drop (first pool wraps the content,
  lanes insert into an existing pool)
- the pan/scroll canvas is keyboard-focusable (role=application with a
  visible focus ring); menu/select keyboard highlight outlined (1.4.11)
- muted text tokens now clear WCAG AA 4.5:1 in both themes
- decorative surfaces hidden from AT (minimap copy, import overlay,
  icon-font glyphs, zoom readout); tooltips and the file menu portaled
  inside their landmarks; the file menu made non-modal
- "Check accessibility" pill linking the running page to the
  accessibilitychecker.org audit

Verified: axe-core clean across the UI states (default, selection, both
inspector tabs, both themes, export dialog, open menu/tooltip), Lighthouse
accessibility 100, keyboard walkthrough, tsc and vite build. The open
Radix Select keeps its inherent modal listbox behavior.
kumilingus and others added 2 commits August 29, 2026 14:46
Arrow keys translate the selected elements by one grid step. Swimlanes
are excluded, boundary events stay snapped to their activity's border
and pools grow to keep containing the moved elements (the pool-grow
logic is now shared between the pointer-drag and keyboard paths).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enter or F2 opens the inline label editor over the single selected cell,
the keyboard equivalent of double-clicking it. Escape now cancels the
edit — the text was written on blur regardless, so it used to save what
Escape was meant to discard — and the closing key no longer reaches the
document-level shortcuts, where it would have reopened the editor
(Enter) or cleared the selection (Escape).

The shortcuts acting on the diagram (rename, move, delete) are limited
to a focused canvas. They are bound on `document`, so they also fired
while the stencil or the toolbar had the focus, where Enter drops a
shape and the arrow keys move the roving focus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kumilingus kumilingus changed the title feat: add accessibility support to the react BPMN editor feat: add accessibility and keyboard editing to the react BPMN editor Aug 29, 2026
kumilingus and others added 25 commits August 29, 2026 15:40
A lane cannot be moved, so the arrow keys were inert while one was
selected. They now resize it by one grid step through the pool's
`changeSwimlaneSize()`, the same route the free transform takes when
its border is dragged, so the pool lays out the remaining lanes and
grows with them.

A lane always spans its pool across the other axis, so only the arrows
running across the lane change its size: down/up for a horizontal lane,
right/left for a vertical one. The rest fall through to scrolling the
canvas. Shrinking stops at the pool's minimum lane size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shrinking a lane with the arrow keys clamped at the pool's minimum lane
size only, so it cut straight through the elements inside it — a lane
went from 372px to 62px over its own tasks.

`ui.BPMNFreeTransform` constrains the equivalent border drag with
`swimlaneMinSize()`, which is internal to the widget and absent from the
typings, so there is no public method to call. This mirrors it for the
two borders the arrows move, from the public pool and lane methods it is
built on (`getMinimumLaneSize`, `getSwimlanePadding`, `getElementsBBox`,
`getContentMargin`). Keyboard and pointer now stop at the same size: a
lane with content at 352px, an empty lane at 62px.

At the minimum the arrow is consumed rather than falling through to the
paper scroller, so reaching the limit no longer scrolls the canvas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`enter` edits what is selected, `cmd+enter` (or `ctrl+enter`) adds a lane
beside it: a selected pool gets one appended, a selected lane gets a
sibling directly after it. The insert position is something a stencil
drop cannot express, and the target is explicit rather than the first
pool `graph.getElements().find(isPool)` happens to return.

The new lane is selected afterwards, so it can be named without reaching
for the pointer: cmd+enter, enter, type, enter. The orientation comes
from the pool, so an incompatible lane cannot be created this way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bindings were undiscoverable — nothing in the UI mentioned enter/F2,
cmd+enter or the arrow keys. A keyboard button in the toolbar opens a
dialog listing all of them, grouped, with the modifier rendered as the
platform expects.

Focus returns to the toolbar button when the dialog closes. The dialog
is rendered on demand rather than from a `Dialog.Trigger` (matching the
export dialog), so Radix has no trigger to restore the focus to and it
would otherwise fall to the body.

The dialog keyframes move to the shared stylesheet, so the new dialog
does not depend on the export dialog's CSS being in the bundle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Placing a pool from the palette took seven undo presses to remove and a
lane took six: the drop touches several cells — the pool, its mandatory
first lane, the content it wraps, the lanes the pool lays out again —
and none of it was batched.

The pointer drop was already atomic because the stencil wraps it in a
batch, so only the keyboard path was out of step. One batch around the
placement brings it in line: pool, lane and plain elements now undo and
redo in one press each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Turns on the built-in clipboard (`<Diagram clipboard>`) and binds
cmd/ctrl+C, X and V to it. The built-in shortcuts stay off, as the rest
of the app's are — the paste needs work doing after it, and the keys
need the same focused-canvas guard as the other diagram shortcuts.

A copy keeps its place in the diagram. `util.cloneCells()` only remaps
the embedding of parents that were copied too, so a task copied out of a
swimlane would paste loose on the canvas; the copy now records the
parent it came from and the paste puts the clone back into it, growing
the pool to fit.

Copying a container takes its contents with it. A pool clones whole,
since its lanes and their shapes are all in the copied set. A lane is
inserted with `pool.addSwimlane()` rather than embedded, so the pool
lays itself out again and the lane's shapes travel with it — it lands
below the lane it was copied from. A lane cannot be cut, though: a pool
must keep a lane (see `onDelete`), so cutting one copies it instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four places turned a type string into a model with the same
`graph.getTypeConstructor(type)!` dance — the halo's `makeElement`, both
stencil drop paths and the inspector's shape morphing — and two read
`label`/`icon` off the constructor through a cast.

`createShape()` takes only what creating a shape needs (the graph, the
type, optional attributes, which is what morphing uses to keep the id),
and `getShapeMeta()` returns the palette metadata. The non-null
assertions are gone: an unregistered type now throws a named error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Selecting a cell moves the focus to it, but a cell selected the moment
it is added — from the stencil, a paste, `cmd+enter` — had no view yet,
because the paper renders asynchronously. `findView()` returned nothing
and the focus was silently dropped, leaving it on the stencil button.

`requireView()` renders the view on demand instead, so the focus lands
on the new shape and the canvas is usable straight away: add, `enter` to
name it, type, `enter`, then the arrows to place it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`getLabelText()` on the pool and lane classes returns the header text,
with a lane falling back to its pool — lanes are commonly left unnamed
while the pool carries the participant name. That knowledge belongs on
the lane rather than in whichever component happens to need a name.

Calling it needs the guards to narrow to the app's own classes, which
`isPool()` already did: `isSwimlane()` now does the same, and the
`AppPool`/`AppSwimlane` aliases carry the unions that were about to be
spelled out in five places. Every cast at the call sites goes away.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pointer drop lands on something; the keyboard drop landed at the
middle of the view with no target at all, so it called `addCell()` and
left the shape loose on the canvas even where pools existed — a state
`validateUnembedding` refuses for a drag, which exported with the task
outside every participant.

The palette now holds an explicit target and the arrows step it, with
the view following through `scrollToElement()`. Up and down walk every
lane in the diagram as one run, carrying on into the next pool at the
end of one; left and right skip a whole pool. Both cycle. For a lane the
target is the gap between lanes, so it can be inserted anywhere in the
stack rather than only appended.

The target is shown while a palette button has the focus, using the
effects a drag already uses — the lane highlight for a shape, the
insertion line for a lane — and named through `aria-describedby`, since
the highlight is no use to a screen reader. The aim is seeded from the
selection, then what is on screen: the canvas selection survives the
focus moving to the palette, so it is the one thing that says where the
user was working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`cmd+enter` offers what can be added next to the selection and adds it: a
shape connected to a shape, a shape inside a lane, a lane inside a pool.
`cmd+arrow` does the same with the side chosen, so a branch off a gateway
can go down rather than right. `shift+cmd+enter` links the selection to a
shape or pool that already exists, or — from a lane, which cannot be
linked — adds a lane after it.

What each offers comes from the model, not a second list: the connect
targets are the shape's own halo handles, so an end event offers nothing
to flow into and the keyboard cannot build what the halo would not; the
link targets are whatever `validateConnection()` accepts, which includes
pools, since a message flow runs between participants. Link types are
left to `prepareLinkReplacement()` to resolve from the endpoints.

The list is portalled out of the paper and placed in screen coordinates.
Inside it, it would ride along with the canvas — including the scroll it
triggers itself while previewing a target. It anchors to the shape it was
opened from, except for a lane, whose rect spans the whole pool: that
anchors to the point where the shape will land, which is also the point
the drop then uses.

A neighbour is placed clear of what is already there and is not clamped
back inside its lane — the pool grows to take it instead, rather than the
new shape landing on top of the one it was added from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`F1` is the platform's help key and the shortcut list is the help, so it
opens from anywhere rather than only from the toolbar button.

The list also picks up the keys added since it was written — adding,
linking, aiming the stencil — and F1 itself, under a Help heading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Everything the free transform can resize now answers to `shift` and an
arrow — a pool, a lane, a group, a comment — growing from the far border
and shrinking back. The plain arrows are left to mean one thing, moving,
rather than resizing a lane and moving everything else.

A lane grows both ways. Across the pool the size is the lane's own, along
the pool it is the pool's, since every lane spans it; both go through
`changeSwimlaneSize()`. The minimum answers for either border now: the
lane's content across the pool, and the pool's minimal range along it,
which is what the free transform uses for the same drag.

A resized shape can outgrow the lane it sits in exactly as a moved one
can leave it, so the resize grows the pool too — it did not, and a
comment stretched past its lane hung outside the pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A group is a BPMN artifact: nothing contains it, and it is meant to be
able to span pools and lanes, which is why `Group.validateEmbedding()`
returns false and a pointer drag never puts one in a lane.

The keyboard drop did, because it carried its own copy of the placement
and never asked the shape — a group added that way travelled with the
pool, where a dragged one stays put. The placement lives in one helper
now, which asks `validateEmbedding()`, embeds only when it is allowed,
and leaves an unembedded shape unclamped so it can extend past a lane.
The stencil no longer highlights a lane for a group either: the
highlight says where a shape will go, and a group does not go there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`shift` and an arrow move a shape's right or bottom border, which leaves
its top and left unreachable: a lane could not grow upwards, and a pool
could not extend to the left. Adding `alt` moves the near border instead,
so the outward arrow grows it — `alt+shift+up` extends the top edge
upwards while the bottom stays put.

The lane minimum answers for all four borders now, mirroring
`swimlaneMinSize()` in each: the content on the axis the lane owns, the
pool's minimal range on the axis it shares.

`alt` was picked over `ctrl` and `cmd`: macOS takes ctrl+arrow for
Spaces before the page sees it, and cmd+arrow already adds a neighbour.
Worth a check in Safari and Firefox, where alt+arrow is history
navigation — the plain combination, not this one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aiming at a place in the diagram — holding a target, stepping it with the
arrows, previewing it on the canvas, naming it for assistive tech — was
written inside the stencil palette, so only the palette could use it. The
canvas commands each settled for a cruder rule instead: `shift+cmd+enter`
inserts a lane after the selected one, and `cmd+enter` appends to the end,
neither of them aimed or previewed.

It is `useTargetAim()` now, with a command-shaped surface — `begin`,
`end`, `step`, `target`, `name` — leaving the palette to wire it to the
buttons. Nothing changes for the user; the aiming is simply reachable
from somewhere other than the stencil.

The move also brought one rule back into the open: a group has nowhere to
aim, since it is never embedded. That was buried in the preview effect
and is now `aimsAtSomething()`, asked where the question belongs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shape lists read a shape's icon off its class (`static icon`, a CSS
class that `getShapeMeta()` hands out), but the pool, group and annotation
classes never declared one — the stencil bypasses that by putting raw
glyphs in its own config. So a pool offered as a link target rendered an
empty box where every other row had an icon.

Declare the icon the way the other shapes do, reusing the glyphs the
stencil already shows: the two pools and the horizontal lane get classes
of their own, while the group and annotation classes were already in
bpmn-icons.css and only lacked the static.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An annotation or a group is an artifact and a pool is the participant the
shape already sits in, so all three are the rarer thing to connect to.
Rank them after the flow shapes, which keep reading order among
themselves, so the list leads with what is usually wanted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bare arrows moved the selection, which put the one key with no
modifier on the one action that changes the drawing — a stray press
nudged a shape and wrote a history entry. They navigate now, and moving
takes `shift`, resizing `alt`. `tab` already walked the cells, but in
document order, which is the order they were added: no way to follow a
flow across pools.

Which shape an arrow lands on is geometry rather than the sequence flows,
since a flow that bends back reads worse than what the user can see in
that direction. A 90° cone comes first, so a row of tasks reads as a row;
where it finds nothing, every shape wholly past the origin's edge on that
side is considered, so a start event tucked below and to the left is
still reachable with `left`. A shape in the same pool wins over a nearer
one in another, and a shape square in the direction pressed over one
sitting diagonally.

Pools and lanes are not candidates — a pool's box spans everything inside
it, so it would win nearly every direction — and stay on `tab`.

With something selected the arrows belong to the diagram whether or not
there is anywhere to go, so one at the edge does nothing rather than
sliding the canvas out from under the selection. With nothing selected
they are the canvas's, and scroll it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reaching it otherwise meant tabbing past every cell in the diagram, since
each one is a tab stop. `alt+enter` is the platform's shortcut for the
properties of whatever is selected, which is what the panel is.

The focus lands on the panel rather than its first control: focusing the
region is what makes assistive technology say where the focus went, and
`tab` carries on into the controls from there. `escape` comes back to the
shape, and stops at the panel — the canvas reads that key as "select
nothing", which would leave the panel empty and the focus on an
unselected shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The selection was filtered to elements before the arrows looked at it, so
a selected flow read as an empty selection and fell through to the branch
that leaves the key to the canvas: with a link selected the arrows
scrolled the paper instead of moving to a shape.

A link is never embedded, so it also has to name its participant through
its source, or the same-pool ranking would rate every shape in its own
pool a tier worse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A BPMN shape draws its type icon as an `<image>`, and the colour is baked
into the SVG data URI, so it cannot follow the theme the way the body
does: the body fill is `var(--bpmn-palette-surface)`, an attribute the
browser resolves, while the icon is a picture of a colour chosen when the
shape was made. The library's `#333333` on the dark theme's `#24262A`
body is invisible.

There was a filter approximating the ink colour in the dark theme, with
gateways exempted because the sample diagram paints them amber. That
exemption was the bug: a gateway drawn on the default body kept a dark
icon on a dark canvas. A filter cannot do better — the icon lives in a
document of its own, and no stylesheet can see what the body is painted
with.

So the colour is chosen per shape from its own body, through the
`iconColor` attribute the icon sets already support, and the filter is
gone. The amber gateways keep their dark icon in both themes; a gateway
on the default body gets a light one.

Shapes are painted at birth as well as on the graph, because the stencil
clones one into a paper of its own to fly under the pointer, and that
clone is never in this graph. The recolouring is vetoed through
`cmdBeforeAdd`: following the theme is not an edit and does not belong on
the undo stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`DataAssociation` and `AnnotationLink` inherited the library's `#333333`
line, which all but disappears on the dark canvas. The sequence flows
already take the theme's outline colour; a data association and a comment
link are the same line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No behaviour change: the size moves out of the shape and into the config
beside it, so the lane defaults can be derived from it rather than
repeating the number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A new lane took the library's default of 60, while a task is 80 with a
content margin of 20 to clear on each side. Dropping one shape into a
fresh lane overflowed it and made the pool grow immediately — a starting
size that cannot hold a single shape is no starting size.

Both axes are set now, and from the task rather than a number of their
own: the horizontal lane only declared a width and the vertical only a
height, so the dimension that mattered was the one falling through to the
library. The other is a seed the pool overwrites with its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kumilingus and others added 29 commits September 1, 2026 12:40
`blue`, `green`, `success`, `danger` and `warning` named the colours they
happened to hold, which made every repaint start from a blue and a green:
the names steer the next choice, and go stale the moment the hue moves.
They are `tint-a`/`tint-b` for the two participants and `start`/`end`/
`decision` for the shapes that carry a meaning. No colour changes here —
the values are the ones already in place.

The accessibility check's pass state was drawing on the palette's green,
which tied a fixed meaning to a colour free to be repainted. It has its
own `--bpmn-ok` now, set to the green it was already showing.

The swatch labels stay as colour words: they are read by whoever is
picking a colour, so they describe what the swatch looks like and are
worth revisiting when the palette is repainted.

Also corrects a claim in the palette comment: these two tints sit at the
same lightness (1.02:1) and separate by hue alone, so what tells two pools
apart is the header text, not the tint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Loading a diagram goes through `graph.resetCells()`, which fires `reset`
rather than adding cell by cell, so `add` never ran for anything opened
and its icons kept whatever colour the library had baked in. Invisible
until now only because that colour suits a white body and an amber
gateway; a shape carrying any other fill would have shown it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same colour means a different path from one shape family to the next: a
task's fill is `attrs/background/fill`, a gateway's `attrs/body/fill`, and a
pool wears its colour on `attrs/header/fill` because a pool always has at
least one lane and its lanes cover its body. So a colour field now says
which of `fill`, `outline` and `text` it paints, and anything reading
across shape types pairs them up by that instead of by path.

Left unroled, each with a line saying why: a data store's cap and a pool's
body, having no counterpart to pair with, and the link configs, since a
link has a line where an element has a fill.

Also fixes the gateway calling its *text* colour "Fill", which showed two
rows both labelled Fill in its own form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting three shapes used to show the same empty panel as selecting
nothing, so recolouring a handful meant clicking each one and repeating
the same swatch click. The inspector now offers the colours the selection
has in common, and one pick paints all of them.

A row appears only where every selected shape has that role, so a comment
or a group in the selection takes the fill row away — neither has a fill.
Each shape is written at its own path, all inside one batch, so the whole
selection is one thing to undo.

Where the shapes disagree no swatch reads as selected, which is the whole
of the visual answer. An empty radio group says nothing to a screen reader
though, so the group is described as mixed for anything listening.

The swatch row moves out of the single-shape field into `ColorSwatches`,
which both forms now render, and the value it takes may be `null` for the
mixed case. Reading a value stays in one place — `readFieldValue` — so
what a swatch shows as selected cannot drift between the two forms.

Links keep their own form: a line has nothing in common with a fill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every shape's border and every link took `--bpmn-palette-outline`, which
is not one of the ten swatches, so the inspector showed no colour selected
for a border that plainly has one — and once a border was changed there
was no way to set it back. The colour was always explicit on the shape;
what was missing was a swatch to match it.

They take `--bpmn-palette-ink` now, which is a swatch, in the shape
defaults, in the sample diagram that carries the same value per cell, and
in the link colour. Borders are darker for it, and contrast improves
rather than suffers: ink sits about 14:1 on the paper where outline sat
7.5:1.

`--bpmn-palette-outline` stays defined for a diagram saved with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule was only enforced on the way in: a link added to a selection was
dropped, but a link clicked on its own arrives through `reset` and never
met the check, so picking a shape afterwards left the two kinds selected
together — and only in that order, which is how it came to light.

The same rule now runs whenever an element joins and whenever a region
replaces the selection wholesale. Elements win, as they already did when a
link was added last, and a selection of links alone is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A comment left in `bpmn-editor.css` described a filter that is no longer
there — a note only legible to someone who had seen the rule it replaced,
attached to nothing. The reason it is gone lives with the code that made it
unnecessary, and the removal is in the commit that did it.

Two more said their piece in the past tense: the accessibility badge's
colour and the outline token now state what they are for, the latter
keeping the one fact a reader needs before deleting it — a diagram saved
earlier still references it.

And the plain-text editor no longer claims a Firefox version I had not
checked. What the fallback rests on is the `contentEditable` setter
throwing for a value the browser does not know, which the HTML spec
requires; the content attribute is the other way round, and that
distinction is worth having next to the `try`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`FocusEvent<HTMLDivElement>` already types `target` as `EventTarget &
HTMLDivElement` and `relatedTarget` as `Element | null`, so `dataset` was
reachable without the cast to `HTMLElement`, `contains()` was satisfied
without the cast to `Node`, and `dataset` is never absent on an element.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three widenings of the same form.

A pool wears its colour on its header, because a pool always has a lane and
its lanes cover its body, so pools and lanes now take part rather than
being edited one at a time.

Connectors get a section of their own. Keeping them apart from the shapes
is what makes a shared "fill" honest: a connector has a line where a shape
has a body, so `outline` reads as "Line" there, and a role a kind has no
word for is simply absent. A colour picked in one section leaves the other
alone. Which also means a selection may hold both kinds again — the rule
that kept them apart is gone, though shapes stay scoped to one pool, since
a message flow crosses pools by definition.

`ctrl+a` now takes the shapes, and the connectors on a second press:
recolouring or deleting "everything" usually means the shapes, and the
connectors follow their ends.

Fonts join the colours. The role moves onto the field base so a select box
can carry one too, and a select box's own option type is preserved when
written — a font size is a number, and writing "14" would quietly make it
a string. Where the cells disagree the box reads as a dash, the same answer
the swatches give by showing nothing selected, with the reason left to
assistive technology.

A pool has no label size to offer, so a selection including one loses that
row and keeps the rest — the intersection doing what it is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A selected connector showed nothing: the frames are masks around a shape's
body, and a connector has none. It now carries a class, styled in CSS —
its wrapper path, invisible and 10px wide, becomes a halo along the line
while the line keeps its own colour.

The class comes from extending the frame list rather than switching to the
options form of `frames`, which is the only branch react-plus applies
`cellClassName` on but replaces the masks with rectangular boxes — no use
to a diamond or a circle. Extending it keeps the outline that follows each
shape and adds the class for everything, and asking for a mask on a
connector is skipped, which also clears the unmeasurable node that left
behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing a palette item started a drag at once, so a click built a drag
clone and threw it away on release — a click and a drag were the same
gesture. A press now becomes a drag only once the pointer has moved a few
pixels. The stencil's own `dragThreshold` has no say here: this palette
starts the drag itself, and `startCellDrag` takes no threshold.

Focusing an item no longer aims at a lane either, unless the focus came
from the keyboard. A click leaves no focus ring, so aiming from one lit up
a lane with nothing to explain it — and the pointer has the drag, with its
own highlighting. The keyboard picks the aim up on the first key instead,
so clicking an item and then pressing an arrow still works.

And the items have a focus ring of the app's own. They were the only
control here left with the browser's hairline default (WCAG 2.4.7), in the
one place a keyboard user starts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ring was drawn as an outline on the scroller, which its own background
covers — a positioned child the size of the entire sheet — leaving the ring
visible only along whichever edges that background happened not to reach,
so it appeared on the right and bottom once the sheet was scrolled to its
end.

It is drawn by the container now, which does not scroll, and as an overlay
rather than an outline so nothing can paint over it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`isPool()` narrows to `AppPool`, whose classes extend `CompositePool`, so
`adjustToContainElements` was already on the type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`App` distinguished these from the library's types back when this was a
generic scaffold; it says nothing now, while `Bpmn` says what they are —
and it reads with the components that already carry the prefix.

`AppElement`, `AppLink`, `AppShape`, `AppPool`, `AppSwimlane`,
`AppLinkView` and `AppLinkConstructor` become `BpmnElement`, `BpmnLink`,
`BpmnShape`, `BpmnPool`, `BpmnSwimlane`, `BpmnLinkView` and
`BpmnLinkConstructor`. The `Appearance*` field types keep their name: they
describe inspector fields, not shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picking cells one by one was scoped to a single participant, so shapes in
two pools could not be selected together — which is exactly what bulk
editing is for. The scoping came from the original React port and predates
that form; the gesture it belongs to is the region, which has its own rule
in `selectionRegion` and keeps it.

Dragging such a selection is the part that misbehaves, so the translate is
withheld while a selection spans pools — computed from the selection's own
membership, since `allowTranslate` is a boolean and cannot be refused per
gesture. A shape still drags on its own, and a selection inside one pool
drags as before.

`preventDefaultInteraction` on the element's pointerdown does not work for
this: the selection widget drives the translate itself rather than going
through the paper's element drag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Looking for a free spot beside a shape counted every element on the paper,
so a shape in another lane pushed the neighbour along past empty space. The
neighbour is embedded in the source's lane and the lane grows to hold it,
so what shares that lane is what can be in the way. Where the spot falls
outside the lane, another pool's shapes cannot be avoided by sliding
anyway — the pool occupies that ground regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Telling a click from a drag is the stencil's business, not a demo's, and
`startCellDrag` starting immediately is what its name says — so holding
the press ourselves, with pointer capture and the bookkeeping around it,
was the wrong place for it. Requested as a library API instead:
clientIO/joint-plus#804.

A press starts the drag again, as it did before, which leaves a click
building a clone and discarding it on release. The two fixes from the same
change stay: focusing an item no longer aims at a lane unless the focus
came from the keyboard, and the items keep their focus ring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The drag clone passed behind the palette. The drag paper is given
`popover="manual"` and has its UA popover styling neutralised, but nothing
calls `showPopover()` on the React drag path, so it never enters the top
layer: it renders as an ordinary absolutely positioned child of the body at
`z-index: auto`, which the stencil container paints over.

Shown here on drag start until the library does it —
clientIO/joint-plus#803 — guarded so it is inert once that lands, and where
the browser has no popover API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A group wore the halo meant for a connector's line as a thick band inside
its dashed border: it has a `wrapper` node of its own — invisible and 10px
wide, there for hit-testing — and the rule was keyed on the selected class
alone. Scoped to links.

The class is also renamed to `highlighter-selected`, from the library's
`jj-is-selected`. This code applies it, so the name should be ours: the two
rules react-plus ships under that name target react-rendered cells this app
does not use, and were the app ever to let the library apply the class
itself, both sources would be styling cells under one name. It now reads
like the other classes applied through `highlighters.addClass`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three places wrote the same two lines to return the focus to the shape they
were opened from, each casting `view.el` to an SVGElement and calling
`focus` optionally — neither needed, since `dia.CellView.el` is typed with
`focus()` already. They call `focusCell(paper, cell)` now.

`useAccessibility` keeps its own version: it goes through `requireView` to
render a cell that has none yet, which is a different thing from focusing
one that is already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hook installed its own `cmdBeforeAdd` on the command manager to keep
the recoloured icons off the undo stack, chaining to whatever veto was
already there. `<Diagram history>` does this itself: react-plus builds a
`cmdBeforeAdd` that reads the last event argument and drops any command
whose options carry `skipHistory`, then defers to `filterCommand`. It reads
the options rather than the setter it was called through, so a plain
`element.attr(path, value, opt)` opts out the same way `setCell` does.

Passing `{ skipHistory: true }` at the write also removes a hazard: setting
`cmdBeforeAdd` from an effect would have overwritten a `filterCommand`
given to `<Diagram>`, since the library composes its veto when the command
manager is constructed.

The two guards for a null `graph` / `commandManager` go with it — both are
non-nullable on the hooks' return types, and no other call site checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three hand-written `graph.on`/`graph.off` pairs in an effect become the
event map the library takes. Its handlers are always-latest — the
subscription is made once and each event reads the current handler — and it
re-subscribes only when the graph or the set of event names changes, where
the effect rebuilt all three listeners whenever the graph did.

The `useEffect` stays for the MutationObserver, which watches the theme
attribute on the document element rather than anything in the graph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`dropPoolAt()`, the keyboard counterpart of the pool drop, repeated the
sizing `onPoolDragStart()` does: the same "first pool only" test, the same
`calculatePoolDimensions()`, the same content bounding box inflated by the
margin and the lane header, the same `pool.size()`. `placePoolAt()` was
already shared between the two paths; this stopped one step short of it.

`sizePoolToContent()` now holds that, returning the box the pool has to
cover and the dimensions it was given, or `null` where there is nothing to
wrap. The pointer path builds its preview from the dimensions and keeps the
pool over the box as it moves; the keyboard path drops it on the box.

`dropPoolAt()` calls it before adding the pool to the graph, or the pool
would count itself as the pool whose presence means the content is already
someone's, and the wrap would quietly stop happening.

Its no-wrap branch keeps positioning the pool itself rather than calling
`placePoolAt()`, which embeds every loose element — a second pool would
take the first one's shapes with it. The comment now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`dnd/` had collected three kinds of code: the pointer handlers, the
placement actions the keyboard drives, and plain queries with no drag in
them. The queries are what non-dnd code kept reaching in for — the aiming
hook and the quick-add.

`utils/pools.ts` now holds them, next to the `isPool` / `getPoolParent`
predicates they read like: `getPoolsInOrder` and `findDropPool` (the pool
to step from and the pool to aim from), `findDropSwimlane` (its lane
counterpart) and `findFreeSpotBeside`, which brings the `Direction` type
along as its only user. `use-target-aim` imports nothing from `dnd/` any
more.

`placePoolAt` becomes module-private: it has no caller outside `dnd/pools`.

The placement actions stay where they are — `dropPoolAt` shares the sizing
and placing internals with the pointer drop, and moving it would export
three helpers to save one import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two files sat on the wrong side of the line between `actions/` — what the
user does, with its side effects and its single undo entry — and the rest.

`importBPMN` loads imported cells into the graph, normalising the structure
as it goes. That is the document command, and it lived in `utils/` while
the file reading that drives it lived in `actions/`: one feature split
across both folders. It moves next to `importFile`, its only caller, and
stops being exported at all.

`setupFileImport` was the reverse — not a command but effect wiring: it
attached three listeners to the scroller and returned a cleanup, for one
caller that ran it inside `useEffect`. That is what an effect body is, so
it moves into `FileImportOverlay`, which already owned the overlay it
toggles. The component now imports the command instead of its own plumbing.

The handlers are arrow consts rather than declarations: TypeScript carries
the guard's narrowing into a closure but not into a hoisted `function`, so
declarations would have cost six non-null assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`actions/import-actions.ts` said it twice. The names are `actions/import`
and `actions/export` now, which the last commit made possible: `import.ts`
was taken until `importBPMN` left `utils/`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shapes a source may connect to is a question about links, answered by
`validateConnection()` — the same rule the pointer path enforces. It sat as
a private function in the `QuickLink` component, where nothing else could
ask it.

It moves to `utils/links.ts`, next to `resolveDefaultLinkType` and
`validateAndReplaceConnections`, taking the ordering with it: a pool or an
artifact reads last because it is the rarer choice, and that is part of the
answer rather than the picker's own presentation.

`describe()` stays in the component — reading a shape's name for a row is
presentation, not a link rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The drag handlers had grown the low-level work inline: `dnd/pools.ts` alone
carried the preview node's SVG, the pointer clamping, the grid snapping, the
content sizing and the placement, so reading a callback meant reading all of
it. They now read as a few named steps, and the work sits where it can be
reached and named:

  utils/pools.ts       sizePoolToContent, isPoolBoundaryRequired,
                       getClampedPoolPosition, canMoveSwimlane,
                       findPoolViewAtPoint, getPositionInSwimlane
  effects/pool-preview show/move/removePoolPreview, beside ghost.ts and
                       swimlane-preview.ts, which already did this
  actions/place-pool   placeDroppedPool, dropPoolAt
  actions/insert-swimlane  insertSwimlaneIntoPool, dropSwimlaneIntoPool
  actions/add-element   addElementToSwimlane

`dnd/pools.ts` goes from 334 lines to 53, `dnd/swimlanes.ts` from 189 to
113, `dnd/elements.ts` from 247 to 102.

Names that were lying are fixed on the way: `checkSwimlaneDrop` performs the
drop rather than checking it, so it is `dropSwimlaneIntoPool`;
`positionInSwimlane` returns a point, so it is `getPositionInSwimlane`; and
`ensurePoolDragBoundary` is `getClampedPoolPosition`.

Two things the flattening turned up. `onSwimlaneDrag` derived the same
"is this pool acceptable" condition in three branches, once per early
return — it is computed once now. And `dropPoolAt` built its own first lane
instead of going through `insertSwimlaneIntoPool`, so "every pool gets a
lane" was written twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dialog's list scrolls when the window is too short for it, and nothing
could focus it, so a keyboard user could not scroll to the shortcuts below
the fold — axe reports it as `scrollable-region-focusable`, serious.

It takes `tabIndex={0}` with a group role and the dialog's own name, plus
the inset ring the palette items use, so the focus is visible when it lands
there. A group rather than a region: a landmark inside a dialog is noise.

Found by re-running the accessibility audit for the PR description, which
had claimed a clean pass since before the palette repaint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kumilingus
kumilingus merged commit aacff0d into main Sep 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants