Skip to content

feat: Atlas interaction overhaul -- unified click model, flip retirement, keyboard nav (goals 0102/0104/0106 slice A) - #227

Merged
alicoding merged 6 commits into
mainfrom
interaction-overhaul
Aug 18, 2026
Merged

feat: Atlas interaction overhaul -- unified click model, flip retirement, keyboard nav (goals 0102/0104/0106 slice A)#227
alicoding merged 6 commits into
mainfrom
interaction-overhaul

Conversation

@alicoding

Copy link
Copy Markdown
Owner

One click model, uniform across every node type (card, sticky, frame body, region chip):

  • Click = select-and-replace with a visible ring; shift-click toggles multi-selection membership; click empty pane deselects.
  • Commit = click the sole-selected node again, double-click, ⌘-click, or ↵ — a leaf opens its page, a place zooms. Two plain clicks equal a double-click; touch needs no separate path.
  • The card flip is retired. The page is the one exhaustive surface; everything the back face carried already lives there. Typed-link drags start from a hover link-handle on the card front — the slot is still the type, the popover-free release contract unchanged. The region chip's kind label moved to a tooltip.
  • Keyboard nav (0104): arrows nudge (persisted on keyup), Tab/Shift-Tab cycle reading order, Option-arrows move focus directionally, ↵ commits, Escape ladders close-page → clear-selection → up-level. React Flow's built-in per-node key handling is disabled so one press never fires two meanings.

Three bugs found live and pinned by tests along the way:

  1. Slot-drag released onto its own origin card synthesized a click on the common ancestor, silently reopening the card — one-shot capture-phase click swallow in useAtlasSlotDrag.
  2. React Flow's built-in a11y key handling raced the board's own hooks (one Escape both cleared and climbed) — disableKeyboardA11y + DOM-truth selection reads.
  3. The selection ring was invisible on the just-clicked node: Primer's [role="button"]:focus:not(:focus-visible) reset (0,4,0) outranked the inner-element ring rules (0,3,0). Pre-existing on main, but the new click model makes plain-click-select the primary gesture. Ring moved to the React Flow node wrapper (.react-flow__node-.selected — no role, structurally immune), pinned by a focused-click e2e case per node family.

Full e2e migration off the flip gesture rides in the same PR; QUARANTINE.md gains atlas-folder-import's contention-only entry. Vitest 546/546, atlas e2e sweep green, all hooks green. Screenshot-reviewed against the design contract (ring visible on click, commit opens page, Escape returns).

Goals: docs/goals/0102-selection-click-model.md, 0104-atlas-keyboard-navigation.md, 0106-frictionless-card.md (slice A).

🤖 Generated with Claude Code

https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd

alicoding and others added 5 commits August 18, 2026 01:03
…ent, keyboard nav (goal 0102/0104/0106 slice A)

Retires the flip gesture across every node type (note card, region
frame, region chip): plain click now selects/replaces per the
researched gesture table, a click on the already-selected node
commits (leaf opens its page, a place zooms), shift-click toggles,
cmd-click commits instantly, and a real double-click reproduces the
same outcome via two plain clicks -- no dedicated dblclick handler
needed. The retired back face's typed-link slot-drag relocates onto
the note card's own hover-revealed right-edge link handle, dragging
the board's default (first-declared) link kind; kind changed
afterward via the existing edge menu.

Keyboard: arrows nudge the selection (1px, 10px with Shift) and
persist via a keyup-batched SetPosition/SetNotePosition, or pan the
camera with no selection; Tab/Shift+Tab walk top-level nodes in
reading order; Option+Arrow jumps to the nearest node in a direction
(atlasKeyboardNavGeometry.ts, unit-tested); Enter commits the sole
selected card. Escape's ladder (clear-selection, then go up one
level) now shares atlasFocusContainment.ts's own guard with the
keyboard-nav hook, so it never fires on top of a portaled Dialog or
an editable field that's already consuming the same keypress --
regression: an Escape meant to close the Lens dialog was also
drilling the board up a level.

Fixes a real click-model bug surfaced by the new "already-selected
commits" rule: releasing a slot-drag back onto its own origin card
synthesizes a native click on their common DOM ancestor (the card
itself), which would otherwise reopen its page uninvited -- a
one-shot capture-phase listener swallows exactly the click that
follows a drag's release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd
… contract item 6)

Every flip-dependent helper/assertion across the atlas e2e suite
migrates to the click-select-then-commit model: fixtures/atlasBoard.
ts's openViaFlip becomes openCard (plays both clicks, retrying past
the same micro-drag race the old flip click already guarded against)
plus a new closeCard helper that waits for Primer's own Dialog
backdrop to fully clear, not just its content -- a tight open/close/
interact cycle can outrun that animation otherwise. Every
`aria-label="Flip …"` locator becomes `"Open …"`; every
data-flipped/atlas-*-card-back assertion is replaced with a
selection-wrapper check or a page-content check, since that content
now lives on the card's own page exclusively. Reaching a place's own
page (previously a body-click flip then Open) is now a ⌘-click on
the frame body, the pointer twin of ⌘↵.

