- Object aria-label uses kindDef().label (the canonical "In-ground") instead of
an ad-hoc kind.replace() that produced "in ground" and diverged from the UI.
4+ models flagged this.
- aria-current, not aria-pressed, for the selected object — selection isn't a
toggle, which is what aria-pressed means; aria-current marks the active item.
- Modal focus trap made robust: if focus is NOT inside the dialog (fell to
<body> because the focused control was removed — ShareGardenModal's
remove-share button — or disabled while busy, or externally stolen), Tab now
pulls it back in instead of escaping. The previous branches only handled
focus being exactly at a known boundary.
- Focus restore checks opener.isConnected before calling focus(): the delete/
clear flows this targets often remove the element that opened the dialog, and
a disconnected node's focus() silently no-ops.
- Hoisted the focusable-element selector to a module constant, and excluded
input[type="hidden"] (it matched input:not([disabled]) and, at a boundary,
broke the wrap).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
The arrow-key nudge handler existed but only ever acted on a POINTER
selection, and nothing could select without a mouse — so the feature was
unusable by exactly the keyboard users it's for. This is the scoped first
slice: give the canvas a keyboard path in, and fix the Modal focus trap that
every destructive confirmation goes through.
Canvas:
- The <svg> gets role="application" + an aria-label describing the controls,
and a <title> naming the garden — a screen reader now announces an
interactive canvas rather than an empty graphic.
- Each object <g> is a focusable role="button" with an aria-label (name +
kind) and aria-pressed reflecting selection. Enter/Space selects it — the
step that was missing — which makes the existing arrow-key nudge reachable.
- A :focus-visible CSS rule draws a dashed accent ring on keyboard focus (and
NOT on a mouse click, which is the point of :focus-visible). CSS rather than
React state because onFocus on an SVG <g> is unreliable, and a CSS rule
cleanly overrides the shape's inline stroke.
Modal (blast radius: DeleteGarden/ClearBed/DeletePlant/DeleteSeedLot/Share):
- Tab is trapped inside the dialog and wraps at the ends, instead of walking
out into the page behind the backdrop.
- On close, focus returns to the element that opened the dialog rather than
landing on <body>.
Verified live against the built binary with real keyboard input: Tab focuses
an object (SVG <g tabindex> genuinely takes focus), Enter flips aria-pressed
false→true, the focus-visible dash renders (computed stroke-dasharray "5px,
4px"), the dialog traps focus through 5 Tabs, and Escape closes it and
restores focus to the opener.
Follow-ups noted, not done here: object dimensions in the aria-label (needs the
garden's unit context this component doesn't hold), roving-tabindex between
plops inside a focused bed, and the EditorRail tablist semantics.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