New coverage per the gesture/key tables: click-replaces-selection,
click-an-already-selected-node-commits, a real double-click producing
the identical outcome, ⌘-click's instant commit, and click-empty
deselect (atlas-gestures.spec.ts); the hover link-handle's slot-drag
relocation (atlas-slots.spec.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd
"Add 1 cards" times out only under 4-worker parallel local load (own
dedicated server, so not a cross-spec data race) -- passes clean in
isolation and on retry every time observed, same signature
layout.spec.ts:163 already carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd
…cape/Enter/Arrow handling

React Flow ships its own per-node keyboard accessibility (Escape
unselects the FOCUSED node, Enter/Space toggles it, arrow keys nudge
it) as a second, uncoordinated keyboard system layered on the exact
same DOM nodes this board's own hooks already own. Reproduced live:
selecting a card then pressing Escape once both cleared the
selection AND climbed a level in the same press, because React
Flow's own unselect ran first (attached directly to the focused
node) and had already cleared the selection by the time this
board's own Escape ladder read it -- true whether that read came
from the selectedCards state mirror or a live DOM query, since
React Flow's own handling had already mutated both.

`disableKeyboardA11y` on the board's `<ReactFlow>` makes this
board's own hooks (useAtlasKeyboardNav, useAtlasSelectionTray's
Escape ladder, each card's own onKeyDown) the sole keyboard
authority, matching the goal 0102/0104 gesture and key tables
exactly rather than a blend of two systems. Also swaps
useAtlasKeyboardNav's own Tab/Enter/Arrow reads and the Escape
ladder's own clear-selection check from the selectedIDsRef/
selectedCards state mirror to a direct DOM read
(atlasFocusContainment.ts's new readSelectedNodeIDs) -- that mirror
updates off React Flow's own onSelectionChange callback, a render
behind the `.selected` class itself, a gap a keydown handler firing
right after the click that just selected something can still hit
even with the accessibility layer disabled.

New coverage: atlas-gestures.spec.ts's Escape ladder test pins the
two-rung sequence (clear, then up-a-level) that the race broke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd
… focus reset beat the inner-element box-shadow

Live-reproduced, screenshot-confirmed: a plain click on a single card
selected it with zero visible change. Primer's global focus reset,
[role="button"]:focus:not(:focus-visible):not(.focus-visible) {
box-shadow: none } (specificity 0,4,0), beats every ring rule of the
shape :global(.react-flow__node.selected) .card (0,3,0) whenever the
clicked node's inner div is :focus without :focus-visible -- exactly
the state every mouse click leaves it in. The ring only ever survived
on non-focused members of a multi-selection and under keyboard
:focus-visible, which is why the prior computed-style e2e assertions
passed while the live gesture was broken. role="button"/tabIndex
pre-existed on main, so the collision itself is pre-existing, but the
new click model makes plain-click-select the PRIMARY gesture, so it
cannot ship this way.

Moves the ring from the inner element to the React Flow node WRAPPER
for all four node types, keyed off the type-scoped class React Flow
already stamps per nodeTypes key (.react-flow__node-<type>.selected)
-- the wrapper carries no role, so Primer's reset structurally cannot
match it, rather than out-specificity-ing a reset that could always
be bumped again. The matching border-radius moves with it so the ring
follows each shape. The sticky's border-color flip stays on the inner
element -- Primer's reset only touches box-shadow, so that half
already survived.

New e2e coverage pins the exact broken state: a plain click on a
single card, then the wrapper's own computed box-shadow while that
card still holds DOM focus (confirmed failing against the pre-fix
selector, passing after). Existing multi-select ring assertions
(atlas-select-group.spec.ts) now measure the wrapper instead of the
inner card, for the same reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd
@alicoding
alicoding enabled auto-merge (squash) August 18, 2026 05:43
… fixture

The spec landed on main (PR #225) after this branch's e2e migration
pass, so the rebase brought it in still importing openViaFlip and
matching the old 'Flip <title>' aria-label; module-load failure took
down all four CI shards. Now uses the shared noteCard/openCard
fixtures (the local helper duplicated what atlasBoard.ts already
exports).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd
@alicoding
alicoding merged commit 15409e7 into main Aug 18, 2026
26 of 27 checks passed
@alicoding
alicoding deleted the interaction-overhaul branch August 18, 2026 06:20
alicoding added a commit that referenced this pull request Aug 18, 2026
…lip, ring reads the node wrapper

PR #227 retired the flip and moved every selection ring to the React
Flow node wrapper; the smoke registry's note-card check now drives
click-select then click-commit (page opens, Escape ladder restores an
unselected board), and readRing/readStickyStyle measure box-shadow on
the wrapper where the ring actually lives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd
alicoding added a commit that referenced this pull request Aug 18, 2026
…lip, ring reads the node wrapper (#228)

PR #227 retired the flip and moved every selection ring to the React
Flow node wrapper; the smoke registry's note-card check now drives
click-select then click-commit (page opens, Escape ladder restores an
unselected board), and readRing/readStickyStyle measure box-shadow on
the wrapper where the ring actually lives.


Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant