From 520eef5b5a78a249654243090d68a2ced92f44a2 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 01:01:50 -0400 Subject: [PATCH 1/6] feat: Atlas interaction overhaul slice A -- click model, flip retirement, 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 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- frontend/src/atlas/AtlasBoard.tsx | 73 +++---- frontend/src/atlas/AtlasCardOverlay.tsx | 1 - frontend/src/atlas/AtlasGroupNode.module.css | 92 +-------- frontend/src/atlas/AtlasGroupNode.tsx | 192 +++++++----------- .../src/atlas/AtlasNoteCardNode.module.css | 158 ++++---------- frontend/src/atlas/AtlasNoteCardNode.tsx | 185 +++++++---------- .../src/atlas/AtlasRegionChipNode.module.css | 58 +----- frontend/src/atlas/AtlasRegionChipNode.tsx | 60 ++---- frontend/src/atlas/AtlasSlotRows.tsx | 114 ++++------- frontend/src/atlas/AtlasStickyNode.tsx | 25 ++- frontend/src/atlas/AtlasView.tsx | 4 - frontend/src/atlas/atlasBuildBoardEdges.ts | 17 +- frontend/src/atlas/atlasBuildBoardNodes.ts | 107 ++++------ frontend/src/atlas/atlasFocusContainment.ts | 24 +++ .../atlas/atlasKeyboardNavGeometry.test.ts | 50 +++++ .../src/atlas/atlasKeyboardNavGeometry.ts | 66 ++++++ frontend/src/atlas/atlasSlotRowModel.ts | 2 +- frontend/src/atlas/atlasStickyNodes.ts | 7 +- frontend/src/atlas/useAtlasKeyboardNav.ts | 151 ++++++++++++++ frontend/src/atlas/useAtlasSelection.ts | 13 +- frontend/src/atlas/useAtlasSelectionTray.ts | 43 ++-- frontend/src/atlas/useAtlasSlotDrag.ts | 11 + frontend/src/locales/en/atlas.json | 11 +- 23 files changed, 692 insertions(+), 772 deletions(-) create mode 100644 frontend/src/atlas/atlasFocusContainment.ts create mode 100644 frontend/src/atlas/atlasKeyboardNavGeometry.test.ts create mode 100644 frontend/src/atlas/atlasKeyboardNavGeometry.ts create mode 100644 frontend/src/atlas/useAtlasKeyboardNav.ts diff --git a/frontend/src/atlas/AtlasBoard.tsx b/frontend/src/atlas/AtlasBoard.tsx index e3f9e495..8cbbcf7f 100644 --- a/frontend/src/atlas/AtlasBoard.tsx +++ b/frontend/src/atlas/AtlasBoard.tsx @@ -25,6 +25,7 @@ import { useAtlasDragFiling, type FrameBox } from './useAtlasDragFiling' import { useAtlasSelection } from './useAtlasSelection' import { useAtlasSelectAll } from './useAtlasSelectAll' import { useAtlasSelectionTray } from './useAtlasSelectionTray' +import { useAtlasKeyboardNav } from './useAtlasKeyboardNav' import { useAtlasSlotDrag } from './useAtlasSlotDrag' import { AtlasSlotDragLine } from './AtlasSlotDragLine' import { buildBoardCardNodes } from './atlasBuildBoardNodes' @@ -72,7 +73,7 @@ export interface AtlasFocusRequest { // media-query gate AtlasNoteCardNode.module.css's own flip already // uses, read here in JS via usePrefersReducedMotion since React Flow's // own transition durations are JS options, not CSS. -function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, parentID, arrangeRequest, viewedID, focusRequest, onDrill, onOpenOverlay, onFocusHandled, onCardContextMenu, onPaneContextMenu, onArteryContextMenu, onNoteContextMenu, onFrameContextMenu, onFrameInteriorContextMenu, onMultiSelectContextMenu, onDeleteSelection, onGroupSelection, placementRequest, promoteRequest, groupRequest, onJumpToChip }: { +function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, parentID, arrangeRequest, viewedID, focusRequest, onDrill, onOpenOverlay, onFocusHandled, onCardContextMenu, onPaneContextMenu, onArteryContextMenu, onNoteContextMenu, onFrameContextMenu, onFrameInteriorContextMenu, onMultiSelectContextMenu, onDeleteSelection, onGroupSelection, placementRequest, promoteRequest, groupRequest }: { cards: Card[] allCards: Card[] kinds: Kind[] @@ -130,8 +131,6 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare placementRequest?: AtlasPlacementRequest | null promoteRequest?: AtlasPromoteRequest | null groupRequest?: AtlasGroupRequest | null - // A slot-row chip's own click (goal 0081 A4): reuses ⌘K's own focus/jump plumbing. - onJumpToChip: (cardID: string) => void }) { const { t } = useTranslation('atlas') const readOnly = useIsNarrowViewport() @@ -140,11 +139,10 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare // positions-sovereign; the packer runs only on demand (below) or // in-memory for cards that have no position yet. const isFree = true - const [flippedID, setFlippedID] = useState(null) const [pulsedID, setPulsedID] = useState(null) const [hintedID, setHintedID] = useState(null) const wrapperRef = useRef(null) - const { fitBounds, fitView, getNodesBounds, screenToFlowPosition } = useReactFlow() + const { fitBounds, fitView, getNodesBounds, getViewport, setViewport, screenToFlowPosition } = useReactFlow() // Free-mode overlap resolution (goal 0073, the growth class): a // frame's size is DERIVED from its children, so a clear layout can @@ -206,56 +204,32 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare return () => window.removeEventListener('keydown', onKeyDown) }, [cards, notes, onDeleteSelection, selection.selectedIDsRef]) - const toggleFlip = useCallback((id: string) => setFlippedID((cur) => (cur === id ? null : id)), []) - // Zoom chip / group-header click / Enter on a region frame (routed // here through AtlasGroupNode's own data.onDrill) all fly the camera // into the frame's rendered bounds first, then re-root exactly once // when that transition resolves -- never before. const handleDrill = useCallback((groupID: string) => { - // A commit supersedes a glance (goal 0074): entering a place - // clears whatever was flipped before the camera moves. - setFlippedID(null) const bounds = getNodesBounds([groupID]) void fitBounds(bounds, { duration: reduceMotion ? 0 : 450, padding: 0.25 }).then(() => onDrill(groupID)) }, [getNodesBounds, fitBounds, reduceMotion, onDrill]) - // A leaf's double-click commit (goal 0074): unflip, open its page. - const handleLeafCommit = useCallback((cardID: string) => { - setFlippedID(null) - onOpenOverlay(cardID) - }, [onOpenOverlay]) - // Which card ids ACTUALLY render on THIS board: top-level children - // plus each frame's capped preview -- excluding a flipped frame's - // children, which builtNodes omits while its back face covers them. - // Honesty here is load-bearing twice over: the ⌘K/entry focus - // effect below trusts this Set before flying (flying to an omitted - // node meant a pulse on nothing), and resolveBoardEdges reattaches - // links to a flipped frame instead of drawing to its missing - // children. Kept independent of pulsedID/hintedID so a pulse's own - // state-set never re-triggers the focus effect mid-animation; - // flippedID IS a dependency now, deliberately -- an unflip must - // re-fire that effect so a pending jump can land on the children it - // just revealed. + // plus each frame's capped preview. Honesty here is load-bearing for + // the ⌘K/entry focus effect below, which trusts this Set before + // flying, and for resolveBoardEdges, which reattaches links to a + // frame's own children by the same set. Kept independent of + // pulsedID/hintedID so a pulse's own state-set never re-triggers the + // focus effect mid-animation. const renderedIDs = useMemo(() => { const ids = new Set() for (const card of cards) { ids.add(card.ID) - if (isGroupCard(allCards, card) && flippedID !== card.ID) { + if (isGroupCard(allCards, card)) { for (const child of computeGroupFrameLayout(allCards, card.ID).children) ids.add(child.card.ID) } } return ids - }, [cards, allCards, flippedID]) - - // Attention supersedes a glance: an incoming jump/entry focus - // unflips whatever is flipped, so a target hidden behind a frame's - // back face becomes real before the fly effect (re-fired by the - // renderedIDs change above) goes looking for it. - useEffect(() => { - if (focusRequest) setFlippedID(null) - }, [focusRequest]) + }, [cards, allCards]) const dragFiling = useAtlasDragFiling({ allCards, parentID, topLevelBoxes, wrapperRef }) @@ -265,7 +239,6 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare onLink: (fromCardID, toCardID, linkKindID) => void AtlasService.CreateLink(fromCardID, toCardID, linkKindID, '').catch(console.error), onGuidedCreate: creation.openSlotLinkedCreate, }) - const removeLink = useCallback((linkID: string) => void AtlasService.DeleteLink(linkID).catch(console.error), []) const areaDraw = useAtlasAreaDraw({ armed: isFree && !readOnly && creation.armedTool === 'area', @@ -283,14 +256,14 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare const builtNodes = useMemo(() => buildBoardCardNodes({ cards, allCards, kinds, links, linkKinds, isFree, readOnly, boardWidth, freeMoves, arteries, - flippedID, pulsedID, hintedID, hoveredFrameID: dragFiling.hoveredFrameID, - toggleFlip, onOpenOverlay, handleDrill, handleLeafCommit, - slotDragSourceID: slotDrag.dragSourceID, onSlotAnchorPointerDown: slotDrag.startDrag, onJumpToChip, onRemoveLink: removeLink, - }), [cards, allCards, kinds, links, linkKinds, isFree, readOnly, flippedID, pulsedID, hintedID, onOpenOverlay, handleDrill, handleLeafCommit, freeMoves, arteries, boardWidth, dragFiling.hoveredFrameID, toggleFlip, slotDrag.dragSourceID, slotDrag.startDrag, onJumpToChip, removeLink]) + pulsedID, hintedID, hoveredFrameID: dragFiling.hoveredFrameID, + isSoleSelected: selection.isSoleSelected, onOpenOverlay, handleDrill, + slotDragSourceID: slotDrag.dragSourceID, onSlotAnchorPointerDown: slotDrag.startDrag, + }), [cards, allCards, kinds, links, linkKinds, isFree, readOnly, pulsedID, hintedID, onOpenOverlay, handleDrill, freeMoves, arteries, boardWidth, dragFiling.hoveredFrameID, selection.isSoleSelected, slotDrag.dragSourceID, slotDrag.startDrag]) const [hoveredEdgeID, setHoveredEdgeID] = useState(null) // Quiet edges (goal 0081 A4): see atlasBuildBoardEdges.ts. - const edges = useMemo(() => buildBoardEdges(arteries, linkKinds, hoveredEdgeID, flippedID, t), [arteries, linkKinds, hoveredEdgeID, flippedID, t]) + const edges = useMemo(() => buildBoardEdges(arteries, linkKinds, hoveredEdgeID, t), [arteries, linkKinds, hoveredEdgeID, t]) // Sticky notes (goal 0081 slice A1): built separately from // builtNodes above (its own file, atlasStickyNodes.ts) since a note @@ -298,9 +271,10 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare // frame layout that dominates that memo. const stickyNodes = useMemo(() => buildStickyNodes({ notes, draftNotePos: creation.draftNoteFlowPos, editingNoteID: creation.editingNoteID, readOnly: readOnly || !isFree, + isSoleSelected: selection.isSoleSelected, onCommitDraft: creation.commitDraftNote, onCancelDraft: creation.cancelDraftNote, onEnterEdit: creation.enterNoteEdit, onCancelEdit: creation.cancelNoteEdit, onCommitEdit: creation.commitNoteEdit, - }), [notes, creation.draftNoteFlowPos, creation.editingNoteID, readOnly, isFree, creation.commitDraftNote, creation.cancelDraftNote, creation.enterNoteEdit, creation.cancelNoteEdit, creation.commitNoteEdit]) + }), [notes, creation.draftNoteFlowPos, creation.editingNoteID, readOnly, isFree, selection.isSoleSelected, creation.commitDraftNote, creation.cancelDraftNote, creation.enterNoteEdit, creation.cancelNoteEdit, creation.commitNoteEdit]) const allNodes = useMemo(() => [...builtNodes, ...stickyNodes], [builtNodes, stickyNodes]) const [nodes, setNodes, onNodesChange] = useNodesState(allNodes) @@ -315,7 +289,16 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare useAtlasSelectAll({ cards, notes, setNodes }) - const { trayRef, hasSelection: haveSelection, onGroup: onTrayGroup, onDelete: onTrayDelete } = useAtlasSelectionTray({ selectedCards: selection.selectedCards, selectedNotes: selection.selectedNotes, clearSelection: selection.clearSelection, setNodes, onDeleteSelection, onGroupSelection, onUnflip: () => setFlippedID(null) }) + const { trayRef, hasSelection: haveSelection, onGroup: onTrayGroup, onDelete: onTrayDelete } = useAtlasSelectionTray({ selectedCards: selection.selectedCards, selectedNotes: selection.selectedNotes, clearSelection: selection.clearSelection, setNodes, onDeleteSelection, onGroupSelection, wrapperRef }) + + useAtlasKeyboardNav({ + cards, readOnly, wrapperRef, + cardBoxes: topLevelBoxes, noteBoxes, + selectedIDsRef: selection.selectedIDsRef, setNodes, + isGroupCardFn: (card) => isGroupCard(allCards, card), + onOpenOverlay, onDrill: handleDrill, + getViewport, setViewport, + }) // Every re-root (drill in, breadcrumb out, jump) settles the new // board with an animated fitView rather than an instant snap. The diff --git a/frontend/src/atlas/AtlasCardOverlay.tsx b/frontend/src/atlas/AtlasCardOverlay.tsx index d6da3d5d..631261cc 100644 --- a/frontend/src/atlas/AtlasCardOverlay.tsx +++ b/frontend/src/atlas/AtlasCardOverlay.tsx @@ -269,7 +269,6 @@ export function AtlasCardOverlay({ card, kinds, allCards, links, linkKinds, onCl allCards={allCards} links={links} linkKinds={linkKinds} - variant="page" onChipClick={nav.navigate} onRemoveLink={(linkID) => void removeLink(linkID)} onAddLink={(linkKindID, toCardID) => void addLink(linkKindID, toCardID)} diff --git a/frontend/src/atlas/AtlasGroupNode.module.css b/frontend/src/atlas/AtlasGroupNode.module.css index d88096c5..f73498b3 100644 --- a/frontend/src/atlas/AtlasGroupNode.module.css +++ b/frontend/src/atlas/AtlasGroupNode.module.css @@ -1,7 +1,8 @@ -/* A region frame (goal 0072 slice A): a card holding cards, drawn as a - bordered/tinted container with its own children rendered as - separate React Flow nodes on top -- this file styles only the - frame's own chrome (background/border/header row). */ +/* A region frame (goal 0072 slice A, re-cut by goal 0106's flip + retirement): a card holding cards, drawn as a bordered/tinted + container with its own children rendered as separate React Flow + nodes on top -- this file styles only the frame's own chrome + (background/border/header row). */ .frame { position: relative; width: 100%; @@ -11,7 +12,6 @@ border-width: 1.5px; border-style: solid; cursor: pointer; - perspective: 1000px; } /* Multi-selection outline (owner-caught follow-up to goal 0092): same @@ -24,28 +24,6 @@ 0 0 0 5px var(--bgColor-accent-muted); } -/* The frame's own flip (goal 0072 slice C item 4) -- same rotateY - transition AtlasNoteCardNode.module.css's own .flipInner uses. */ -.flipInner { - position: relative; - width: 100%; - height: 100%; - transition: transform 0.5s cubic-bezier(0.4, 0.1, 0.2, 1); - transform-style: preserve-3d; -} -.flipInner[data-flipped='true'] { - transform: rotateY(180deg); -} -@media (prefers-reduced-motion: reduce) { - .flipInner { - transition: none; - } -} -.frontFace { - position: absolute; - inset: 0; - backface-visibility: hidden; -} .header { position: absolute; top: 0; @@ -117,8 +95,7 @@ /* A ⌘K jump landing on this frame (goal 0072 slice B) -- same animation/timing as AtlasNoteCardNode.module.css's own pulse ring - and hint chip, applied to the frame's own border instead of a flip - scene. */ + and hint chip. */ @keyframes atlasGroupPulseRing { from { box-shadow: 0 0 0 0 var(--fgColor-accent); @@ -154,63 +131,6 @@ z-index: 10; } -/* The frame's own back face (goal 0072 slice C item 4): covers the - entire frame at its own size, same style family as - AtlasNoteCardNode.module.css's own back face -- scaled up (more - padding, a slightly larger note clamp) since a region frame is - usually far larger than a single note card. */ -.backFace { - position: absolute; - inset: 0; - box-sizing: border-box; - border-radius: 14px; - background: var(--bgColor-default); - border: 1.5px solid var(--borderColor-emphasis); - padding: 12px 14px; - display: flex; - flex-direction: column; - gap: 4px; - backface-visibility: hidden; - transform: rotateY(180deg); -} -.backEyebrow { - font-family: var(--mill-mono); - font-size: 9.5px; - text-transform: uppercase; - color: var(--fgColor-muted); -} -.backNote { - font-size: 12px; - line-height: 1.35; - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; -} -.backRow { - font-family: var(--mill-mono); - font-size: 10px; - color: var(--fgColor-muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.openButton { - margin-top: auto; - align-self: flex-start; - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 10.5px; - font-weight: 650; - color: var(--fgColor-accent); - background: transparent; - border: 1px solid var(--fgColor-accent); - border-radius: 4px; - padding: 3px 7px; - cursor: pointer; -} - /* The "+ K more" ghost tile (goal 0073): a note-sized slot with a dashed outline -- deliberately quieter than a real card, it promises content without pretending to be it. */ diff --git a/frontend/src/atlas/AtlasGroupNode.tsx b/frontend/src/atlas/AtlasGroupNode.tsx index 4c007d5e..74e0e421 100644 --- a/frontend/src/atlas/AtlasGroupNode.tsx +++ b/frontend/src/atlas/AtlasGroupNode.tsx @@ -2,8 +2,7 @@ import { memo } from 'react' import { useTranslation } from 'react-i18next' import { Handle, Position as RFPosition } from '@xyflow/react' import type { NodeProps, Node as RFNode } from '@xyflow/react' -import { ArrowUpRightIcon } from '@primer/octicons-react' -import type { Card, Kind, Link, LinkKind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import type { Card } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import { kindColorTokens } from './atlasKindColor' import { NOTE_HEIGHT, NOTE_WIDTH } from './atlasBoardLayout' import type { FreshnessRollup } from './atlasCardPresentation' @@ -11,22 +10,17 @@ import styles from './AtlasGroupNode.module.css' export interface AtlasGroupData extends Record { card: Card - kind: Kind | undefined - allCards: Card[] - links: Link[] - linkKinds: LinkKind[] childCount: number freshness: FreshnessRollup // A ⌘K jump landing on this frame (goal 0072 slice B) -- same // meaning as AtlasNoteCardData's own pulsed/hinted. pulsed: boolean hinted: boolean - // The frame's own back-face flip (goal 0072 slice C item 4, the - // deferred slice-A residual): flipped/onToggleFlip share the SAME - // one-flipped-at-a-time state AtlasBoard already keeps for note - // cards, so opening a frame's back unflips whichever note card (or - // other frame) was flipped before it. - flipped: boolean + // The click model's own commit test (goal 0102's gesture table): + // true when this frame was the sole selected node before the + // current click gesture began -- see useAtlasSelection.ts's own + // header comment. + isSoleSelected: (id: string) => boolean // Semantic zoom (goal 0073): set when the preview cap truncated this // frame's children -- where the "+ K more" ghost tile renders, in // the frame's own coordinate space. @@ -36,44 +30,39 @@ export interface AtlasGroupData extends Record { // live "you'd file into me" affordance. dragHighlighted: boolean onDrill: (id: string) => void - onToggleFlip: (id: string) => void onOpenOverlay: (id: string) => void } export type AtlasGroupRFNode = RFNode -// A region frame (goal 0072 slice A): a card holding cards, drawn as a -// bordered/tinted frame with its own children rendered as separate -// React Flow nodes on top (parentId + extent:'parent', built by -// AtlasBoard) -- this component renders only the frame's own chrome -// (background/border/header), never the children themselves. The -// header is the ONLY drill affordance; a click on the frame's own BODY -// (goal 0072 slice C item 4) flips it in place instead, covering the -// children with a back-face summary -- the header's own onClick stops -// propagation so drilling and flipping never both fire from one click. +// A region frame (goal 0072 slice A, re-cut by goal 0106's flip +// retirement): a card holding cards, drawn as a bordered/tinted frame +// with its own children rendered as separate React Flow nodes on top +// (parentId + extent:'parent', built by AtlasBoard) -- this component +// renders only the frame's own chrome (background/border/header), +// never the children themselves. The header is the ONLY drill +// affordance available unconditionally; the frame's own BODY follows +// the uniform click model (goal 0102): a click on an already-selected +// frame's body commits too (the same zoom the header always offers). export const AtlasGroupNode = memo(function AtlasGroupNode({ data }: NodeProps) { const { t } = useTranslation('atlas') - const { card, kind, allCards, links, linkKinds, childCount, freshness, pulsed, hinted, flipped, overflow, dragHighlighted, onDrill, onToggleFlip, onOpenOverlay } = data + const { card, childCount, freshness, pulsed, hinted, isSoleSelected, overflow, dragHighlighted, onDrill, onOpenOverlay } = data const tokens = kindColorTokens(card.KindID) - const cardLinks = links.filter((l) => l.FromCardID === card.ID || l.ToCardID === card.ID) - const cardByID = new Map(allCards.map((c) => [c.ID, c])) - const linkKindByID = new Map(linkKinds.map((lk) => [lk.ID, lk])) - const firstLink = cardLinks[0] - const firstLinkOther = firstLink ? cardByID.get(firstLink.FromCardID === card.ID ? firstLink.ToCardID : firstLink.FromCardID) : undefined - const firstLinkKind = firstLink ? linkKindByID.get(firstLink.LinkKindID) : undefined const drill = (e: { stopPropagation: () => void }) => { e.stopPropagation() onDrill(card.ID) } - const toggleFlip = () => onToggleFlip(card.ID) - // ⌘-click opens the place's own page directly (the pointer twin of - // ⌘↵, goal 0074) -- plain click keeps flipping. + // The click model (goal 0102's gesture table): shift-click is React + // Flow's own multi-select toggle; ⌘-click opens this place's own + // page directly (the pointer twin of ⌘↵); a plain click on the + // ALREADY-selected frame commits -- a place's commit is zooming in, + // the same drill the header always offers. Every other plain click + // just lets React Flow's own select-and-replace stand. const bodyClick = (e: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean }) => { - // Shift-click is selection (goal 0092) -- never also a flip. if (e.shiftKey) return - if (e.metaKey || e.ctrlKey) onOpenOverlay(card.ID) - else toggleFlip() + if (e.metaKey || e.ctrlKey) { onOpenOverlay(card.ID); return } + if (isSoleSelected(card.ID)) onDrill(card.ID) } return ( @@ -81,25 +70,16 @@ export const AtlasGroupNode = memo(function AtlasGroupNode({ data }: NodeProps { - e.stopPropagation() - onDrill(card.ID) - }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - toggleFlip() + onDrill(card.ID) } }} > @@ -109,84 +89,50 @@ export const AtlasGroupNode = memo(function AtlasGroupNode({ data }: NodeProps - {/* The frame's own body flip (goal 0072 slice C item 4): the same - 3D rotateY the note card uses, applied to the frame's own - chrome only -- the externally-positioned children (separate - React Flow nodes) never rotate, they're simply covered by the - now-opaque back face once it's on top (AtlasBoard's own - zIndex bump on the flipped node, above). */} -
-
-
{ - if (e.key === 'Enter') { - e.preventDefault() - e.stopPropagation() - onDrill(card.ID) - } - }} - > - {card.Title} - {t('board.cardsCount', { count: childCount })} - {freshness.fresh > 0 && ( - - {t('board.freshChip', { count: freshness.fresh })} - - )} - {freshness.stale > 0 && ( - - {t('board.staleChip', { count: freshness.stale })} - - )} - {t('board.zoomChip')} -
- {/* The "+ K more" ghost tile (goal 0073): the preview cap's - honest remainder, occupying the note-sized slot the layout - reserved for it. Clicking it is the same act as the header - -- zoom into the place to see everything. */} - {overflow && ( - - )} -
- -
-
{t('board.flipSideEyebrow', { kind: kind?.Label ?? '' })}
-
{card.Note || card.Title}
-
{t('page.cardsChip', { count: childCount })}
- {firstLink && ( -
- {t('board.linkRow', { kind: firstLinkKind?.Label ?? firstLink.LinkKindID, title: firstLinkOther?.Title ?? '' })} - {cardLinks.length > 1 && ` ${t('board.moreLinks', { count: cardLinks.length - 1 })}`} -
- )} - -
+
{ + if (e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + onDrill(card.ID) + } + }} + > + {card.Title} + {t('board.cardsCount', { count: childCount })} + {freshness.fresh > 0 && ( + + {t('board.freshChip', { count: freshness.fresh })} + + )} + {freshness.stale > 0 && ( + + {t('board.staleChip', { count: freshness.stale })} + + )} + {t('board.zoomChip')}
+ {/* The "+ K more" ghost tile (goal 0073): the preview cap's + honest remainder, occupying the note-sized slot the layout + reserved for it. Clicking it is the same act as the header + -- zoom into the place to see everything. */} + {overflow && ( + + )} {hinted && ( diff --git a/frontend/src/atlas/AtlasNoteCardNode.module.css b/frontend/src/atlas/AtlasNoteCardNode.module.css index 989b4c91..56afcbb9 100644 --- a/frontend/src/atlas/AtlasNoteCardNode.module.css +++ b/frontend/src/atlas/AtlasNoteCardNode.module.css @@ -1,21 +1,28 @@ -/* The book-metaphor flip card (goal 0072 slice A): a fixed 190x128 - footprint, front = cover, back = the working context. 3D rotateY, - instant under prefers-reduced-motion (frontend.md has no existing - motion-reduction precedent to reuse -- this is the surface's own - first animated transform). */ -.flipScene { +/* The uniform note card (goal 0072 slice A, re-cut by goal 0106's + flip retirement): a fixed 190x128 footprint, one face. */ +.card { position: relative; width: 190px; height: 128px; - perspective: 1000px; + box-sizing: border-box; + border: 1px solid var(--borderColor-default); + border-radius: var(--borderRadius-medium); + background: var(--bgColor-default); + display: flex; + flex-direction: column; + padding: 7px 9px 8px; cursor: pointer; + overflow: hidden; +} +.card:hover { + border-color: var(--borderColor-emphasis); } /* Multi-selection outline (owner-caught follow-up to goal 0092): keyed off React Flow's own .selected class on the node's outer wrapper -- box-shadow, not border-width, so it never shifts this card's fixed 190x128 footprint or its own hit-testing box. */ -:global(.react-flow__node.selected) .flipScene { +:global(.react-flow__node.selected) .card { border-radius: var(--borderRadius-medium); box-shadow: 0 0 0 2px var(--borderColor-accent-emphasis), @@ -30,47 +37,7 @@ opacity: 0; pointer-events: none; } -.flipInner { - position: relative; - width: 100%; - height: 100%; - transition: transform 0.5s cubic-bezier(0.4, 0.1, 0.2, 1); - transform-style: preserve-3d; -} -.flipInner[data-flipped='true'] { - transform: rotateY(180deg); -} -@media (prefers-reduced-motion: reduce) { - .flipInner { - transition: none; - } -} -.face { - position: absolute; - inset: 0; - backface-visibility: hidden; - border: 1px solid var(--borderColor-default); - border-radius: var(--borderRadius-medium); - background: var(--bgColor-default); - display: flex; - flex-direction: column; - box-sizing: border-box; - /* The back face's own content (eyebrow/note/source/mirror/slot - rows/Open) can exceed the card's fixed 128px height on a - heavily-linked card -- clipped here rather than spilling past - the card's own border, since AtlasSlotRows already caps its own - row count for this same reason. */ - overflow: hidden; -} -.flipScene:hover .face { - border-color: var(--borderColor-emphasis); -} - -/* Front face */ -.face:first-child { - padding: 7px 9px 8px; -} .frontHeader { display: flex; align-items: center; @@ -164,72 +131,36 @@ padding: 0 4px; } -/* Back face */ -.face:last-child { - transform: rotateY(180deg); - padding: 8px 10px; - gap: 3px; -} -.backEyebrow { - font-family: var(--mill-mono); - font-size: 9px; - text-transform: uppercase; - color: var(--fgColor-muted); -} -.backNote { - font-size: 10.5px; - line-height: 1.3; - overflow: hidden; - display: -webkit-box; - /* 2 lines, not 3 -- the slot rows block below (goal 0081 slice A4) - shares this face's fixed 128px height. */ - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} -.backRow { - font-family: var(--mill-mono); - font-size: 9.5px; - color: var(--fgColor-muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +/* The typed-link hover handle (goal 0106 contract item 1, relocating + the retired flip's own slot-drag anchor): a small tab on the card's + right edge, invisible until the card is hovered or focused so the + calm front face never shows it at rest. Dragging from it starts a + link of the board's default kind (useAtlasSlotDrag). */ +.linkHandle { + position: absolute; + right: -6px; + top: 50%; + transform: translateY(-50%); + width: 12px; + height: 24px; + border-radius: 6px; + background: var(--fgColor-accent); + opacity: 0; + cursor: crosshair; + transition: opacity 0.15s ease-out; } -.openButton { - margin-top: auto; - align-self: flex-start; - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 10px; - font-weight: 650; - color: var(--fgColor-accent); - background: transparent; - border: 1px solid var(--fgColor-accent); - border-radius: 4px; - padding: 2px 6px; - cursor: pointer; +.card:hover .linkHandle, +.card:focus-visible .linkHandle { + opacity: 0.85; } - -/* Touch-target floor (goal 0068's mobile companion pass) -- a no-op - above 767px, where the button's own compact sizing is unchanged. - flex-shrink: 0 is load-bearing: the back face's own column flex - layout otherwise shrinks this item below its explicit min-height - once the card's fixed 128px height is oversubscribed by its other - rows, the standard flexbox "min-height alone doesn't protect a - shrinkable item" gotcha. */ -@media (max-width: 767px) { - .openButton { - min-height: 44px; - min-width: 44px; - flex-shrink: 0; - } +.linkHandle:hover { + opacity: 1; } -/* A ⌘K jump landing on this card (goal 0072 slice B): the pulse ring - sits on .flipScene (not .face) so it stays flat regardless of which - face .flipInner's rotateY currently shows. Reduced motion swaps the - animated ring for a static outline held for the same total window - (AtlasBoard.tsx's PULSE_MS_REDUCED) instead of animating. */ +/* A ⌘K jump landing on this card (goal 0072 slice B): reduced motion + swaps the animated ring for a static outline held for the same + total window (AtlasBoard.tsx's PULSE_MS_REDUCED) instead of + animating. */ @keyframes atlasPulseRing { from { box-shadow: 0 0 0 0 var(--fgColor-accent); @@ -238,12 +169,12 @@ box-shadow: 0 0 0 10px transparent; } } -.flipScene[data-pulse='true'] { +.card[data-pulse='true'] { border-radius: var(--borderRadius-medium); animation: atlasPulseRing 0.6s ease-out 2; } @media (prefers-reduced-motion: reduce) { - .flipScene[data-pulse='true'] { + .card[data-pulse='true'] { animation: none; outline: 2px solid var(--fgColor-accent); outline-offset: 2px; @@ -251,8 +182,7 @@ } /* The transient "press Enter to open" hint (goal 0072 slice B): - positioned above the card, centered -- a sibling of .flipInner so - the flip transform never carries it along. */ + positioned above the card, centered. */ .hintChip { position: absolute; top: -26px; diff --git a/frontend/src/atlas/AtlasNoteCardNode.tsx b/frontend/src/atlas/AtlasNoteCardNode.tsx index 662e976b..8681f8e0 100644 --- a/frontend/src/atlas/AtlasNoteCardNode.tsx +++ b/frontend/src/atlas/AtlasNoteCardNode.tsx @@ -1,16 +1,14 @@ import { memo } from 'react' -import type { PointerEvent as ReactPointerEvent } from 'react' import { useTranslation } from 'react-i18next' import { Handle, Position as RFPosition } from '@xyflow/react' import type { NodeProps, Node as RFNode } from '@xyflow/react' -import { ArrowUpRightIcon } from '@primer/octicons-react' import type { Card, Kind, Link, LinkKind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import { kindColorTokens } from './atlasKindColor' -import { basenameOf, daysSinceSync, deriveFileTag, freshnessDotColor, hostnameOf } from './atlasCardPresentation' +import { deriveFileTag, freshnessDotColor } from './atlasCardPresentation' import { childrenOf } from './atlasGrouping' -import { AtlasSlotRows } from './AtlasSlotRows' -import slotStyles from './AtlasSlotRows.module.css' import styles from './AtlasNoteCardNode.module.css' +import slotStyles from './AtlasSlotRows.module.css' +import type { PointerEvent as ReactPointerEvent } from 'react' export interface AtlasNoteCardData extends Record { card: Card @@ -18,78 +16,73 @@ export interface AtlasNoteCardData extends Record { allCards: Card[] links: Link[] linkKinds: LinkKind[] - flipped: boolean // A ⌘K jump landing on this card (goal 0072 slice B): pulsed drives // the accent ring, hinted shows the transient "press Enter to open" // chip -- both timed and cleared by AtlasBoard, this component only // renders their current on/off state. pulsed: boolean hinted: boolean - onToggleFlip: (id: string) => void - onOpenOverlay: (id: string) => void - // Double-click's commit (goal 0074): unflips, then opens this - // leaf's page -- wired by AtlasBoard so the unflip and the open - // share one state owner. + // The click model's own commit test (goal 0102's gesture table): + // true when this card was the sole selected node before the current + // click gesture began -- see useAtlasSelection.ts's own header + // comment. + isSoleSelected: (id: string) => boolean + // A leaf's commit (goal 0102/0106): opens this card's own page. + // Reached by ⌘-click (instant), or a plain click on an + // already-selected card -- a natural double-click is just those two + // clicks in a row, so no separate dblclick handler exists. onCommit: (id: string) => void - // Typed link slots (goal 0081 slice A4, LOCKED design §3): a - // slot-drag started FROM elsewhere on the board highlights every - // OTHER top-level card -- this one only ever renders true/false, the - // drag state itself lives in useAtlasSlotDrag (AtlasBoard). + // The typed-link slot-drag's own relocated origin (goal 0106 + // contract item 1): a hover-visible handle on the card's right edge, + // dragging from it starts a link of the board's default (first- + // declared) kind -- useAtlasSlotDrag owns the gesture, this + // component only reports where it started. slotDragHighlight: boolean onSlotAnchorPointerDown: (linkKindID: string, e: ReactPointerEvent) => void - onJumpToChip: (cardID: string) => void - onRemoveLink: (linkID: string) => void } export type AtlasNoteCardRFNode = RFNode -// The uniform 190x128 note card (goal 0072 slice A): a book-metaphor -// flip in place on click -- board/camera never moves, exactly one card -// flipped at a time (state lives one level up, in AtlasBoard, so a -// second card's click can unflip the first). Used both as a top-level -// leaf node and as a region frame's own one-level-deep children -// preview -- the same face content either way. +// The uniform 190x128 note card (goal 0072 slice A, re-cut by goal +// 0106's flip retirement): a single face, no back, no in-place rotate. +// Used both as a top-level leaf node and as a region frame's own +// one-level-deep children preview -- the same content either way. export const AtlasNoteCardNode = memo(function AtlasNoteCardNode({ data }: NodeProps) { const { t } = useTranslation('atlas') - const { card, kind, allCards, links, linkKinds, flipped, pulsed, hinted, onToggleFlip, onOpenOverlay, onCommit, slotDragHighlight, onSlotAnchorPointerDown, onJumpToChip, onRemoveLink } = data + const { card, kind, allCards, links, linkKinds, pulsed, hinted, isSoleSelected, onCommit, slotDragHighlight, onSlotAnchorPointerDown } = data const tokens = kindColorTokens(card.KindID) const fileTag = deriveFileTag(card) const dot = freshnessDotColor(card) const childCount = childrenOf(allCards, card.ID).length const cardLinks = links.filter((l) => l.FromCardID === card.ID || l.ToCardID === card.ID) + const defaultLinkKindID = linkKinds[0]?.ID + return (
{ - // Shift-click is selection (goal 0092, React Flow's - // multiSelectionKeyCode) -- never also a glance-flip. if (e.shiftKey) return - if (e.metaKey || e.ctrlKey) onCommit(card.ID) - else onToggleFlip(card.ID) - }} - // The gesture model (goal 0074): click glances, double-click - // commits -- for a leaf, the commit is its page. The commit - // supersedes the glance, so the double-click's own two single - // clicks having toggled the flip doesn't matter: onCommit - // unflips before opening. - onDoubleClick={(e) => { - e.stopPropagation() - onCommit(card.ID) + if (e.metaKey || e.ctrlKey) { onCommit(card.ID); return } + if (isSoleSelected(card.ID)) onCommit(card.ID) }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - onToggleFlip(card.ID) + onCommit(card.ID) } }} > @@ -101,76 +94,44 @@ export const AtlasNoteCardNode = memo(function AtlasNoteCardNode({ data }: NodeP connection. */} -
-
-
- - {(kind?.Label ?? '?').charAt(0).toUpperCase()} - - {kind?.Label ?? ''} - {fileTag && ( - - {fileTag.label} - - )} -
-
{card.Title}
- {card.Note &&
{card.Note}
} -
- {dot && } - {cardLinks.length > 0 && ( - {t('board.linksChip', { count: cardLinks.length })} - )} - {childCount > 0 ? ( - {'▸'} {childCount} - ) : ( - {t('board.leafChip')} - )} -
-
- -
-
{t('board.flipSideEyebrow', { kind: kind?.Label ?? '' })}
-
{card.Note || card.Title}
- {card.Source &&
{t('board.sourceRow', { host: hostnameOf(card.Source) })}
} - {card.MirrorPath && ( -
- {t('board.mirrorRow', { - name: basenameOf(card.MirrorPath), - status: - dot === 'fresh' - ? t('board.mirrorStatusFresh') - : t('board.mirrorStatusStale', { days: daysSinceSync(card.LastSyncedAt) }), - })} -
- )} - - -
+
+ + {(kind?.Label ?? '?').charAt(0).toUpperCase()} + + {kind?.Label ?? ''} + {fileTag && ( + + {fileTag.label} + + )}
+
{card.Title}
+ {card.Note &&
{card.Note}
} +
+ {dot && } + {cardLinks.length > 0 && ( + {t('board.linksChip', { count: cardLinks.length })} + )} + {childCount > 0 ? ( + {'▸'} {childCount} + ) : ( + {t('board.leafChip')} + )} +
+ {defaultLinkKindID && ( + { + e.stopPropagation() + onSlotAnchorPointerDown(defaultLinkKindID, e) + }} + /> + )} {hinted && ( - // A sibling of .flipInner, not a child -- the flip's own 3D - // rotateY transform must never carry this chip along with it, - // whichever face is currently showing. )}
diff --git a/frontend/src/atlas/AtlasRegionChipNode.module.css b/frontend/src/atlas/AtlasRegionChipNode.module.css index 0ef753ee..989ccd38 100644 --- a/frontend/src/atlas/AtlasRegionChipNode.module.css +++ b/frontend/src/atlas/AtlasRegionChipNode.module.css @@ -5,7 +5,10 @@ border: 1.5px solid; border-radius: 10px; cursor: pointer; - perspective: 600px; + padding: 8px 10px; + display: flex; + flex-direction: column; + justify-content: space-between; } .chip:focus-visible { outline: 2px solid var(--focus-outlineColor); @@ -20,35 +23,6 @@ 0 0 0 2px var(--borderColor-accent-emphasis), 0 0 0 5px var(--bgColor-accent-muted); } -.flipInner { - position: relative; - width: 100%; - height: 100%; - transition: transform 0.5s cubic-bezier(0.4, 0.1, 0.2, 1); - transform-style: preserve-3d; -} -.flipInner[data-flipped='true'] { - transform: rotateY(180deg); -} -@media (prefers-reduced-motion: reduce) { - .flipInner { - transition: none; - } -} -.face { - position: absolute; - inset: 0; - box-sizing: border-box; - padding: 8px 10px; - display: flex; - flex-direction: column; - justify-content: space-between; - backface-visibility: hidden; -} -.backFace { - transform: rotateY(180deg); - gap: 2px; -} .title { font-size: 12px; font-weight: 650; @@ -64,30 +38,6 @@ font-size: 10px; color: var(--fgColor-muted); } -.backEyebrow { - font-family: var(--mill-mono); - font-size: 8.5px; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--fgColor-muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.openButton { - align-self: flex-start; - display: inline-flex; - align-items: center; - gap: 3px; - border: 1px solid var(--fgColor-accent); - border-radius: 5px; - background: transparent; - color: var(--fgColor-accent); - font-size: 9.5px; - font-weight: 650; - padding: 1px 6px; - cursor: pointer; -} .hiddenHandle { opacity: 0; pointer-events: none; diff --git a/frontend/src/atlas/AtlasRegionChipNode.tsx b/frontend/src/atlas/AtlasRegionChipNode.tsx index 7cb2cefe..e5f7dd75 100644 --- a/frontend/src/atlas/AtlasRegionChipNode.tsx +++ b/frontend/src/atlas/AtlasRegionChipNode.tsx @@ -2,7 +2,6 @@ import { memo } from 'react' import { useTranslation } from 'react-i18next' import { Handle, Position } from '@xyflow/react' import type { Node, NodeProps } from '@xyflow/react' -import { ArrowUpRightIcon } from '@primer/octicons-react' import type { Card, Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import { kindColorTokens } from './atlasKindColor' import styles from './AtlasRegionChipNode.module.css' @@ -15,21 +14,23 @@ export type AtlasRegionChipRFNode = Node<{ // card's own pulsed) -- attention needs a visible answer even when // the target renders as a place. pulsed: boolean - // Shares AtlasBoard's one-flipped-at-a-time state with notes and - // frames (goal 0074: click glances EVERYWHERE, chips included). - flipped: boolean - onToggleFlip: (id: string) => void + // The click model's own commit test (goal 0102's gesture table): + // true when this chip was the sole selected node before the current + // click gesture began -- see useAtlasSelection.ts's own header + // comment. + isSoleSelected: (id: string) => boolean onOpenOverlay: (id: string) => void onDrill: (id: string) => void }, 'atlas-region-chip'> // A nested area previewed inside its parent's frame (goal 0073 // semantic zoom): a place within a place, drawn as a compact tinted -// chip -- never a full note card, never its own children. Gestures -// follow the uniform model (goal 0074): click flips to a minimal back -// (kind eyebrow, card count, Open), double-click commits -- a place's -// commit is zooming in. Invisible handles exist only so links can -// attach (same constraint the note card documents). +// chip -- never a full note card, never its own children. The click +// model (goal 0102) is uniform: plain click selects/replaces, a +// second click on the already-selected chip commits (a place's +// commit is zooming in), ⌘-click opens its own page directly. +// Invisible handles exist only so links can attach (same constraint +// the note card documents). function AtlasRegionChipNodeInner({ data }: NodeProps) { const { t } = useTranslation('atlas') const tokens = kindColorTokens(data.card.KindID) @@ -43,51 +44,26 @@ function AtlasRegionChipNodeInner({ data }: NodeProps) { }} role="button" tabIndex={0} - aria-label={t('board.flipCardAriaLabel', { title: data.card.Title })} + title={data.kind?.Label} + aria-label={t('board.zoomIntoAriaLabel', { title: data.card.Title })} data-testid="atlas-region-chip" data-pulse={data.pulsed} - data-flipped={data.flipped} onClick={(e) => { - // Shift-click is selection (goal 0092) -- never also a flip. if (e.shiftKey) return - if (e.metaKey || e.ctrlKey) data.onOpenOverlay(data.card.ID) - else data.onToggleFlip(data.card.ID) - }} - onDoubleClick={(e) => { - e.stopPropagation() - data.onDrill(data.card.ID) + if (e.metaKey || e.ctrlKey) { data.onOpenOverlay(data.card.ID); return } + if (data.isSoleSelected(data.card.ID)) data.onDrill(data.card.ID) }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - data.onToggleFlip(data.card.ID) + data.onDrill(data.card.ID) } }} > -
-
- {data.card.Title} - {'▸'} {data.childCount} -
-
- {t('board.flipSideEyebrow', { kind: data.kind?.Label ?? '' })} - {t('page.cardsChip', { count: data.childCount })} - -
-
+ {data.card.Title} + {'▸'} {data.childCount}
) } diff --git a/frontend/src/atlas/AtlasSlotRows.tsx b/frontend/src/atlas/AtlasSlotRows.tsx index 25822593..7bb9e67b 100644 --- a/frontend/src/atlas/AtlasSlotRows.tsx +++ b/frontend/src/atlas/AtlasSlotRows.tsx @@ -1,37 +1,19 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import type { PointerEvent as ReactPointerEvent } from 'react' import { Button, Select } from '@primer/react' import type { Card, Link, LinkKind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import { buildSlotRows } from './atlasSlotRowModel' import styles from './AtlasSlotRows.module.css' -// The flip back face's fixed footprint (goal 0081 slice A4, LOCKED -// design §3/D1.5) only ever fits this many rows before the card grows -// taller than its uniform note size -- capped, not scrolled, with the -// remainder named by the overflow line below. The PAGE variant (goal -// 0081 slice A5) has no such footprint constraint and shows every kind -// (LOCKED design §5b), so the cap only applies to the 'canvas' variant. -const MAX_VISIBLE_ROWS = 2 - // The typed link slots block (goal 0081 slice A4, LOCKED design §3 -// "the slot IS the type"): one row per declared link kind, links-first -// (atlasSlotRowModel.ts), chips identical on both surfaces (LOCKED -// design §2 "chips are the record"). Two variants share this ONE -// component rather than forking -- the row/chip markup is identical -// either way, only the per-row trailing control and the cap differ: -// - 'canvas' (default, the flip back face): capped to -// MAX_VISIBLE_ROWS with an overflow line, trailing a drag ANCHOR -- -// dragging FROM a row starts a link of exactly that row's kind -// (useAtlasSlotDrag owns the gesture; this component only reports -// where it started). -// - 'page' (the card page, no drag surface there): every row shown, -// trailing a card SELECT + Add button (onAddLink) instead of an -// anchor -- the LOCKED design's "each row's add control = a select -// of cards + an Add button (no drag on the page)". +// "the slot IS the type"; the canvas-face variant retired by goal +// 0106 -- the card page is now the ONLY place a link's kind is picked +// or changed directly): one row per declared link kind, links-first +// (atlasSlotRowModel.ts), chips identical to how the board itself +// renders a card's link count. Every row shows, trailing a card +// SELECT + Add button. export function AtlasSlotRows({ - card, allCards, links, linkKinds, onChipClick, onRemoveLink, - variant = 'canvas', onAnchorPointerDown, onAddLink, + card, allCards, links, linkKinds, onChipClick, onRemoveLink, onAddLink, }: { card: Card allCards: Card[] @@ -39,26 +21,21 @@ export function AtlasSlotRows({ linkKinds: LinkKind[] onChipClick: (cardID: string) => void onRemoveLink: (linkID: string) => void - variant?: 'canvas' | 'page' - onAnchorPointerDown?: (linkKindID: string, e: ReactPointerEvent) => void - onAddLink?: (linkKindID: string, toCardID: string) => void + onAddLink: (linkKindID: string, toCardID: string) => void }) { const { t } = useTranslation('atlas') const rows = buildSlotRows(card, allCards, links, linkKinds) - const visible = variant === 'page' ? rows : rows.slice(0, MAX_VISIBLE_ROWS) - const overflowCount = rows.length - visible.length const pickable = allCards.filter((c) => c.ID !== card.ID) const [pendingByRow, setPendingByRow] = useState>({}) if (rows.length === 0) return null return ( -
- {visible.map((row) => ( +
+ {rows.map((row) => (
{row.label} - {row.chips.length === 0 && variant === 'canvas' && {t('board.slotDragToAdd')}} {row.chips.map((chip) => ( - - ) : ( - { - e.stopPropagation() - onAnchorPointerDown?.(row.linkKindID, e) + + + +
))} - {overflowCount > 0 && ( -
- {t('board.slotMoreKinds', { count: overflowCount })} -
- )}
) } diff --git a/frontend/src/atlas/AtlasStickyNode.tsx b/frontend/src/atlas/AtlasStickyNode.tsx index 62660be6..97e8ca5c 100644 --- a/frontend/src/atlas/AtlasStickyNode.tsx +++ b/frontend/src/atlas/AtlasStickyNode.tsx @@ -7,13 +7,18 @@ import styles from './AtlasStickyNode.module.css' export interface AtlasStickyData extends Record { // null for a draft sticky -- not yet persisted; created only once its // text commits non-blank (the LOCKED design's own "empty text on - // blur = cancel, nothing created"). A note NEVER carries a kind chip, - // flip face, or link handle -- structurally annotation, not data. + // blur = cancel, nothing created"). A note NEVER carries a kind chip + // or link handle -- structurally annotation, not data. note: Note | null editing: boolean onCommit: (text: string) => void onCancelEdit: () => void onEnterEdit: () => void + // The click model's own commit test (goal 0102's gesture table, + // uniform across every node type): true when this note was the sole + // selected node before the current click gesture began -- see + // useAtlasSelection.ts's own header comment. + isSoleSelected: (id: string) => boolean } export type AtlasStickyRFNode = RFNode @@ -27,7 +32,7 @@ export type AtlasStickyRFNode = RFNode // creates or updates. export const AtlasStickyNode = memo(function AtlasStickyNode({ data }: NodeProps) { const { t } = useTranslation('atlas') - const { note, editing, onCommit, onCancelEdit, onEnterEdit } = data + const { note, editing, onCommit, onCancelEdit, onEnterEdit, isSoleSelected } = data const [draftText, setDraftText] = useState(note?.Text ?? '') // Guards against a double-fire: Escape (which unmounts this editing // view) must never also let a trailing blur re-commit the same text. @@ -73,12 +78,18 @@ export const AtlasStickyNode = memo(function AtlasStickyNode({ data }: NodeProps role="button" tabIndex={0} aria-label={t('sticky.ariaLabel')} - onDoubleClick={(e) => { - e.stopPropagation() - onEnterEdit() + // The click model (goal 0102's gesture table, uniform across + // every node type): a note's own commit is entering edit -- + // reached by ⌘-click (instant) or a plain click on the + // already-selected note, so two ordinary clicks in a row + // reproduce a double-click's outcome with no separate handler. + onClick={(e) => { + if (e.shiftKey) return + if (e.metaKey || e.ctrlKey) { onEnterEdit(); return } + if (note && isSoleSelected(note.ID)) onEnterEdit() }} onKeyDown={(e) => { - if (e.key === 'Enter') { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onEnterEdit() } diff --git a/frontend/src/atlas/AtlasView.tsx b/frontend/src/atlas/AtlasView.tsx index a3a4cdd5..744f7a95 100644 --- a/frontend/src/atlas/AtlasView.tsx +++ b/frontend/src/atlas/AtlasView.tsx @@ -443,10 +443,6 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { promoteRequest={creationRequests.promoteRequest} groupRequest={creationRequests.groupRequest} onFocusHandled={() => setFocusRequest(null)} - onJumpToChip={(cardID) => { - const target = allCards.find((c) => c.ID === cardID) - if (target) jumpToCard(target, false) - }} /> {undoToast.pending && ( , ): AtlasLinkRFEdge[] { const linkKindByID = new Map(linkKinds.map((lk) => [lk.ID, lk])) return arteries.map((r): AtlasLinkRFEdge => { - const endpointActive = flippedID === r.source || flippedID === r.target const tintToken = linkKindTintToken(r.linkKindIDs[0]) return { id: r.id, @@ -42,7 +33,7 @@ export function buildBoardEdges( label: r.count === 1 ? (linkKindByID.get(r.linkKindIDs[0])?.Label ?? '') : t('board.linksChip', { count: r.count }), style: { stroke: `var(${tintToken})`, strokeWidth: r.count === 1 ? 1.6 : 2.2, opacity: 0.75 }, interactionWidth: 8, - data: { hovered: hoveredEdgeID === r.id || endpointActive }, + data: { hovered: hoveredEdgeID === r.id }, } }) } diff --git a/frontend/src/atlas/atlasBuildBoardNodes.ts b/frontend/src/atlas/atlasBuildBoardNodes.ts index 14281ceb..32a7adeb 100644 --- a/frontend/src/atlas/atlasBuildBoardNodes.ts +++ b/frontend/src/atlas/atlasBuildBoardNodes.ts @@ -22,8 +22,8 @@ export type BoardCardRFNode = AtlasNoteCardRFNode | AtlasGroupRFNode | AtlasRegi // mode; a childless card renders as a flippable note (AtlasNoteCardNode). export function buildBoardCardNodes({ cards, allCards, kinds, links, linkKinds, isFree, readOnly, boardWidth, freeMoves, arteries, - flippedID, pulsedID, hintedID, hoveredFrameID, toggleFlip, onOpenOverlay, handleDrill, handleLeafCommit, - slotDragSourceID, onSlotAnchorPointerDown, onJumpToChip, onRemoveLink, + pulsedID, hintedID, hoveredFrameID, isSoleSelected, onOpenOverlay, handleDrill, + slotDragSourceID, onSlotAnchorPointerDown, }: { cards: Card[] allCards: Card[] @@ -35,23 +35,23 @@ export function buildBoardCardNodes({ boardWidth: number freeMoves: { id: string; x: number; y: number }[] arteries: { source: string; target: string }[] - flippedID: string | null pulsedID: string | null hintedID: string | null // Drag filing's own live release-target affordance (goal 0081 A2): // the frame currently under a dragged card's center, if any. hoveredFrameID: string | null - toggleFlip: (id: string) => void + // The click model's own commit test (goal 0102's gesture table): + // whether a given node id was the SOLE selected node when the + // current click gesture began -- see useAtlasSelection.ts's own + // header comment. + isSoleSelected: (id: string) => boolean onOpenOverlay: (id: string) => void handleDrill: (id: string) => void - handleLeafCommit: (id: string) => void // Slot-drag's own live release-target affordance (goal 0081 A4): the // card a slot-drag started FROM, if any -- every OTHER top-level // card highlights while it's non-null (slice A's all-answer rule). slotDragSourceID: string | null onSlotAnchorPointerDown: (cardID: string, linkKindID: string, e: ReactPointerEvent) => void - onJumpToChip: (cardID: string) => void - onRemoveLink: (linkID: string) => void }): BoardCardRFNode[] { const kindByID = new Map(kinds.map((k) => [k.ID, k])) const adjacency = new Map() @@ -73,16 +73,12 @@ export function buildBoardCardNodes({ allCards, links, linkKinds, - flipped: flippedID === card.ID, pulsed: pulsedID === card.ID, hinted: hintedID === card.ID, + isSoleSelected, slotDragHighlight: slotDragHighlight(card.ID), - onToggleFlip: toggleFlip, - onOpenOverlay, - onCommit: handleLeafCommit, + onCommit: onOpenOverlay, onSlotAnchorPointerDown: (linkKindID: string, e: ReactPointerEvent) => onSlotAnchorPointerDown(card.ID, linkKindID, e), - onJumpToChip, - onRemoveLink, }) for (const card of cards) { @@ -95,7 +91,6 @@ export function buildBoardCardNodes({ if (isGroupCard(allCards, card)) { const frame = computeGroupFrameLayout(allCards, card.ID) const size = isFree ? frame.size : { width: box?.width ?? frame.size.width, height: box?.height ?? frame.size.height } - const groupFlipped = flippedID === card.ID nodes.push({ id: card.ID, type: 'atlas-group', @@ -105,10 +100,6 @@ export function buildBoardCardNodes({ draggable: isFree && !readOnly, data: { card, - kind: kindByID.get(card.KindID), - allCards, - links, - linkKinds, childCount: childrenOf(allCards, card.ID).length, // Roll-up covers EVERY direct child, drawn or capped -- the // pills stay the deep truth regardless of the preview. @@ -116,57 +107,45 @@ export function buildBoardCardNodes({ overflow: frame.overflow, pulsed: pulsedID === card.ID, hinted: hintedID === card.ID, - flipped: groupFlipped, + isSoleSelected, dragHighlighted: hoveredFrameID === card.ID, onDrill: handleDrill, - onToggleFlip: toggleFlip, onOpenOverlay, }, }) - // A flipped frame's own back face must visually and - // interactively cover its own preview children -- React Flow - // always renders a parentId child at parentZ+1 minimum - // (@xyflow/system's own calculateChildXYZ), so a parent node - // can never out-z-index its own children; omitting the - // children entirely while flipped is what actually achieves - // "z above the children," not a z-index that RF's own child - // stacking invariant would silently defeat. - if (!groupFlipped) { - for (const child of frame.children) { - if (child.variant === 'chip') { - nodes.push({ - id: child.card.ID, - type: 'atlas-region-chip', - position: child.position, - width: child.size.width, - height: child.size.height, - parentId: card.ID, - extent: 'parent', - draggable: false, - data: { - card: child.card, - kind: kindByID.get(child.card.KindID), - childCount: childrenOf(allCards, child.card.ID).length, - pulsed: pulsedID === child.card.ID, - flipped: flippedID === child.card.ID, - onToggleFlip: toggleFlip, - onOpenOverlay, - onDrill: handleDrill, - }, - }) - } else { - nodes.push({ - id: child.card.ID, - type: 'atlas-note', - position: child.position, - width: child.size.width, - height: child.size.height, - parentId: card.ID, - extent: 'parent', - draggable: false, - data: noteData(child.card), - }) - } + for (const child of frame.children) { + if (child.variant === 'chip') { + nodes.push({ + id: child.card.ID, + type: 'atlas-region-chip', + position: child.position, + width: child.size.width, + height: child.size.height, + parentId: card.ID, + extent: 'parent', + draggable: false, + data: { + card: child.card, + kind: kindByID.get(child.card.KindID), + childCount: childrenOf(allCards, child.card.ID).length, + pulsed: pulsedID === child.card.ID, + isSoleSelected, + onOpenOverlay, + onDrill: handleDrill, + }, + }) + } else { + nodes.push({ + id: child.card.ID, + type: 'atlas-note', + position: child.position, + width: child.size.width, + height: child.size.height, + parentId: card.ID, + extent: 'parent', + draggable: false, + data: noteData(child.card), + }) } } } else { diff --git a/frontend/src/atlas/atlasFocusContainment.ts b/frontend/src/atlas/atlasFocusContainment.ts new file mode 100644 index 00000000..3ace52b0 --- /dev/null +++ b/frontend/src/atlas/atlasFocusContainment.ts @@ -0,0 +1,24 @@ +import type { RefObject } from 'react' + +// Whether a keyboard event genuinely belongs to the BOARD, not some +// other surface (a Dialog, a popover, an AnchoredOverlay) currently +// holding focus -- shared by every board-level window keydown listener +// (useAtlasKeyboardNav's Tab/Arrows/Enter, useAtlasSelectionTray's +// Escape ladder) so a key meant to close/edit one of those surfaces +// never ALSO fires a board action in the same press (regression: an +// Escape closing the Lens dialog also drilled the board up a level, +// since nothing else excluded it). Two independent checks, since a +// surface can be excluded either way: +// - Portal-rendered surfaces (Primer's Dialog: the card page, Lens, +// Jump, matrix/coverage) mount OUTSIDE the board wrapper's own DOM +// subtree -- activeElement containment catches these. +// - In-wrapper surfaces (AtlasPlacementPopover's AnchoredOverlay, +// a sticky note's own textarea) still pass containment, but their +// own focused control is always an editable target -- callers +// already guard with isEditableTarget separately, which is why +// this helper only needs to check containment, not editability. +export function isFocusInsideBoard(wrapperRef: RefObject): boolean { + const active = document.activeElement + if (!active || active === document.body) return true + return !!wrapperRef.current?.contains(active) +} diff --git a/frontend/src/atlas/atlasKeyboardNavGeometry.test.ts b/frontend/src/atlas/atlasKeyboardNavGeometry.test.ts new file mode 100644 index 00000000..cee0732c --- /dev/null +++ b/frontend/src/atlas/atlasKeyboardNavGeometry.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { nearestInDirection, readingOrder } from './atlasKeyboardNavGeometry' +import type { NavBox } from './atlasKeyboardNavGeometry' + +describe('readingOrder', () => { + it('sorts a single row left-to-right regardless of input order', () => { + const boxes: NavBox[] = [ + { id: 'c', x: 400, y: 0, width: 100, height: 100 }, + { id: 'a', x: 0, y: 0, width: 100, height: 100 }, + { id: 'b', x: 200, y: 0, width: 100, height: 100 }, + ] + expect(readingOrder(boxes).map((b) => b.id)).toEqual(['a', 'b', 'c']) + }) + + it('groups boxes into rows by vertical overlap, then sorts rows top-to-bottom', () => { + const boxes: NavBox[] = [ + { id: 'bottom-right', x: 300, y: 400, width: 100, height: 100 }, + { id: 'top-left', x: 0, y: 0, width: 100, height: 100 }, + { id: 'top-right', x: 300, y: 10, width: 100, height: 100 }, + { id: 'bottom-left', x: 0, y: 410, width: 100, height: 100 }, + ] + expect(readingOrder(boxes).map((b) => b.id)).toEqual(['top-left', 'top-right', 'bottom-left', 'bottom-right']) + }) +}) + +describe('nearestInDirection', () => { + const current: NavBox = { id: 'origin', x: 0, y: 0, width: 100, height: 100 } + + it('picks the nearest candidate whose center lies in the requested direction', () => { + const far: NavBox = { id: 'far-right', x: 600, y: 0, width: 100, height: 100 } + const near: NavBox = { id: 'near-right', x: 300, y: 0, width: 100, height: 100 } + const above: NavBox = { id: 'above', x: 0, y: -600, width: 100, height: 100 } + expect(nearestInDirection(current, [far, near, above], 'right')?.id).toBe('near-right') + }) + + it('excludes a candidate outside the direction cone even if it is the closest overall', () => { + // Directly below current, not to the right at all. + const below: NavBox = { id: 'below', x: 0, y: 300, width: 100, height: 100 } + expect(nearestInDirection(current, [below], 'right')).toBeNull() + }) + + it('returns null when no candidate qualifies', () => { + expect(nearestInDirection(current, [], 'up')).toBeNull() + }) + + it('never returns the current box itself even if ids collide with a same-position candidate', () => { + const self: NavBox = { id: 'origin', x: 0, y: 0, width: 100, height: 100 } + expect(nearestInDirection(current, [self], 'right')).toBeNull() + }) +}) diff --git a/frontend/src/atlas/atlasKeyboardNavGeometry.ts b/frontend/src/atlas/atlasKeyboardNavGeometry.ts new file mode 100644 index 00000000..4f575f35 --- /dev/null +++ b/frontend/src/atlas/atlasKeyboardNavGeometry.ts @@ -0,0 +1,66 @@ +export interface NavBox { id: string; x: number; y: number; width: number; height: number } + +// Reading order for Tab/Shift+Tab (goal 0104's key table): row-major, +// top-to-bottom then left-to-right -- the three-tool convergence the +// table cites. Rows are bucketed by vertical overlap (a box's own +// center falling within another's own vertical span counts as "the +// same row") rather than an exact Y match, since free-mode positions +// are hand-placed and rarely land on a shared pixel row. +export function readingOrder(boxes: NavBox[]): NavBox[] { + const sorted = [...boxes].sort((a, b) => a.y - b.y || a.x - b.x) + const rows: NavBox[][] = [] + for (const box of sorted) { + const cy = box.y + box.height / 2 + const row = rows.find((r) => cy >= r[0].y && cy <= r[0].y + r[0].height) + if (row) row.push(box) + else rows.push([box]) + } + return rows.flatMap((row) => [...row].sort((a, b) => a.x - b.x)) +} + +export type NavDirection = 'up' | 'down' | 'left' | 'right' + +// Option+Arrow's own directional focus (goal 0104's key table, tldraw's +// spatial nav): among every OTHER candidate, keep only the ones whose +// center actually lies in the requested direction from the current +// box's center (a ±45° cone around that axis, so "right" never picks a +// box that's really above), then return the nearest by straight-line +// distance. Returns null when nothing qualifies (the edge of the +// board in that direction). +export function nearestInDirection(current: NavBox, candidates: NavBox[], direction: NavDirection): NavBox | null { + const cx = current.x + current.width / 2 + const cy = current.y + current.height / 2 + let best: NavBox | null = null + let bestDist = Infinity + for (const box of candidates) { + if (box.id === current.id) continue + const bx = box.x + box.width / 2 + const by = box.y + box.height / 2 + const dx = bx - cx + const dy = by - cy + if (dx === 0 && dy === 0) continue + if (!inDirectionCone(dx, dy, direction)) continue + const dist = Math.hypot(dx, dy) + if (dist < bestDist) { + bestDist = dist + best = box + } + } + return best +} + +function inDirectionCone(dx: number, dy: number, direction: NavDirection): boolean { + const angle = Math.atan2(dy, dx) // 0 = right, +90deg (PI/2) = down + const diff = (a: number, b: number) => { + let d = Math.abs(a - b) % (2 * Math.PI) + if (d > Math.PI) d = 2 * Math.PI - d + return d + } + const targets: Record = { + right: 0, + down: Math.PI / 2, + left: Math.PI, + up: -Math.PI / 2, + } + return diff(angle, targets[direction]) <= Math.PI / 4 +} diff --git a/frontend/src/atlas/atlasSlotRowModel.ts b/frontend/src/atlas/atlasSlotRowModel.ts index 985b54a0..315cba02 100644 --- a/frontend/src/atlas/atlasSlotRowModel.ts +++ b/frontend/src/atlas/atlasSlotRowModel.ts @@ -18,7 +18,7 @@ export interface AtlasSlotRow { chips: AtlasSlotChip[] } -// buildSlotRows is the flip back face's own slot-row model (goal 0081 +// buildSlotRows is the card page's own slot-row model (goal 0081 // slice A4, LOCKED design §3): one row per declared link kind, chips // resolved in EITHER direction, ordered links-first so a card with // real relations never buries them under empty rows. Ties (same diff --git a/frontend/src/atlas/atlasStickyNodes.ts b/frontend/src/atlas/atlasStickyNodes.ts index 1d6a4ec0..03235dc4 100644 --- a/frontend/src/atlas/atlasStickyNodes.ts +++ b/frontend/src/atlas/atlasStickyNodes.ts @@ -9,12 +9,15 @@ import type { AtlasStickyRFNode } from './AtlasStickyNode' // own builtNodes memo stays a thin composition of card + sticky nodes // (architecture.md's 500-line convention). export function buildStickyNodes({ - notes, draftNotePos, editingNoteID, readOnly, onCommitDraft, onCancelDraft, onEnterEdit, onCancelEdit, onCommitEdit, + notes, draftNotePos, editingNoteID, readOnly, isSoleSelected, onCommitDraft, onCancelDraft, onEnterEdit, onCancelEdit, onCommitEdit, }: { notes: Note[] draftNotePos: { x: number; y: number } | null editingNoteID: string | null readOnly: boolean + // The click model's own commit test (goal 0102's gesture table) -- + // see useAtlasSelection.ts's own header comment. + isSoleSelected: (id: string) => boolean onCommitDraft: (text: string) => void onCancelDraft: () => void onEnterEdit: (id: string) => void @@ -31,6 +34,7 @@ export function buildStickyNodes({ data: { note, editing: editingNoteID === note.ID, + isSoleSelected, onCommit: (text: string) => onCommitEdit(note.ID, text), onCancelEdit, onEnterEdit: () => onEnterEdit(note.ID), @@ -47,6 +51,7 @@ export function buildStickyNodes({ data: { note: null, editing: true, + isSoleSelected, onCommit: onCommitDraft, onCancelEdit: onCancelDraft, onEnterEdit: () => {}, diff --git a/frontend/src/atlas/useAtlasKeyboardNav.ts b/frontend/src/atlas/useAtlasKeyboardNav.ts new file mode 100644 index 00000000..246c8928 --- /dev/null +++ b/frontend/src/atlas/useAtlasKeyboardNav.ts @@ -0,0 +1,151 @@ +import { useEffect, useRef } from 'react' +import type { RefObject } from 'react' +import type { Node as RFNode, Viewport } from '@xyflow/react' +import type { Card } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import { isEditableTarget } from '../shared/keybinding' +import { AtlasService } from '../shared/bindings' +import { isFocusInsideBoard } from './atlasFocusContainment' +import { nearestInDirection, readingOrder } from './atlasKeyboardNavGeometry' +import type { NavBox, NavDirection } from './atlasKeyboardNavGeometry' + +const NUDGE_STEP = 1 +const NUDGE_STEP_SHIFT = 10 +const PAN_STEP = 60 +const PAN_STEP_SHIFT = 240 + +const ARROW_KEYS: Record = { + ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', +} +const ARROW_DELTA: Record = { + up: { dx: 0, dy: -1 }, down: { dx: 0, dy: 1 }, left: { dx: -1, dy: 0 }, right: { dx: 1, dy: 0 }, +} + +// Atlas keyboard navigation (goal 0104's key table -- one contract with +// 0102's gesture table): arrows nudge the selection or pan the empty +// camera, Tab/Shift+Tab walk top-level nodes in reading order, +// Option+Arrow jumps to the nearest node in that direction, Enter +// commits a single selected card. Escape's own ladder stays where it +// already lived (useAtlasCreation's cancelAll, useAtlasSelectionTray's +// clear-then-up) -- not duplicated here. +// +// A window-level listener, guarded two ways: isEditableTarget (typing +// in any field, including the placement popover's own title input, +// which renders inside this board's wrapper) and a focus-containment +// check (nothing keyboard-trapped in a portal-rendered Dialog/menu +// outside the wrapper) -- so a modal on top of the board keeps owning +// its own Tab/Enter/Arrows untouched. +export function useAtlasKeyboardNav({ + cards, readOnly, wrapperRef, + cardBoxes, noteBoxes, + selectedIDsRef, setNodes, + isGroupCardFn, onOpenOverlay, onDrill, + getViewport, setViewport, +}: { + cards: Card[] + readOnly: boolean + wrapperRef: RefObject + cardBoxes: NavBox[] + noteBoxes: NavBox[] + selectedIDsRef: RefObject + setNodes: (updater: (nodes: TNode[]) => TNode[]) => void + isGroupCardFn: (card: Card) => boolean + onOpenOverlay: (id: string) => void + onDrill: (id: string) => void + getViewport: () => Viewport + setViewport: (vp: Viewport, opts?: { duration?: number }) => void +}) { + const latest = useRef({ cards, readOnly, cardBoxes, noteBoxes, isGroupCardFn, onOpenOverlay, onDrill, getViewport, setViewport }) + useEffect(() => { + latest.current = { cards, readOnly, cardBoxes, noteBoxes, isGroupCardFn, onOpenOverlay, onDrill, getViewport, setViewport } + }) + + // Nudge's own pending-persist accumulator: every held-key repeat + // updates the RENDERED position immediately (setNodes), but the + // Go-side SetPosition/SetNotePosition write is batched to keyup -- + // the drag-equivalent commit point, not a per-pixel round trip. + const pendingNudgeRef = useRef>(new Map()) + + useEffect(() => { + const flushNudge = () => { + for (const [id, pos] of pendingNudgeRef.current) { + if (pos.isNote) void AtlasService.SetNotePosition(id, { X: pos.x, Y: pos.y }).catch(console.error) + else void AtlasService.SetPosition(id, { X: pos.x, Y: pos.y }).catch(console.error) + } + pendingNudgeRef.current = new Map() + } + + const onKeyUp = (e: KeyboardEvent) => { + if (e.key in ARROW_KEYS && pendingNudgeRef.current.size > 0) flushNudge() + } + + const onKeyDown = (e: KeyboardEvent) => { + if (isEditableTarget(e.target)) return + if (!isFocusInsideBoard(wrapperRef)) return + const { cards: c, readOnly: ro, cardBoxes: cb, noteBoxes: nb, isGroupCardFn: isGroup, onOpenOverlay: openOverlay, onDrill: drill, getViewport: getVp, setViewport: setVp } = latest.current + const sel = selectedIDsRef.current + + if (e.key === 'Tab') { + e.preventDefault() + const order = readingOrder([...cb, ...nb]) + if (order.length === 0) return + const anchor = order.find((b) => sel.includes(b.id)) + const from = anchor ? order.indexOf(anchor) : (e.shiftKey ? 0 : -1) + const next = order[(from + (e.shiftKey ? -1 : 1) + order.length) % order.length] + setNodes((nds) => nds.map((node) => ({ ...node, selected: node.id === next.id }))) + return + } + + if (e.key === 'Enter') { + if (sel.length !== 1) return + const card = c.find((cd) => cd.ID === sel[0]) + if (!card) return + e.preventDefault() + if (isGroup(card)) drill(card.ID) + else openOverlay(card.ID) + return + } + + const direction = ARROW_KEYS[e.key] + if (!direction) return + if (e.metaKey || e.ctrlKey) return // atlas.up (⌘↑) owns Cmd/Ctrl+Arrow already + e.preventDefault() + + if (e.altKey) { + const boxes = [...cb, ...nb] + const anchor = boxes.find((b) => sel.includes(b.id)) ?? readingOrder(boxes)[0] + if (!anchor) return + const target = nearestInDirection(anchor, boxes, direction) + if (!target) return + setNodes((nds) => nds.map((node) => ({ ...node, selected: node.id === target.id }))) + return + } + + if (sel.length === 0) { + const step = e.shiftKey ? PAN_STEP_SHIFT : PAN_STEP + const { dx, dy } = ARROW_DELTA[direction] + const vp = getVp() + setVp({ x: vp.x - dx * step, y: vp.y - dy * step, zoom: vp.zoom }) + return + } + + if (ro) return + const step = e.shiftKey ? NUDGE_STEP_SHIFT : NUDGE_STEP + const { dx, dy } = ARROW_DELTA[direction] + const noteIDs = new Set(nb.map((b) => b.id)) + const movedIDs = new Set(sel) + setNodes((nds) => nds.map((node) => { + if (!movedIDs.has(node.id) || node.parentId) return node + const position = { x: node.position.x + dx * step, y: node.position.y + dy * step } + pendingNudgeRef.current.set(node.id, { x: position.x, y: position.y, isNote: noteIDs.has(node.id) }) + return { ...node, position } + })) + } + + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + } + }, [selectedIDsRef, setNodes, wrapperRef]) +} diff --git a/frontend/src/atlas/useAtlasSelection.ts b/frontend/src/atlas/useAtlasSelection.ts index 617585cb..dea5f734 100644 --- a/frontend/src/atlas/useAtlasSelection.ts +++ b/frontend/src/atlas/useAtlasSelection.ts @@ -87,5 +87,16 @@ export function useAtlasSelection({ cards, notes, onMultiSelectContextMenu }: { return openMultiMenu(sel, pos) }, [openMultiMenu]) - return { selectedIDsRef, selectedCards, selectedNotes, onSelectionChange, snapshotSelection, onSelectionContextMenu, tryNodeMultiMenu, clearSelection } + // The click model's own commit test (goal 0102's gesture table, + // "click an already-selected card"): true when this node was the + // SOLE selected node the instant this click gesture began -- the + // same pre-select snapshot onNodeContextMenu reads above, reused so + // two rapid plain clicks (select, then commit) and a real + // double-click land on the identical outcome. + const isSoleSelected = useCallback((id: string): boolean => { + const sel = contextSelectionRef.current + return sel.length === 1 && sel[0] === id + }, []) + + return { selectedIDsRef, selectedCards, selectedNotes, onSelectionChange, snapshotSelection, onSelectionContextMenu, tryNodeMultiMenu, isSoleSelected, clearSelection } } diff --git a/frontend/src/atlas/useAtlasSelectionTray.ts b/frontend/src/atlas/useAtlasSelectionTray.ts index 62b7bd54..2eee383c 100644 --- a/frontend/src/atlas/useAtlasSelectionTray.ts +++ b/frontend/src/atlas/useAtlasSelectionTray.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useRef } from 'react' +import type { RefObject } from 'react' import type { Node } from '@xyflow/react' import { isEditableTarget } from '../shared/keybinding' +import { useAppStore } from '../shared/store' +import { isFocusInsideBoard } from './atlasFocusContainment' // The selection tray's own state glue + keyboard doors (owner-caught // follow-up to goal 0092: a multi-selection had no visible state and @@ -12,18 +15,19 @@ import { isEditableTarget } from '../shared/keybinding' // creation tray's own bare C/N/A (app/useKeymapDispatch.ts), kept // local here rather than in that app-level dispatcher since the // selection it acts on is this board's own state, not a cross-surface -// signal. Escape's selection-clear takes precedence over the board's -// own unflip duty: a live selection is the front-most transient state. +// signal. Escape's own ladder (goal 0102's gesture table): clear +// whatever selection exists, or -- with nothing selected -- go up one +// level (the same signal ⌘↑/atlas.up bumps), never a broken empty +// press at the top. // // The window keydown listener registers exactly ONCE (empty deps) and // reads every value through a ref -- registering it per-dependency- // change (selectedCards/selectedNotes are fresh arrays most renders, // and callers pass inline callbacks) reopened a real gap where a fast // keypress landed between an unsubscribe and the next resubscribe and -// was silently dropped (confirmed live: Escape right after a flip -// intermittently never reached this handler at all). +// was silently dropped. export function useAtlasSelectionTray({ - selectedCards, selectedNotes, clearSelection, setNodes, onDeleteSelection, onGroupSelection, onUnflip, + selectedCards, selectedNotes, clearSelection, setNodes, onDeleteSelection, onGroupSelection, wrapperRef, }: { selectedCards: string[] selectedNotes: string[] @@ -31,19 +35,25 @@ export function useAtlasSelectionTray({ setNodes: (updater: (nodes: TNode[]) => TNode[]) => void onDeleteSelection: (cardIDs: string[], noteIDs: string[]) => void onGroupSelection: (cardIDs: string[], noteIDs: string[], pos: { x: number; y: number }) => void - onUnflip: () => void + // Escape's own ladder must never fire on top of some OTHER surface + // (a Dialog, a popover) that's legitimately consuming the same + // keypress to close/cancel itself -- see atlasFocusContainment.ts's + // own header comment for the regression this guards. + wrapperRef: RefObject }) { const trayRef = useRef(null) - // >=2, not >=1: React Flow selects the clicked node on ANY plain - // click (the flip gesture included), independent of Shift -- >=1 - // would make the tray/Escape-clear fire on every ordinary flip. - // >=2 is the same "real multi-selection" threshold openMultiMenu's - // own sel.length check already uses. + // The tray's own visibility threshold stays >=2 (not >=1): a plain + // click now genuinely selects a single card (goal 0102), and + // flashing the Group/Delete tray for every ordinary single-select + // would be noisier than the "+ Add" creation tray it replaces. + // Escape's own clear-selection rung below uses a separate >=1 test + // -- it clears ANY live selection, not only a 2+ one. const hasSelection = selectedCards.length + selectedNotes.length >= 2 + const anySelected = selectedCards.length + selectedNotes.length >= 1 - const latest = useRef({ selectedCards, selectedNotes, hasSelection, clearSelection, setNodes, onDeleteSelection, onGroupSelection, onUnflip }) + const latest = useRef({ selectedCards, selectedNotes, hasSelection, anySelected, clearSelection, setNodes, onDeleteSelection, onGroupSelection }) useEffect(() => { - latest.current = { selectedCards, selectedNotes, hasSelection, clearSelection, setNodes, onDeleteSelection, onGroupSelection, onUnflip } + latest.current = { selectedCards, selectedNotes, hasSelection, anySelected, clearSelection, setNodes, onDeleteSelection, onGroupSelection } }) // Clears BOTH halves: React Flow's own node.selected flags (so the @@ -71,8 +81,9 @@ export function useAtlasSelectionTray({ useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { - if (latest.current.hasSelection) clearAll() - else latest.current.onUnflip() + if (isEditableTarget(e.target) || !isFocusInsideBoard(wrapperRef)) return + if (latest.current.anySelected) clearAll() + else useAppStore.getState().requestAtlasUp() return } if (e.metaKey || e.ctrlKey || e.altKey || isEditableTarget(e.target)) return @@ -82,7 +93,7 @@ export function useAtlasSelectionTray({ } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [clearAll, groupFromKeyboard]) + }, [clearAll, groupFromKeyboard, wrapperRef]) return { trayRef, hasSelection, onGroup: triggerGroup, onDelete: () => onDeleteSelection(selectedCards, selectedNotes) } } diff --git a/frontend/src/atlas/useAtlasSlotDrag.ts b/frontend/src/atlas/useAtlasSlotDrag.ts index 7421266c..0ace95d5 100644 --- a/frontend/src/atlas/useAtlasSlotDrag.ts +++ b/frontend/src/atlas/useAtlasSlotDrag.ts @@ -74,6 +74,17 @@ export function useAtlasSlotDrag({ const release = { x: e.clientX, y: e.clientY } setDrag((d) => { if (!d) return null + // A browser synthesizes a "click" on the nearest common ancestor + // of the mousedown and mouseup targets -- releasing back onto + // the SAME card (the handle's own parent) makes that ancestor + // the card itself, so its own onClick would otherwise see a + // spurious click right after this drag resolves (goal 0102's + // click model: on an already-selected card, that would wrongly + // COMMIT). A one-shot capture-phase listener swallows exactly + // that one following click, for every release target, not just + // the same-card case -- harmless when no click was going to + // follow anyway. + window.addEventListener('click', (ce) => ce.stopPropagation(), { capture: true, once: true }) const flowPos = screenToFlowRef.current(release) if (hitTest(flowPos, noteBoxesRef.current)) return null // a note is a no-op release target const target = hitTest(flowPos, topLevelBoxesRef.current) diff --git a/frontend/src/locales/en/atlas.json b/frontend/src/locales/en/atlas.json index 386f0877..f0815601 100644 --- a/frontend/src/locales/en/atlas.json +++ b/frontend/src/locales/en/atlas.json @@ -10,7 +10,7 @@ "arrangeAction": "Auto-arrange" }, "board": { - "flipCardAriaLabel": "Flip {{title}}", + "cardAriaLabel": "Open {{title}}", "zoomIntoAriaLabel": "Zoom into {{title}}", "cardsCount_one": "{{count}} card", "cardsCount_other": "{{count}} cards", @@ -18,20 +18,11 @@ "freshChip": "{{count}} fresh", "staleChip": "{{count}} stale", "zoomChip": "Zoom ⤢", - "flipSideEyebrow": "{{kind}} · flip side", - "sourceRow": "source: {{host}}", - "mirrorRow": "mirror: {{name}} · {{status}}", - "mirrorStatusFresh": "fresh", - "mirrorStatusStale": "stale {{days}}d", - "linkRow": "{{kind}} → {{title}}", - "moreLinks": "+{{count}} more", - "open": "Open", "jumpHint": "↵ open", "linksChip_one": "{{count}} link", "linksChip_other": "{{count}} links", "moreTile": "+ {{count}} more", "slotDragToAdd": "drag to add", - "slotMoreKinds": "+{{count}} more kinds", "selectionTrayAriaLabel": "Selection actions", "selectionCount_one": "{{count}} selected", "selectionCount_other": "{{count}} selected", From 7018e6b8945e141a2a67bbd973b664b2daa36d51 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 01:02:25 -0400 Subject: [PATCH 2/6] test: migrate atlas e2e suite off the retired flip gesture (goal 0106 contract item 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- frontend/e2e/atlas-capture.spec.ts | 12 +- frontend/e2e/atlas-containment.spec.ts | 2 +- frontend/e2e/atlas-folder-import.spec.ts | 21 ++-- frontend/e2e/atlas-gestures.spec.ts | 74 +++++++++++-- frontend/e2e/atlas-jump.spec.ts | 2 +- frontend/e2e/atlas-page-edit.spec.ts | 42 ++++--- frontend/e2e/atlas-page-scale.spec.ts | 20 ++-- frontend/e2e/atlas-page.spec.ts | 81 +++++++------- frontend/e2e/atlas-projections.spec.ts | 21 ++-- frontend/e2e/atlas-scale.spec.ts | 28 ++--- frontend/e2e/atlas-select-group.spec.ts | 9 +- frontend/e2e/atlas-session-restore.spec.ts | 4 +- frontend/e2e/atlas-share.spec.ts | 41 +++---- frontend/e2e/atlas-slots.spec.ts | 123 +++++++++++---------- frontend/e2e/atlas.spec.ts | 92 +++++++-------- frontend/e2e/fixtures/animation.ts | 10 +- frontend/e2e/fixtures/atlasBoard.ts | 58 ++++++---- frontend/e2e/fixtures/atlasCards.ts | 7 +- frontend/e2e/mobile.spec.ts | 32 ++---- 19 files changed, 357 insertions(+), 322 deletions(-) diff --git a/frontend/e2e/atlas-capture.spec.ts b/frontend/e2e/atlas-capture.spec.ts index 4e397480..b2075eb2 100644 --- a/frontend/e2e/atlas-capture.spec.ts +++ b/frontend/e2e/atlas-capture.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from './fixtures/server' import type { Page } from '@playwright/test' -import { clickCorner, zoomAllTheWayOut } from './fixtures/atlasBoard' +import { clickCorner, openCard, zoomAllTheWayOut } from './fixtures/atlasBoard' import { deleteViaPageMenu } from './fixtures/atlasPage' // Atlas capture doors (goal 0081 slice A3, LOCKED design §2b/§3b): @@ -65,9 +65,7 @@ test('paste text opens the placement popover prefilled with title and note', asy await expect(card).toContainText('Finish the vendor review before rollout.') // Cleanup (testing.md's within-file/within-worker discipline). - await card.click() - await expect(card).toHaveAttribute('data-flipped', 'true') - await card.getByTestId('atlas-note-open').click() + await openCard(page, card) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() await deleteViaPageMenu(page, overlay) @@ -95,9 +93,7 @@ test('paste HTML converts to Markdown before prefilling the popover', async ({ p await expect(card).toBeVisible() await expect(card).toContainText('Prod credentials never stay with the requester.') - await card.click() - await expect(card).toHaveAttribute('data-flipped', 'true') - await card.getByTestId('atlas-note-open').click() + await openCard(page, card) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() await deleteViaPageMenu(page, overlay) @@ -132,7 +128,7 @@ test('Scratchpad seed is a container card with the inbox guidance note', async ( await page.getByRole('link', { name: 'Atlas' }).click() await expect(page.getByTestId('atlas-board')).toBeVisible() - const scratchpad = page.locator('[data-testid="atlas-note-card"][aria-label="Flip Scratchpad"]') + const scratchpad = page.locator('[data-testid="atlas-note-card"][aria-label="Open Scratchpad"]') await expect(scratchpad).toBeVisible() await expect(scratchpad).toContainText('Quick captures land here. Drag notes out to file them, or promote them into cards.') // Still an ordinary Topic card structurally (containment is a role diff --git a/frontend/e2e/atlas-containment.spec.ts b/frontend/e2e/atlas-containment.spec.ts index a8076685..e836dc85 100644 --- a/frontend/e2e/atlas-containment.spec.ts +++ b/frontend/e2e/atlas-containment.spec.ts @@ -128,7 +128,7 @@ test('atlas containment: area drawing, marker-box grouping, drag filing, dissolv // children. Placements stay >= 0.3 board-width fractions apart -- // a card's own rendered footprint at this zoom level is wide // enough that a tighter gap lands a later click ON the earlier - // card (toggling its flip) instead of on empty canvas. --- + // card (selecting it) instead of on empty canvas. --- await armAndPlaceTopicCard(page, board, popover, 0.25, 0.05, 'ZzC2eMemberA') await armAndPlaceTopicCard(page, board, popover, 0.55, 0.05, 'ZzC2eMemberB') await page.keyboard.press('a') diff --git a/frontend/e2e/atlas-folder-import.spec.ts b/frontend/e2e/atlas-folder-import.spec.ts index 6ef72d6e..6a1ff859 100644 --- a/frontend/e2e/atlas-folder-import.spec.ts +++ b/frontend/e2e/atlas-folder-import.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from './fixtures/server' import { deleteViaPageMenu } from './fixtures/atlasPage' -import { openViaFlip } from './fixtures/atlasBoard' +import { openCard } from './fixtures/atlasBoard' // Synced-folder onboarding (docs/goals/0067) over real Go bindings // (Wails3 server mode): AtlasService.PickFolder's own MILL_TEST_ @@ -19,12 +19,9 @@ import { openViaFlip } from './fixtures/atlasBoard' // container category below assigns the ordinary seeded "Topic" Kind, // not a dedicated container concept. -// Precise per-card matching: a plain hasText substring filter is -// unreliable here since a card's own BACK face can legitimately -// contain another card's title (its own " -> " -// link row) -- aria-label carries the exact title instead. +// Precise per-card matching: aria-label carries the exact title. function noteCard(page: import('@playwright/test').Page, title: string) { - return page.locator(`[data-testid="atlas-note-card"][aria-label="Flip ${title}"]`) + return page.locator(`[data-testid="atlas-note-card"][aria-label="Open ${title}"]`) } function groupCard(page: import('@playwright/test').Page, title: string) { @@ -69,7 +66,7 @@ test('add from folder: scan, partial accept, containment, and mirror rendering a // The rejected entry never becomes a card; every accepted root-level // entry does, under "My space" -- containment for the nested entry // is checked separately below. "Reports" now holds its own imported - // child, so it renders as a region frame, not a flippable leaf note. + // child, so it renders as a region frame, not a plain leaf note. await expect(page.getByTestId('atlas-note-card').filter({ hasText: 'Project Plan' })).toHaveCount(0) const notesCard = noteCard(page, 'Meeting Notes') const logoCard = noteCard(page, 'Logo') @@ -86,7 +83,7 @@ test('add from folder: scan, partial accept, containment, and mirror rendering a await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Reports') const summaryCard = noteCard(page, 'Q1 Summary') await expect(summaryCard).toBeVisible() - await openViaFlip(summaryCard) + await openCard(page, summaryCard) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() await expect(overlay.getByTestId('atlas-page-mirror-path')).toHaveValue(/Reports\/Q1 Summary\.md$/) @@ -95,13 +92,13 @@ test('add from folder: scan, partial accept, containment, and mirror rendering a // Cleanup (testing.md's within-file/within-worker discipline): the // child card must go before its own container can be deleted. Once // "Reports" holds no children, it renders as a plain note card, - // deleted the same flip-then-Open way as every other leaf below. + // deleted the same select-then-commit way as every other leaf below. await deleteViaPageMenu(page, overlay) await expect(summaryCard).not.toBeVisible() await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() for (const card of [noteCard(page, 'Reports'), notesCard, logoCard]) { - await openViaFlip(card) + await openCard(page, card) await expect(overlay).toBeVisible() await deleteViaPageMenu(page, overlay) await expect(overlay).not.toBeVisible() @@ -166,14 +163,14 @@ test('add from folder: an already-imported file stays flagged and default-unchec // this scenario created -- the one just made here, then the first // one back under "My space". const overlay = page.locator('[data-component="atlas-card-overlay"]') - await openViaFlip(secondNotesCard) + await openCard(page, secondNotesCard) await expect(overlay).toBeVisible() await deleteViaPageMenu(page, overlay) await expect(overlay).not.toBeVisible() await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() const firstNotesCard = noteCard(page, 'Meeting Notes') - await openViaFlip(firstNotesCard) + await openCard(page, firstNotesCard) await expect(overlay).toBeVisible() await deleteViaPageMenu(page, overlay) await expect(overlay).not.toBeVisible() diff --git a/frontend/e2e/atlas-gestures.spec.ts b/frontend/e2e/atlas-gestures.spec.ts index 512735a9..bfd0c9f3 100644 --- a/frontend/e2e/atlas-gestures.spec.ts +++ b/frontend/e2e/atlas-gestures.spec.ts @@ -1,19 +1,52 @@ import { test, expect } from './fixtures/server' import { groupCard, noteCard } from './fixtures/atlasCards' +import { clickCorner, zoomAllTheWayOut } from './fixtures/atlasBoard' -// The gesture model (goal 0074) + surface-scoped shortcuts (goal 0071 -// slice): click glances, double-click commits, cmd-click opens, -// cmd-ArrowUp climbs the depth ladder -- split from atlas.spec.ts at -// the 500-line convention, same seam the share/projection groups -// already took. +// The click model (goal 0102's gesture table) + surface-scoped +// shortcuts (goal 0071 slice): plain click selects/replaces, a second +// click on the already-selected node commits (leaf -> page, place -> +// zoom), double-click reproduces the same outcome as two plain clicks, +// shift-click toggles, cmd-click commits instantly, click-empty +// deselects, cmd-ArrowUp climbs the depth ladder -- split from +// atlas.spec.ts at the 500-line convention, same seam the share/ +// projection groups already took. -test('the gesture model: double-click commits -- a leaf opens its page, a frame body and a region chip zoom in, chips flip on single click', async ({ page }) => { +function selectedWrapper(page: import('@playwright/test').Page, card: import('@playwright/test').Locator) { + return page.locator('.react-flow__node.selected').filter({ has: card }) +} + +test('plain click selects (replacing any prior selection); a second click on the already-selected node commits', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(page.getByTestId('atlas-board')).toBeVisible() + + const getting = noteCard(page, 'Getting started') + const scratchpad = noteCard(page, 'Scratchpad') + + await getting.click() + await expect(selectedWrapper(page, getting)).toHaveCount(1) + + // A DIFFERENT card's plain click replaces the selection outright -- + // never a surface pop, never a co-selection. + await scratchpad.click() + await expect(selectedWrapper(page, scratchpad)).toHaveCount(1) + await expect(selectedWrapper(page, getting)).toHaveCount(0) + + // The already-selected card's own second click commits -- a leaf's + // commit is its page. + await scratchpad.click() + const overlay = page.locator('[data-component="atlas-card-overlay"]') + await expect(overlay).toBeVisible() + await expect(overlay.getByTestId('atlas-page-title')).toHaveValue('Scratchpad') + await page.keyboard.press('Escape') + await expect(overlay).not.toBeVisible() +}) + +test('a real double-click reproduces the same select-then-commit outcome as two plain clicks, for both a leaf and a frame body', async ({ page }) => { await page.goto('/') await page.getByRole('link', { name: 'Atlas' }).click() await expect(page.getByTestId('atlas-board')).toBeVisible() - // Leaf double-click = open its page; the commit unflips, so the - // card is front-facing again once the page closes. const getting = noteCard(page, 'Getting started') await getting.dblclick() const overlay = page.locator('[data-component="atlas-card-overlay"]') @@ -21,7 +54,6 @@ test('the gesture model: double-click commits -- a leaf opens its page, a frame await expect(overlay.getByTestId('atlas-page-title')).toHaveValue('Getting started') await page.keyboard.press('Escape') await expect(overlay).not.toBeVisible() - await expect(getting).toHaveAttribute('data-flipped', 'false') // Frame body double-click = zoom into the place (padding strip: // the frame centre belongs to its preview-child nodes). @@ -35,15 +67,33 @@ test('the gesture model: double-click commits -- a leaf opens its page, a frame await expect(page.getByTestId('atlas-breadcrumb')).not.toContainText('Example area') await page.keyboard.press('Meta+ArrowUp') await expect(page.getByTestId('atlas-breadcrumb')).toContainText('My space') +}) + +test('⌘-click commits instantly with no prior selection needed; a plain click on empty canvas deselects', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(page.getByTestId('atlas-board')).toBeVisible() + + const getting = noteCard(page, 'Getting started') + await expect(selectedWrapper(page, getting)).toHaveCount(0) - // ⌘-click = the pointer twin of ⌘↵: opens the card's page with no - // flip step, and the card is front-facing behind it. + // ⌘-click = the pointer twin of ⌘↵: opens the card's page directly, + // with no prior select-click needed. await getting.click({ modifiers: ['Meta'] }) + const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() await expect(overlay.getByTestId('atlas-page-title')).toHaveValue('Getting started') await page.keyboard.press('Escape') await expect(overlay).not.toBeVisible() - await expect(getting).toHaveAttribute('data-flipped', 'false') + + // Click-empty deselects (React Flow's own native pane-click + // behavior) -- select the card fresh first. + await getting.click() + await expect(selectedWrapper(page, getting)).toHaveCount(1) + const board = page.getByTestId('atlas-board') + await zoomAllTheWayOut(page) + await clickCorner(board, 'top-left') + await expect(selectedWrapper(page, getting)).toHaveCount(0) }) test('atlas.up is surface-scoped: listed under "On this page" in the palette on Atlas, absent and inert elsewhere', async ({ page }) => { diff --git a/frontend/e2e/atlas-jump.spec.ts b/frontend/e2e/atlas-jump.spec.ts index 1ae99309..73eefb1b 100644 --- a/frontend/e2e/atlas-jump.spec.ts +++ b/frontend/e2e/atlas-jump.spec.ts @@ -25,7 +25,7 @@ function jumpDialog(page: import('@playwright/test').Page) { } function noteCard(page: import('@playwright/test').Page, title: string) { - return page.locator(`[data-testid="atlas-note-card"][aria-label="Flip ${title}"]`) + return page.locator(`[data-testid="atlas-note-card"][aria-label="Open ${title}"]`) } function groupCard(page: import('@playwright/test').Page, title: string) { diff --git a/frontend/e2e/atlas-page-edit.spec.ts b/frontend/e2e/atlas-page-edit.spec.ts index 32f6c9b2..9c848e16 100644 --- a/frontend/e2e/atlas-page-edit.spec.ts +++ b/frontend/e2e/atlas-page-edit.spec.ts @@ -10,7 +10,7 @@ import { } from './fixtures/server' import { deleteViaPageMenu } from './fixtures/atlasPage' import { ATLAS_KIND_TOPIC, selectKind } from './fixtures/kindPicker' -import { groupCard, noteCard, openViaFlip } from './fixtures/atlasBoard' +import { groupCard, noteCard, openCard } from './fixtures/atlasBoard' import { clickAtFraction } from './fixtures/animation' // Atlas card page read-is-edit + chip navigation (goal 0081 slice A5, @@ -46,12 +46,12 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin const overlay = page.locator('[data-component="atlas-card-overlay"]') // --- Fresh open starts with an empty nav stack: no back button, - // whether reached via the ordinary flip-then-Open door or (the - // same code path) a deep link -- AtlasView never points a NEW - // overlayCardID at an already-open page without unmounting it + // whether reached via the ordinary click-select-then-commit door + // or (the same code path) a deep link -- AtlasView never points a + // NEW overlayCardID at an already-open page without unmounting it // first, so every open is "fresh" from the page's own point of // view. --- - await openViaFlip(noteCard(page, 'Getting started')) + await openCard(page, noteCard(page, 'Getting started')) await expect(overlay).toBeVisible() await expect(overlay.getByTestId('atlas-page-back')).toHaveCount(0) @@ -65,7 +65,7 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin await page.keyboard.press('Escape') await expect(overlay).not.toBeVisible() - await openViaFlip(noteCard(page, 'Getting started (edited)')) + await openCard(page, noteCard(page, 'Getting started (edited)')) await expect(overlay).toBeVisible() await expect(overlay.getByTestId('atlas-page-title')).toHaveValue('Getting started (edited)') @@ -81,7 +81,7 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin await noteField.blur() await expect(overlay.getByTestId('atlas-page-saved-tick')).toBeVisible() await page.keyboard.press('Escape') - await openViaFlip(noteCard(page, 'Getting started')) + await openCard(page, noteCard(page, 'Getting started')) await expect(overlay.getByTestId('atlas-page-note')).toHaveValue('Edited by the e2e suite.') // --- Kind-gated Source/Mirror path (LOCKED design §5b): "Getting @@ -95,7 +95,7 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin // select, Add, and the chip appears; the card's own front-face // links chip (the map's rendering of the same field) goes from // absent to "1 link". --- - await openViaFlip(noteCard(page, 'Scratchpad')) + await openCard(page, noteCard(page, 'Scratchpad')) await expect(overlay).toBeVisible() const addSelect = overlay.locator('[data-testid^="atlas-slot-add-select-"]').first() const addButton = overlay.locator('[data-testid^="atlas-slot-add-button-"]').first() @@ -108,7 +108,7 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin // Cleanup: remove the link this test added via the same chip's × // (testing.md's within-file cleanup discipline). - await openViaFlip(noteCard(page, 'Scratchpad')) + await openCard(page, noteCard(page, 'Scratchpad')) await overlay.getByTestId('atlas-slot-chip').filter({ hasText: 'Getting started' }).getByRole('button', { name: /Remove link/ }).click() await expect(overlay.getByTestId('atlas-slot-chip').filter({ hasText: 'Getting started' })).toHaveCount(0) await page.keyboard.press('Escape') @@ -120,7 +120,7 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin // card in place; a back button shows the previous card's title; // clicking it returns; Esc closes the whole page to the map // regardless of stack depth. --- - await openViaFlip(noteCard(page, 'Getting started')) + await openCard(page, noteCard(page, 'Getting started')) await expect(overlay).toBeVisible() await overlay.getByTestId('atlas-slot-chip').filter({ hasText: 'Ada Lovelace' }).getByRole('button', { name: 'Ada Lovelace', exact: true }).click() await expect(overlay.getByTestId('atlas-page-title')).toHaveValue('Ada Lovelace') @@ -139,17 +139,15 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin // nav.navigate function (not a second copy) -- opening a // container's page, clicking a leaf child row, swaps to that // card's page with a back button naming the container. "Example - // area" is a region frame (a group card, not a leaf note), so it - // opens via its own flip-the-frame-body door, not openViaFlip. - // Re-centered via Fit View first: this many open/close round trips - // drift the board's own camera enough that a card can end up - // clipped past the viewport edge, unclickable at a fixed fraction - // of its own bounding box. --- + // area" is a region frame (a group card, not a leaf note), so its + // own page opens via a ⌘-click on the frame body (goal 0102's + // instant-commit path), not openCard. Re-centered via Fit View + // first: this many open/close round trips drift the board's own + // camera enough that a card can end up clipped past the viewport + // edge, unclickable at a fixed fraction of its own bounding box. --- await page.getByRole('button', { name: 'Fit View' }).click() const exampleAreaFrame = groupCard(page, 'Example area') - await clickAtFraction(exampleAreaFrame, 0.01, 0.5) - await expect(exampleAreaFrame).toHaveAttribute('data-flipped', 'true') - await exampleAreaFrame.getByTestId('atlas-group-open').click() + await clickAtFraction(exampleAreaFrame, 0.01, 0.5, { modifiers: ['Meta'] }) await expect(overlay).toBeVisible() const childRow = overlay.getByTestId('atlas-page-child').filter({ hasText: 'Ada Lovelace' }) await expect(childRow).toBeVisible() @@ -170,7 +168,7 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin // charter" (Document kind, mirror-bearing) starts with a Source // but no MirrorPath. --- await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() - await openViaFlip(noteCard(page, 'Project charter')) + await openCard(page, noteCard(page, 'Project charter')) await expect(overlay).toBeVisible() // --- Actions block (goal 0084): the seeded action row renders @@ -191,7 +189,7 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin await expect(actionRow).toHaveCount(1) await page.keyboard.press('Escape') await expect(overlay).not.toBeVisible() - await openViaFlip(noteCard(page, 'Project charter')) + await openCard(page, noteCard(page, 'Project charter')) await expect(overlay).toBeVisible() await expect(actions.getByTestId('atlas-page-action-row')).toHaveCount(1) @@ -244,7 +242,7 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin await page.getByRole('button', { name: 'Create' }).click() const throwaway = noteCard(page, title) await expect(throwaway).toBeVisible() - await openViaFlip(throwaway) + await openCard(page, throwaway) await expect(overlay).toBeVisible() await deleteViaPageMenu(page, overlay) await expect(overlay).not.toBeVisible() diff --git a/frontend/e2e/atlas-page-scale.spec.ts b/frontend/e2e/atlas-page-scale.spec.ts index 18f70b08..d77c592f 100644 --- a/frontend/e2e/atlas-page-scale.spec.ts +++ b/frontend/e2e/atlas-page-scale.spec.ts @@ -28,7 +28,7 @@ import { // children, ordered by path depth), rather than a manual empty-card // creation followed by a board-level drill: the board has no // affordance to descend into a card before it holds at least one real -// child (a childless card always renders as a flippable note, never a +// child (a childless card always renders as a plain note, never a // region frame), so the ONLY way to land 5 real children under one // freshly-named container is a single import that creates both in the // same call. HumanizeFilename title-cases every word of a scanned @@ -76,16 +76,14 @@ test('a card page at scale caps its entries with an honest expander and lazy-loa const mirrorStack = groupCard(page, 'Mirror Stack') await expect(mirrorStack).toBeVisible() - // Open the page: a region frame's own body click flips it in - // place, then its back face's Open leads to the same full-page - // overlay a leaf's own flip does (atlas-page.spec.ts's own - // established pattern for reaching a group's page). x:6 sits - // inside the frame's left GROUP_PADDING gutter, a blank strip - // running the frame's full height below its header -- safe - // regardless of how many rows the 5 previewed children wrap into. - await mirrorStack.click({ position: { x: 6, y: 60 } }) - await expect(mirrorStack).toHaveAttribute('data-flipped', 'true') - await mirrorStack.getByTestId('atlas-group-open').click() + // Open the page: ⌘-click on the frame's own body opens its page + // directly (goal 0102's gesture table's instant-commit path, + // atlas-page.spec.ts's own established pattern for reaching a + // group's page). x:6 sits inside the frame's left GROUP_PADDING + // gutter, a blank strip running the frame's full height below its + // header -- safe regardless of how many rows the 5 previewed + // children wrap into. + await mirrorStack.click({ position: { x: 6, y: 60 }, modifiers: ['Meta'] }) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() diff --git a/frontend/e2e/atlas-page.spec.ts b/frontend/e2e/atlas-page.spec.ts index 3623f999..f94871bb 100644 --- a/frontend/e2e/atlas-page.spec.ts +++ b/frontend/e2e/atlas-page.spec.ts @@ -1,19 +1,19 @@ import { test, expect } from './fixtures/server' import { deleteViaPageMenu } from './fixtures/atlasPage' import { clickAtFraction } from './fixtures/animation' -import { openViaFlip } from './fixtures/atlasBoard' +import { openCard } from './fixtures/atlasBoard' // Exercises the card PAGE's own ratified anatomy (goal 0072 slice C, // docs/adr/0038): the header row (kind glyph/circle, title, file tag, // Close), the two-column Contents/meta-rail body, and a region frame's -// own body-click flip -- split out of atlas.spec.ts (architecture.md's +// own click model -- split out of atlas.spec.ts (architecture.md's // 500-line convention), same family split atlas-share.spec.ts/ // atlas-jump.spec.ts/atlas-projections.spec.ts already established. // That file's own header covers the shared egocentric-root auto-entry // behavior every test below relies on. function noteCard(page: import('@playwright/test').Page, title: string) { - return page.locator(`[data-testid="atlas-note-card"][aria-label="Flip ${title}"]`) + return page.locator(`[data-testid="atlas-note-card"][aria-label="Open ${title}"]`) } function groupCard(page: import('@playwright/test').Page, title: string) { @@ -26,7 +26,7 @@ test('the page header shows a kind glyph, title, file tag, and Close; the seeded await expect(page.getByTestId('atlas-board')).toBeVisible() const overlay = page.locator('[data-component="atlas-card-overlay"]') - await openViaFlip(noteCard(page, 'Getting started')) + await openCard(page, noteCard(page, 'Getting started')) await expect(overlay).toBeVisible() await expect(overlay.getByTestId('atlas-page-title')).toHaveValue('Getting started') const topicGlyph = overlay.getByTestId('atlas-page-glyph') @@ -39,13 +39,13 @@ test('the page header shows a kind glyph, title, file tag, and Close; the seeded await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Example area') - await openViaFlip(noteCard(page, 'Project charter')) + await openCard(page, noteCard(page, 'Project charter')) await expect(overlay).toBeVisible() await expect(overlay.getByTestId('atlas-page-file-tag')).toHaveText('URL') await page.keyboard.press('Escape') await expect(overlay).not.toBeVisible() - await openViaFlip(noteCard(page, 'Ada Lovelace')) + await openCard(page, noteCard(page, 'Ada Lovelace')) await expect(overlay).toBeVisible() const contactGlyph = overlay.getByTestId('atlas-page-glyph') expect(await contactGlyph.evaluate((el) => getComputedStyle(el).borderRadius)).toBe('50%') @@ -57,7 +57,7 @@ test('the open page is the top layer: app chrome never paints over it and its ba await page.getByRole('link', { name: 'Atlas' }).click() await expect(page.getByTestId('atlas-board')).toBeVisible() - await openViaFlip(noteCard(page, 'Getting started')) + await openCard(page, noteCard(page, 'Getting started')) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() @@ -92,7 +92,7 @@ test('the page\'s links render as slot rows (goal 0081 slice A5), not a second r await page.getByRole('link', { name: 'Atlas' }).click() await expect(page.getByTestId('atlas-board')).toBeVisible() - await openViaFlip(noteCard(page, 'Getting started')) + await openCard(page, noteCard(page, 'Getting started')) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() @@ -108,13 +108,14 @@ test('the page\'s links render as slot rows (goal 0081 slice A5), not a second r await page.keyboard.press('Escape') }) -test('a region frame\'s body click flips it in place; Esc unflips; Open on the back opens the group\'s own page', async ({ page }) => { +test('a region frame\'s body click selects it (never drills); ⌘-click opens the group\'s own page directly; Esc clears the selection', async ({ page }) => { await page.goto('/') await page.getByRole('link', { name: 'Atlas' }).click() await expect(page.getByTestId('atlas-board')).toBeVisible() const exampleArea = groupCard(page, 'Example area') await expect(exampleArea).toBeVisible() + const exampleAreaWrapper = page.locator('.react-flow__node.selected').filter({ has: exampleArea }) // A region frame's own body has one reliably blank strip regardless // of child count or row layout: the left GROUP_PADDING gutter @@ -123,18 +124,18 @@ test('a region frame\'s body click flips it in place; Esc unflips; Open on the b // below the header -- a 1% fraction of width stays inside that gutter // whatever the board's current zoom level scales it to. await clickAtFraction(exampleArea, 0.01, 0.5) - await expect(exampleArea).toHaveAttribute('data-flipped', 'true') - const back = exampleArea.getByTestId('atlas-group-card-back') - await expect(back).toBeVisible() - await expect(back).toContainText('2 cards') + await expect(exampleAreaWrapper).toHaveCount(1) + // The board never re-roots off a plain body click -- the header + // remains the only unconditional drill affordance. + await expect(page.getByTestId('atlas-breadcrumb')).not.toContainText('Example area') await page.keyboard.press('Escape') - await expect(exampleArea).toHaveAttribute('data-flipped', 'false') - - await clickAtFraction(exampleArea, 0.01, 0.5) - await expect(exampleArea).toHaveAttribute('data-flipped', 'true') - await exampleArea.getByTestId('atlas-group-open').click() + await expect(exampleAreaWrapper).toHaveCount(0) + // ⌘-click opens the frame's own page directly (goal 0102's gesture + // table: ⌘-click = instant commit, the pointer twin of ⌘↵) -- + // reached with no prior selection needed. + await clickAtFraction(exampleArea, 0.01, 0.5, { modifiers: ['Meta'] }) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() await expect(overlay.getByTestId('atlas-page-title')).toHaveValue('Example area') @@ -157,7 +158,7 @@ test('a child\'s mirror preview renders inline in the parent page; the card\'s o const charterCard = noteCard(page, 'Project charter') const overlay = page.locator('[data-component="atlas-card-overlay"]') - await openViaFlip(charterCard) + await openCard(page, charterCard) await expect(overlay).toBeVisible() await overlay.getByTestId('atlas-page-mirror-path').fill(file) await overlay.getByTestId('atlas-page-mirror-path').blur() @@ -167,7 +168,7 @@ test('a child\'s mirror preview renders inline in the parent page; the card\'s o // The card's own page: meta rail source/mirror/freshness, each a // read-only summary of a field the fields column above still owns. - await openViaFlip(charterCard) + await openCard(page, charterCard) await expect(overlay).toBeVisible() await expect(overlay.getByTestId('atlas-page-meta-source')).toContainText('example.com') await expect(overlay.getByTestId('atlas-page-meta-mirror')).toContainText('notes.md') @@ -176,12 +177,12 @@ test('a child\'s mirror preview renders inline in the parent page; the card\'s o await expect(overlay).not.toBeVisible() // "Example area"'s own page: "Project charter" appears as a child - // entry with its mirror content rendered inline. + // entry with its mirror content rendered inline. ⌘-click on the + // frame's own body opens its page directly (goal 0102's gesture + // table's instant-commit path). await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() const exampleAreaFrame = groupCard(page, 'Example area') - await clickAtFraction(exampleAreaFrame, 0.01, 0.5) - await expect(exampleAreaFrame.getByTestId('atlas-group-card-back')).toBeVisible() - await exampleAreaFrame.getByTestId('atlas-group-open').click() + await clickAtFraction(exampleAreaFrame, 0.01, 0.5, { modifiers: ['Meta'] }) await expect(overlay).toBeVisible() await expect(overlay.getByTestId('atlas-page-title')).toHaveValue('Example area') const charterEntry = overlay.getByTestId('atlas-page-child').filter({ hasText: 'Project charter' }) @@ -194,7 +195,7 @@ test('a child\'s mirror preview renders inline in the parent page; the card\'s o // into a later test in this same file/worker (testing.md's // within-file cleanup discipline). await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() - await openViaFlip(charterCard) + await openCard(page, charterCard) await overlay.getByTestId('atlas-page-mirror-path').fill('') await overlay.getByTestId('atlas-page-mirror-path').blur() await expect(overlay.getByTestId('atlas-page-saved-tick')).toBeVisible() @@ -268,10 +269,10 @@ test('a group entry inside a page re-roots the board to a deeper card, and the b await expect(l3).toBeVisible() // Back to "My space": L1 is a top-level frame, and L2 -- itself a - // group -- previews inside it as a REGION CHIP (goal 0073), a place - // with no flip face. The path to L2's page is therefore the place - // path: drill into L1 so L2 becomes a top-level frame, flip its - // body, Open. + // group -- previews inside it as a REGION CHIP (goal 0073). The path + // to L2's page is therefore the place path: drill into L1 so L2 + // becomes a top-level frame, ⌘-click its body to open its own page + // directly (goal 0102's gesture table's instant-commit path). await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() const breadcrumb = page.getByTestId('atlas-breadcrumb') await expect(breadcrumb).not.toContainText('Reports') @@ -283,9 +284,7 @@ test('a group entry inside a page re-roots the board to a deeper card, and the b // The frame's centre is covered by its own preview-child nodes // (separate React Flow nodes on top) -- click the frame's own left // padding strip, below the header inset, where only the frame is. - await l2Frame.click({ position: { x: 6, y: 60 } }) - await expect(l2Frame).toHaveAttribute('data-flipped', 'true') - await l2Frame.getByTestId('atlas-group-open').click() + await l2Frame.click({ position: { x: 6, y: 60 }, modifiers: ['Meta'] }) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() @@ -307,8 +306,8 @@ test('a group entry inside a page re-roots the board to a deeper card, and the b // Cleanup (testing.md's within-file discipline): delete bottom-up, // since atlassvc.DeleteCard is blocked while a card still has // children. A chip is a place: clicking it drills straight to L3. - async function deleteViaFlip(card: import('@playwright/test').Locator) { - await openViaFlip(card) + async function deleteViaCommit(card: import('@playwright/test').Locator) { + await openCard(page, card) await deleteViaPageMenu(page, overlay) await expect(overlay).not.toBeVisible() } @@ -324,23 +323,23 @@ test('a group entry inside a page re-roots the board to a deeper card, and the b // into) renders as a frame with its own child ALSO previewed one // level deep, so "Q1 Summary" would match twice (L2's own child and // L3's preview grandchild) rather than the single card this step - // means to flip. + // means to open. await l3Chip.dblclick() await expect(breadcrumbReports).toHaveCount(3) - await deleteViaFlip(noteCard(page, 'Q1 Summary')) + await deleteViaCommit(noteCard(page, 'Q1 Summary')) await page.getByTestId('atlas-breadcrumb').getByText('Reports').nth(1).click() - await deleteViaFlip(noteCard(page, 'Reports')) + await deleteViaCommit(noteCard(page, 'Reports')) // L2's other child (its own Q1 Summary). - await deleteViaFlip(noteCard(page, 'Q1 Summary')) + await deleteViaCommit(noteCard(page, 'Q1 Summary')) // Up to L1: delete L2 (now childless) and L1's own Q1 Summary. await page.getByTestId('atlas-breadcrumb').getByText('Reports').first().click() - await deleteViaFlip(noteCard(page, 'Reports')) - await deleteViaFlip(noteCard(page, 'Q1 Summary')) + await deleteViaCommit(noteCard(page, 'Reports')) + await deleteViaCommit(noteCard(page, 'Q1 Summary')) // Back to "My space": delete L1. await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() - await deleteViaFlip(noteCard(page, 'Reports')) + await deleteViaCommit(noteCard(page, 'Reports')) await expect(breadcrumb).not.toContainText('Reports') }) diff --git a/frontend/e2e/atlas-projections.spec.ts b/frontend/e2e/atlas-projections.spec.ts index 19e0e2bd..1b4afce9 100644 --- a/frontend/e2e/atlas-projections.spec.ts +++ b/frontend/e2e/atlas-projections.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from './fixtures/server' import { deleteViaPageMenu } from './fixtures/atlasPage' import { ATLAS_KIND_DOCUMENT, selectKind } from './fixtures/kindPicker' -import { openViaFlip } from './fixtures/atlasBoard' +import { openCard } from './fixtures/atlasBoard' // Atlas projections (docs/goals/0064, ADR-0038): mirror-content // rendering, the traceability matrix, and coverage -- each proven @@ -10,16 +10,13 @@ import { openViaFlip } from './fixtures/atlasBoard' // egocentric-root auto-entry behavior every test below relies on (the // board is already "My space"'s content on landing, no "My space" // click needed). One-map board (goal 0072 slice A): a card overlay -// opens by flipping a note card then clicking its back's Open button; -// a card holding cards ("Example area") drills via its own region- -// frame header, not a card-body click. - -// Precise per-card matching: a plain hasText substring filter is -// unreliable here since a card's own BACK face can legitimately -// contain another card's title (its own " -> " -// link row) -- aria-label carries the exact title instead. +// opens via the click model's select-then-commit (goal 0102); a card +// holding cards ("Example area") drills via its own region-frame +// header, not a card-body click. + +// Precise per-card matching: aria-label carries the exact title. function noteCard(page: import('@playwright/test').Page, title: string) { - return page.locator(`[data-testid="atlas-note-card"][aria-label="Flip ${title}"]`) + return page.locator(`[data-testid="atlas-note-card"][aria-label="Open ${title}"]`) } function groupCard(page: import('@playwright/test').Page, title: string) { @@ -47,7 +44,7 @@ test('a card with a Mirror path pointing at a markdown file renders its content const newCard = noteCard(page, title) await expect(newCard).toBeVisible() - await openViaFlip(newCard) + await openCard(page, newCard) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() @@ -56,7 +53,7 @@ test('a card with a Mirror path pointing at a markdown file renders its content await expect(overlay.getByTestId('atlas-page-saved-tick')).toBeVisible() await page.keyboard.press('Escape') - await openViaFlip(newCard) + await openCard(page, newCard) await expect(overlay).toBeVisible() await expect(overlay.getByTestId('atlas-mirror-markdown')).toBeVisible() await expect(overlay.getByTestId('atlas-mirror-markdown')).toContainText('Field notes') diff --git a/frontend/e2e/atlas-scale.spec.ts b/frontend/e2e/atlas-scale.spec.ts index 3c70f90b..010063eb 100644 --- a/frontend/e2e/atlas-scale.spec.ts +++ b/frontend/e2e/atlas-scale.spec.ts @@ -92,8 +92,8 @@ test('a dense area previews bounded: capped tiles, region chips, a truthful ghos const topLevel = [ velocity, page.locator('[data-testid="atlas-group-card"]').filter({ has: page.locator('[aria-label="Zoom into Example area"]') }), - page.locator('[aria-label="Flip Getting started"]'), - page.locator('[aria-label="Flip Scratchpad"]'), + page.locator('[aria-label="Open Getting started"]'), + page.locator('[aria-label="Open Scratchpad"]'), ] const rects = [] for (const loc of topLevel) { @@ -120,13 +120,13 @@ test('a dense area previews bounded: capped tiles, region chips, a truthful ghos await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() await expect(page.getByTestId('atlas-breadcrumb')).not.toContainText('Velocity') - // Gesture model (goal 0074): a chip answers a single click like - // every other card -- it flips to its minimal back -- and - // double-click commits, zooming into the place. + // The click model (goal 0102): a chip answers a single click like + // every other node -- it selects, replacing any prior selection -- + // and a real double-click commits, zooming into the place (the + // same outcome two plain clicks in a row produce). const platform = page.locator('[data-testid="atlas-region-chip"]').filter({ hasText: 'Platform' }) await platform.click() - await expect(platform).toHaveAttribute('data-flipped', 'true') - await expect(platform.getByTestId('atlas-region-chip-back')).toContainText('flip side') + await expect(page.locator('.react-flow__node.selected').filter({ has: platform })).toHaveCount(1) await platform.dblclick() await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Platform') await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Velocity') @@ -144,7 +144,7 @@ test('a dense area previews bounded: capped tiles, region chips, a truthful ghos await expect(page.getByTestId('atlas-breadcrumb')).not.toContainText('Platform') await page.getByTestId('atlas-auto-arrange').click() const ea = page.locator('[data-testid="atlas-group-card"]').filter({ has: page.locator('[aria-label="Zoom into Example area"]') }) - const gs = page.locator('[aria-label="Flip Getting started"]') + const gs = page.locator('[aria-label="Open Getting started"]') await expect(ea).toBeVisible() // Poll: the toggle's re-layout lands a paint or two after the // click -- a one-shot sample raced it on CI and measured the @@ -167,8 +167,8 @@ test('a dense area previews bounded: capped tiles, region chips, a truthful ghos expect(gsBox.x).toBeGreaterThan(eaBox.x + eaBox.width - 3) // Card-page-at-scale (goal 0073 slice B): Velocity's own page -- - // reached the same body-click-flip-then-Open way any region - // frame's page is -- caps its entries with an honest expander once + // reached with a ⌘-click on the frame body (goal 0102's instant- + // commit path) -- caps its entries with an honest expander once // density crosses the limit, the same deep counts the header row // and frame preview already summarize but never list in full. // Velocity holds exactly 12 direct children and 0 own links: the @@ -181,9 +181,7 @@ test('a dense area previews bounded: capped tiles, region chips, a truthful ghos // clickAtFraction samples the frame's GROUP_PADDING gutter as a // fraction of its current box instead. await waitForViewportStable(board) - await clickAtFraction(velocity, 0.01, 0.5) - await expect(velocity).toHaveAttribute('data-flipped', 'true') - await velocity.getByTestId('atlas-group-open').click() + await clickAtFraction(velocity, 0.01, 0.5, { modifiers: ['Meta'] }) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() await expect.poll(() => pageChildCount(overlay)).toBe(12) @@ -210,9 +208,7 @@ test('a dense area previews bounded: capped tiles, region chips, a truthful ghos // Past the cap: 11 visible (limit-1) plus an honest "Show 5 more" // -- clicking it renders all 16, the expander gone. await waitForViewportStable(board) - await clickAtFraction(velocity, 0.01, 0.5) - await expect(velocity).toHaveAttribute('data-flipped', 'true') - await velocity.getByTestId('atlas-group-open').click() + await clickAtFraction(velocity, 0.01, 0.5, { modifiers: ['Meta'] }) await expect(overlay).toBeVisible() await expect.poll(() => pageChildCount(overlay)).toBe(11) const showMore = overlay.getByTestId('atlas-page-show-more') diff --git a/frontend/e2e/atlas-select-group.spec.ts b/frontend/e2e/atlas-select-group.spec.ts index bba968eb..a385625a 100644 --- a/frontend/e2e/atlas-select-group.spec.ts +++ b/frontend/e2e/atlas-select-group.spec.ts @@ -194,8 +194,8 @@ test('atlas shift-click select: toggle membership, group via member right-click, const cardB = noteCard(page, 'ZzK2eClickB') const selected = page.locator('.react-flow__node.selected') - // Toggle in, toggle out, toggle back in -- and no glance-flip on - // any of it (the shift guard on the card's own click handler). + // Toggle in, toggle out, toggle back in -- and no commit on any of + // it (the shift guard on the card's own click handler). await cardA.click({ modifiers: ['Shift'] }) await expect(selected).toHaveCount(1) await cardB.click({ modifiers: ['Shift'] }) @@ -224,7 +224,6 @@ test('atlas shift-click select: toggle membership, group via member right-click, await stickyNote.click({ button: 'right' }) await menu.getByText('Delete note', { exact: true }).click() await expect(stickyNote).toHaveCount(0) - await expect(page.locator('[data-testid="atlas-note-card"][data-flipped="true"]')).toHaveCount(0) await cardB.click({ modifiers: ['Shift'] }) await expect(selected).toHaveCount(1) await cardB.click({ modifiers: ['Shift'] }) @@ -250,8 +249,8 @@ test('atlas shift-click select: toggle membership, group via member right-click, await expect(trayDelete).toContainText('Delete') await expect(trayDelete).toContainText('⌫') - // Escape clears the selection (takes precedence over the board's - // own unflip duty) -- the creation tray comes back. + // Escape clears the selection (the ladder's own first rung with a + // live selection, goal 0102) -- the creation tray comes back. await page.keyboard.press('Escape') await expect(selected).toHaveCount(0) await expect(selectionTray).toHaveCount(0) diff --git a/frontend/e2e/atlas-session-restore.spec.ts b/frontend/e2e/atlas-session-restore.spec.ts index ebb80619..acbd85d1 100644 --- a/frontend/e2e/atlas-session-restore.spec.ts +++ b/frontend/e2e/atlas-session-restore.spec.ts @@ -8,7 +8,7 @@ import { spawnMillServer, type SpawnedServer, } from './fixtures/server' -import { groupCard, noteCard, openViaFlip } from './fixtures/atlasBoard' +import { groupCard, noteCard, openCard } from './fixtures/atlasBoard' // Session restore (goal 0091): the viewed level and open card persist // server-side and come back on the next mount. Its OWN server @@ -39,7 +39,7 @@ test('session restore: the viewed level and open card survive a reload (goal 009 await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Example area') - await openViaFlip(noteCard(page, 'Ada Lovelace')) + await openCard(page, noteCard(page, 'Ada Lovelace')) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() diff --git a/frontend/e2e/atlas-share.spec.ts b/frontend/e2e/atlas-share.spec.ts index c846d9dc..c773be64 100644 --- a/frontend/e2e/atlas-share.spec.ts +++ b/frontend/e2e/atlas-share.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from './fixtures/server' import { withClipboardLock } from './fixtures/clipboardLock' +import { openCard } from './fixtures/atlasBoard' // goal 0063's share model -- card overlay + space toolbar share // actions, proven against the seeded "Project charter" card (has a @@ -9,28 +10,24 @@ import { withClipboardLock } from './fixtures/clipboardLock' // 500-line convention) -- that file's own header covers the shared // egocentric-root auto-entry behavior every test below relies on. The // one-map board (goal 0072 slice A) retired the card chip's own quick- -// share menu (no slot in the ratified note-card front/back anatomy) -- -// share now lives only in the card overlay's own Share section, -// reached by flipping the card then clicking its back's Open button, -// and in the space toolbar's Share menu (both proven below). Real -// browser clipboard I/O (Playwright's clipboard-read/clipboard-write -// permissions), so every clipboard-touching section runs inside -// withClipboardLock -- same discipline quick-panel-clipboard-apply. -// spec.ts already established for navigator.clipboard, not just the Go -// osascript/pbcopy adapter. Deliberately never clicks a reveal-in- -// Finder action here: it shells out to the real OS file manager -// (BackupService.RevealBackupFolder's own mechanism, reused by -// RevealSpaceFolder/RevealCardMirror), the same reason goal 0065's own -// "Show in Finder" button has no e2e click coverage either -- Go-level -// tests (atlasservice_share_test.go) cover that behavior instead; e2e -// only asserts the action's presence. +// share menu (no slot in the ratified note-card anatomy) -- share now +// lives only in the card overlay's own Share section, reached by the +// click model's select-then-commit (goal 0102), and in the space +// toolbar's Share menu (both proven below). Real browser clipboard I/O +// (Playwright's clipboard-read/clipboard-write permissions), so every +// clipboard-touching section runs inside withClipboardLock -- same +// discipline quick-panel-clipboard-apply.spec.ts already established +// for navigator.clipboard, not just the Go osascript/pbcopy adapter. +// Deliberately never clicks a reveal-in-Finder action here: it shells +// out to the real OS file manager (BackupService.RevealBackupFolder's +// own mechanism, reused by RevealSpaceFolder/RevealCardMirror), the +// same reason goal 0065's own "Show in Finder" button has no e2e click +// coverage either -- Go-level tests (atlasservice_share_test.go) cover +// that behavior instead; e2e only asserts the action's presence. -// Precise per-card matching: a plain hasText substring filter is -// unreliable here since a card's own BACK face can legitimately -// contain another card's title (its own " -> " -// link row) -- aria-label carries the exact title instead. +// Precise per-card matching: aria-label carries the exact title. function noteCard(page: import('@playwright/test').Page, title: string) { - return page.locator(`[data-testid="atlas-note-card"][aria-label="Flip ${title}"]`) + return page.locator(`[data-testid="atlas-note-card"][aria-label="Open ${title}"]`) } function groupCard(page: import('@playwright/test').Page, title: string) { @@ -58,9 +55,7 @@ test('the card overlay Share section copies context and the cloud link to the cl await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Example area') const charterCard = noteCard(page, 'Project charter') - await charterCard.click() - await expect(charterCard).toHaveAttribute('data-flipped', 'true') - await charterCard.getByTestId('atlas-note-open').click() + await openCard(page, charterCard) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() diff --git a/frontend/e2e/atlas-slots.spec.ts b/frontend/e2e/atlas-slots.spec.ts index e3099d55..a8712a52 100644 --- a/frontend/e2e/atlas-slots.spec.ts +++ b/frontend/e2e/atlas-slots.spec.ts @@ -11,18 +11,19 @@ import { } from './fixtures/server' import { contextMenu } from './fixtures/contextMenu' import { ATLAS_KIND_TOPIC, selectKind } from './fixtures/kindPicker' -import { clickCorner, noteCard, zoomAllTheWayOut } from './fixtures/atlasBoard' +import { clickCorner, closeCard, noteCard, openCard, zoomAllTheWayOut } from './fixtures/atlasBoard' -// Atlas typed link slots (goal 0081 slice A4): the flip back face's -// slot-row block, slot-drag = instant link (release on a card) / no-op -// (release on the same card or a note) / guided-create (release on -// empty canvas), chip removal, quiet edges' hover-only label, and the -// edge/card context menus -- driven end to end against its own -// dedicated server (fixtures/server.ts's ATLAS_SLOTS_* ports), same -// own-server-own-ports reasoning as atlas-authoring/atlas-containment: -// this spec asserts exact edge/chip counts, which the standard -// per-worker pool can't guarantee stays uncontaminated by another spec -// file sharing that worker's server. +// Atlas typed link slots (goal 0081 slice A4, relocated by goal 0106 +// contract item 1): the card page's own slot-row block, slot-drag from +// the card's hover link-handle = instant link of the board's default +// kind (release on a card) / no-op (release on the same card or a +// note) / guided-create (release on empty canvas), chip removal, quiet +// edges' hover-only label, and the edge/card context menus -- driven +// end to end against its own dedicated server (fixtures/server.ts's +// ATLAS_SLOTS_* ports), same own-server-own-ports reasoning as +// atlas-authoring/atlas-containment: this spec asserts exact edge/chip +// counts, which the standard per-worker pool can't guarantee stays +// uncontaminated by another spec file sharing that worker's server. // // A real MirrorPath-bearing card cannot be produced through the UI in // this environment (no seeded card carries one, and setting it needs @@ -53,19 +54,26 @@ async function dragBetween(page: Page, from: { x: number; y: number }, to: { x: } // The seeded space carries only one link kind (relates-to, -// builtin.go), so a card's own slot-row/anchor block always has +// builtin.go), so a card's own slot-row block on its page always has // exactly one row -- selected by its wildcard testid rather than the // kind's own id, which this frontend package never hardcodes -// (ADR-0038 Decision 2). -function slotAnchor(card: Locator): Locator { - return card.locator('[data-testid^="atlas-slot-anchor-"]').first() +// (ADR-0038 Decision 2). Slot rows only ever render on the card PAGE +// now (goal 0106 contract item 1 retired the canvas back face) -- +// `within` is the overlay a card's page renders into. +function slotRow(within: Locator): Locator { + return within.locator('[data-testid^="atlas-slot-row-"]').first() } -function slotRow(card: Locator): Locator { - return card.locator('[data-testid^="atlas-slot-row-"]').first() +// The typed-link slot-drag's own relocated origin: a hover-visible +// handle on the card's right edge (goal 0106 contract item 1) -- +// hover first so it's actually revealed, matching a real drag's own +// entry point, though the raw pointer sequence below would still hit +// it at opacity 0 (CSS-only, not display:none). +function linkHandle(card: Locator): Locator { + return card.getByTestId('atlas-note-link-handle') } // eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture. -test('atlas typed link slots: flip-face rows, slot-drag linking, chip removal, quiet edges, menus', async ({}, testInfo) => { +test('atlas typed link slots: page slot rows, hover-handle slot-drag linking, chip removal, quiet edges, menus', async ({}, testInfo) => { const idx = testInfo.parallelIndex const dir = mkdtempSync(path.join(tmpdir(), `mill-e2e-atlas-slots-${idx}-`)) const settingsPath = path.join(dir, 'settings.json') @@ -109,21 +117,19 @@ test('atlas typed link slots: flip-face rows, slot-drag linking, chip removal, q } const cardA = noteCard(page, 'ZzE2eSlotA') const cardB = noteCard(page, 'ZzE2eSlotB') + const overlay = page.locator('[data-component="atlas-card-overlay"]') - // --- Flip A: the slot rows block renders with a drag-to-add hint --- - await cardA.click() - await expect(cardA).toHaveAttribute('data-flipped', 'true') - await expect(cardA.getByTestId('atlas-slot-rows')).toBeVisible() - await expect(slotRow(cardA)).toContainText('drag to add') + // --- Hovering the card reveals its own link handle (goal 0106 + // contract item 1: the flip's slot-drag origin relocated onto the + // card's own right edge) --- + const handle = linkHandle(cardA) + await expect(handle).toHaveCSS('opacity', '0') + await cardA.hover() + await expect.poll(() => handle.evaluate((el) => getComputedStyle(el).opacity)).not.toBe('0') - // --- Slot-drag onto another card = instant link, no popover --- - // boundingBox() (unlike click()) never waits for CSS stability -- - // the flip's own 0.5s rotateY transition (AtlasNoteCardNode.module. - // css) must settle before the anchor's on-screen coordinates are - // real, and there's no DOM-observable transitionend signal - // Playwright's own waiters can poll here. - await page.waitForTimeout(600) - const anchorBox = await slotAnchor(cardA).boundingBox() + // --- Slot-drag from the handle onto another card = instant link + // of the board's default kind, no popover --- + const anchorBox = await handle.boundingBox() const targetBox = await cardB.boundingBox() if (!anchorBox || !targetBox) throw new Error('missing bounding box') await dragBetween(page, @@ -131,13 +137,17 @@ test('atlas typed link slots: flip-face rows, slot-drag linking, chip removal, q { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 }) await expect(popover).not.toBeVisible() await expect(page.locator('.react-flow__edge')).toHaveCount(seededEdgeCount + 1) - await expect(slotRow(cardA)).toContainText('ZzE2eSlotB') - // --- The chip shows on the OTHER end too, prefixed as incoming --- - await cardB.click() - await expect(cardB).toHaveAttribute('data-flipped', 'true') - await expect(slotRow(cardB)).toContainText('← ZzE2eSlotA') - await cardB.click() // unflip + // --- The chip renders on the card's own page, on both ends -- + // A's own row shows the outgoing link, B's shows it prefixed as + // incoming (relocated off the retired flip back face). --- + await openCard(page, cardA) + await expect(slotRow(overlay)).toContainText('ZzE2eSlotB') + await closeCard(page, overlay) + + await openCard(page, cardB) + await expect(slotRow(overlay)).toContainText('← ZzE2eSlotA') + await closeCard(page, overlay) // --- Quiet edges: label hidden by default, shown on hover --- // Scoped by data-hovered rather than a bare .atlas-link-label @@ -184,21 +194,20 @@ test('atlas typed link slots: flip-face rows, slot-drag linking, chip removal, q await expect(menu).toBeVisible() await menu.getByText('Remove link', { exact: true }).click() await expect(page.locator('.react-flow__edge')).toHaveCount(seededEdgeCount) - // B's own earlier flip (to check its incoming chip) unflipped A -- - // only one card is ever flipped at a time. - await cardA.click() - await expect(cardA).toHaveAttribute('data-flipped', 'true') - await expect(slotRow(cardA)).toContainText('drag to add') - await page.waitForTimeout(600) // flip transition settle, see the comment above anchorBox's own first read + await openCard(page, cardA) + await expect(slotRow(overlay).getByTestId('atlas-slot-chip')).toHaveCount(0) + await closeCard(page, overlay) // --- Chip's own × removal (re-create the link first) --- await dragBetween(page, { x: anchorBox.x + anchorBox.width / 2, y: anchorBox.y + anchorBox.height / 2 }, { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 }) await expect(page.locator('.react-flow__edge')).toHaveCount(seededEdgeCount + 1) - await slotRow(cardA).getByTestId('atlas-slot-chip').locator('button').last().click() + await openCard(page, cardA) + await slotRow(overlay).getByTestId('atlas-slot-chip').locator('button').last().click() await expect(page.locator('.react-flow__edge')).toHaveCount(seededEdgeCount) - await expect(slotRow(cardA)).toContainText('drag to add') + await expect(slotRow(overlay).getByTestId('atlas-slot-chip')).toHaveCount(0) + await closeCard(page, overlay) // --- Release on empty canvas: guided create, card born already linked --- const boardBox = await board.boundingBox() @@ -217,7 +226,9 @@ test('atlas typed link slots: flip-face rows, slot-drag linking, chip removal, q await popover.getByTestId('atlas-placement-submit').click() await expect(popover).not.toBeVisible() await expect(noteCard(page, 'ZzE2eSlotGuided')).toBeVisible() - await expect(slotRow(cardA)).toContainText('ZzE2eSlotGuided') + await openCard(page, cardA) + await expect(slotRow(overlay)).toContainText('ZzE2eSlotGuided') + await closeCard(page, overlay) // --- Release on the same card / a note = no-op --- const sameCardBox = await cardA.boundingBox() @@ -226,10 +237,10 @@ test('atlas typed link slots: flip-face rows, slot-drag linking, chip removal, q { x: anchorBox.x + anchorBox.width / 2, y: anchorBox.y + anchorBox.height / 2 }, { x: sameCardBox.x + sameCardBox.width / 2, y: sameCardBox.y + sameCardBox.height / 2 }) await expect(popover).not.toBeVisible() + await expect(overlay).not.toBeVisible() await expect(page.locator('.react-flow__edge')).toHaveCount(seededEdgeCount + 1) // only the guided-create link from above // --- Card menu: kind-aware ordering + "Add linked card…" --- - await cardA.click() // unflip await cardB.click({ button: 'right' }) await expect(menu).toBeVisible() await expect(menu.getByText('Open file', { exact: true })).toHaveCount(0) @@ -241,16 +252,16 @@ test('atlas typed link slots: flip-face rows, slot-drag linking, chip removal, q await popover.getByTestId('atlas-placement-submit').click() await expect(popover).not.toBeVisible() await expect(noteCard(page, 'ZzE2eSlotAddLinked')).toBeVisible() - await cardB.click() - await expect(cardB).toHaveAttribute('data-flipped', 'true') - await expect(slotRow(cardB)).toContainText('ZzE2eSlotAddLinked') - await cardB.click() + await openCard(page, cardB) + await expect(slotRow(overlay)).toContainText('ZzE2eSlotAddLinked') + await closeCard(page, overlay) - // Region chips (AtlasRegionChipNode) never render AtlasSlotRows at - // all -- verified by source inspection (it's a different node - // component that never imports it), not re-asserted here: the - // seeded space has no nested area to reach one through without - // constructing extra fixture state this spec doesn't otherwise need. + // Region frames/chips never render the hover link-handle at all -- + // verified by source inspection (AtlasGroupNode/AtlasRegionChipNode + // never import AtlasSlotRows or render the handle), not re-asserted + // here: the seeded space has no nested area to reach one through + // without constructing extra fixture state this spec doesn't + // otherwise need. // --- Within-file cleanup (goal 0093: instant, no confirm) --- for (const title of ['ZzE2eSlotA', 'ZzE2eSlotB', 'ZzE2eSlotGuided', 'ZzE2eSlotAddLinked']) { diff --git a/frontend/e2e/atlas.spec.ts b/frontend/e2e/atlas.spec.ts index 9d891ed1..1056f99e 100644 --- a/frontend/e2e/atlas.spec.ts +++ b/frontend/e2e/atlas.spec.ts @@ -1,25 +1,24 @@ import { test, expect } from './fixtures/server' import { deleteViaPageMenu } from './fixtures/atlasPage' import { ATLAS_KIND_TOPIC, selectKind } from './fixtures/kindPicker' -import { openViaFlip } from './fixtures/atlasBoard' +import { openCard } from './fixtures/atlasBoard' // Exercises the Atlas surface's one-map board (docs/adr/0038, // goal 0072 slice A: AtlasShelves retired, every level renders through // AtlasBoard) over real Go bindings (Wails3 server mode): the seeded // root/My space space (Example area, Getting started, Scratchpad) -// proves auto-entry, drill via a region frame's own header, the flip- -// in-place engagement (front glance -> back working context -> Open), -// the explicit sibling-vs-child create flow, the per-space lens, and -// Quick Panel's card search -- the same seeded-example-is-the-proof -// pattern every other e2e spec in this suite follows. Seeded names -// ("My space", "Example area", "Getting started", "Scratchpad", -// "Contact", "Ada Lovelace") are used here to assert against the real -// seed (.claude/rules/testing.md: fine in e2e specs, never in -// frontend/src). With exactly one seeded root card, the surface -// auto-enters it -- every test below already lands on "My space" -// without needing to click it, and the "All spaces" meta-level crumb -// is absent unless a test explicitly creates a second root card. The -// share (goal 0063) and projection (goal 0064) test groups live in +// proves auto-entry, drill via a region frame's own header, the click +// model (select -> commit, goal 0102), the explicit sibling-vs-child +// create flow, the per-space lens, and Quick Panel's card search -- the +// same seeded-example-is-the-proof pattern every other e2e spec in this +// suite follows. Seeded names ("My space", "Example area", "Getting +// started", "Scratchpad", "Contact", "Ada Lovelace") are used here to +// assert against the real seed (.claude/rules/testing.md: fine in e2e +// specs, never in frontend/src). With exactly one seeded root card, the +// surface auto-enters it -- every test below already lands on "My +// space" without needing to click it, and the "All spaces" meta-level +// crumb is absent unless a test explicitly creates a second root card. +// The share (goal 0063) and projection (goal 0064) test groups live in // sibling files, atlas-share.spec.ts and atlas-projections.spec.ts -- // split out to stay under architecture.md's 500-line convention, same // pattern composition.spec.ts/composition-canvas-interactions.spec.ts @@ -29,12 +28,9 @@ function atlasView(page: import('@playwright/test').Page) { return page.getByTestId('atlas-view') } -// Precise per-card matching: a plain hasText substring filter is -// unreliable here since a card's own BACK face can legitimately -// contain another card's title (its own " -> " -// link row) -- aria-label carries the exact title instead. +// Precise per-card matching: aria-label carries the exact title. function noteCard(page: import('@playwright/test').Page, title: string) { - return page.locator(`[data-testid="atlas-note-card"][aria-label="Flip ${title}"]`) + return page.locator(`[data-testid="atlas-note-card"][aria-label="Open ${title}"]`) } function groupCard(page: import('@playwright/test').Page, title: string) { @@ -77,17 +73,17 @@ test('the seeded single root auto-enters "My space"; drilling into a region fram await expect(page.getByTestId('atlas-board')).toBeVisible() // "Example area" holds children -- it renders as a region frame, not - // a flippable leaf note. Its own preview children are separate React - // Flow nodes anchored inside its frame (parentId + extent:'parent'), - // not DOM descendants of the frame's own element. + // a leaf note. Its own preview children are separate React Flow + // nodes anchored inside its frame (parentId + extent:'parent'), not + // DOM descendants of the frame's own element. const exampleArea = groupCard(page, 'Example area') await expect(exampleArea).toBeVisible() await expect(noteCard(page, 'Ada Lovelace')).toBeVisible() await expect(noteCard(page, 'Getting started')).toBeVisible() await expect(noteCard(page, 'Scratchpad')).toBeVisible() - // The header is the only drill affordance on a region frame -- its - // own body never flips. + // The header always drills; a click on the frame's own body follows + // the uniform click model instead (select, then commit-to-zoom). await exampleArea.getByTestId('atlas-group-header').click() await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Example area') await expect(page.getByTestId('atlas-breadcrumb')).toContainText('My space') @@ -130,14 +126,14 @@ test('creating a sibling of the auto-entered root surfaces the "All spaces" meta // within-file cleanup discipline) -- back down to one root card, the // meta level (and its crumb) stop existing again. A childless new // root renders as a plain note card. - await openViaFlip(noteCard(page, title)) + await openCard(page, noteCard(page, title)) const rootOverlay = page.locator('[data-component="atlas-card-overlay"]') await deleteViaPageMenu(page, rootOverlay) await expect(newRootCard).not.toBeVisible() await expect(page.getByTestId('atlas-breadcrumb')).not.toContainText('All spaces') }) -test('the note card front shows kind/title/note/file-tag/presence chips; the back shows source/link/Open', async ({ page }) => { +test('the note card front shows kind/title/note/file-tag/presence chips; the page shows source/link details', async ({ page }) => { await page.goto('/') await page.getByRole('link', { name: 'Atlas' }).click() await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() @@ -152,15 +148,16 @@ test('the note card front shows kind/title/note/file-tag/presence chips; the bac await expect(ada.getByTestId('atlas-note-leaf-chip')).toBeVisible() await expect(ada.getByTestId('atlas-note-links-chip')).toHaveText('2 links') - await charter.click() - await expect(charter).toHaveAttribute('data-flipped', 'true') - await expect(charter.getByTestId('atlas-note-card-back')).toContainText('source: example.com') - await expect(charter.getByTestId('atlas-note-open')).toBeVisible() + // Source/link detail relocated onto the card's own page (goal 0106 + // contract item 1 -- the flip's back face retired). + await openCard(page, charter) + const overlay = page.locator('[data-component="atlas-card-overlay"]') + await expect(overlay.getByTestId('atlas-page-meta-source')).toContainText('example.com') await page.keyboard.press('Escape') - await expect(charter).toHaveAttribute('data-flipped', 'false') + await expect(overlay).not.toBeVisible() }) -test('clicking a card flips it in place without moving the board; a second card unflips the first; Escape unflips', async ({ page }) => { +test('clicking a card selects it (replacing any prior selection) without moving the board; Escape clears the selection', async ({ page }) => { await page.goto('/') await page.getByRole('link', { name: 'Atlas' }).click() await expect(page.getByTestId('atlas-board')).toBeVisible() @@ -169,21 +166,24 @@ test('clicking a card flips it in place without moving the board; a second card const scratchpad = noteCard(page, 'Scratchpad') const boardBefore = await page.getByTestId('atlas-board').boundingBox() + const gettingWrapper = page.locator('.react-flow__node.selected').filter({ has: getting }) + const scratchpadWrapper = page.locator('.react-flow__node.selected').filter({ has: scratchpad }) + await getting.click() - await expect(getting).toHaveAttribute('data-flipped', 'true') - await expect(getting.getByTestId('atlas-note-card-back')).toContainText('flip side') + await expect(gettingWrapper).toHaveCount(1) - // Flipping a second card unflips the first -- exactly one card - // flipped at a time. + // A plain click on a DIFFERENT card replaces the selection -- never + // pops a surface, never leaves both selected (goal 0102's gesture + // table). await scratchpad.click() - await expect(scratchpad).toHaveAttribute('data-flipped', 'true') - await expect(getting).toHaveAttribute('data-flipped', 'false') + await expect(scratchpadWrapper).toHaveCount(1) + await expect(gettingWrapper).toHaveCount(0) const boardAfter = await page.getByTestId('atlas-board').boundingBox() expect(boardAfter).toEqual(boardBefore) await page.keyboard.press('Escape') - await expect(scratchpad).toHaveAttribute('data-flipped', 'false') + await expect(scratchpadWrapper).toHaveCount(0) }) test('arrange is an action: dragging persists a position, Auto-arrange re-seats it (goal 0089)', async ({ page }) => { @@ -203,7 +203,7 @@ test('arrange is an action: dragging persists a position, Auto-arrange re-seats // coords on the node element -- camera-independent, unlike // boundingBox, which shifts with fitView's post-reload camera). await arrange.click() - const adaNode = page.locator('.react-flow__node').filter({ has: page.locator('[aria-label="Flip Ada Lovelace"]') }) + const adaNode = page.locator('.react-flow__node').filter({ has: page.locator('[aria-label="Open Ada Lovelace"]') }) await expect(adaNode).toBeVisible() let before = '' await expect.poll(async () => { @@ -227,7 +227,7 @@ test('Auto-arrange from the command palette runs the same action as the toolbar await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Example area') - const adaNode = page.locator('.react-flow__node').filter({ has: page.locator('[aria-label="Flip Ada Lovelace"]') }) + const adaNode = page.locator('.react-flow__node').filter({ has: page.locator('[aria-label="Open Ada Lovelace"]') }) await expect(adaNode).toBeVisible() await page.keyboard.press('Meta+/') @@ -240,7 +240,7 @@ test('Auto-arrange from the command palette runs the same action as the toolbar await expect.poll(async () => (await adaNode.evaluate((el) => (el as HTMLElement).style.transform)) ?? '').toContain('translate') }) -test('create a child card, edit + persist it via the flip-then-Open overlay, then delete it', async ({ page }) => { +test('create a child card, edit + persist it via the card page, then delete it', async ({ page }) => { const title = 'ZzE2eAtlasChildCard' await page.goto('/') await page.getByRole('link', { name: 'Atlas' }).click() @@ -257,7 +257,7 @@ test('create a child card, edit + persist it via the flip-then-Open overlay, the const newCard = noteCard(page, title) await expect(newCard).toBeVisible() - await openViaFlip(newCard) + await openCard(page, newCard) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() await overlay.getByTestId('atlas-page-note').fill('A note written by the e2e suite.') @@ -266,7 +266,7 @@ test('create a child card, edit + persist it via the flip-then-Open overlay, the await page.keyboard.press('Escape') await expect(overlay).not.toBeVisible() - await openViaFlip(newCard) + await openCard(page, newCard) await expect(page.getByTestId('atlas-page-note')).toHaveValue('A note written by the e2e suite.') // Cleanup: delete the card this test created (testing.md's @@ -368,7 +368,7 @@ test('a sibling card created into a Free-mode space lands clear of both leaf not } // Cleanup (testing.md's within-file discipline). - await openViaFlip(newCard) + await openCard(page, newCard) const siblingOverlay = page.locator('[data-component="atlas-card-overlay"]') await deleteViaPageMenu(page, siblingOverlay) await expect(newCard).not.toBeVisible() @@ -412,7 +412,7 @@ test('Update now on the seeded mirror card runs its workflow through the normal const charterCard = noteCard(page, 'Project charter') await expect(charterCard).toBeVisible() - await openViaFlip(charterCard) + await openCard(page, charterCard) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() await expect(overlay.getByTestId('atlas-overlay-update-now')).toBeVisible() diff --git a/frontend/e2e/fixtures/animation.ts b/frontend/e2e/fixtures/animation.ts index 1e764601..10e441ae 100644 --- a/frontend/e2e/fixtures/animation.ts +++ b/frontend/e2e/fixtures/animation.ts @@ -27,10 +27,12 @@ export async function waitForViewportStable(panel: Locator, timeout = 5_000): Pr // Clicks a locator at a fractional position within its own bounding box // (fx/fy in [0, 1] -- 0.5/0.5 is dead center) rather than Playwright's // default center click, for elements where the center point resolves to -// a different nested element (a card that flips to show its back face, -// say) -- generalized from atlas-page.spec.ts's own clickFrameBody. -export async function clickAtFraction(locator: Locator, fx: number, fy: number): Promise { +// a different nested element (a region frame's own preview children +// covering its body, say) -- generalized from atlas-page.spec.ts's own +// clickFrameBody. `opts` passes through to Locator.click (e.g. +// `{ modifiers: ['Meta'] }` for the click model's instant-commit path). +export async function clickAtFraction(locator: Locator, fx: number, fy: number, opts?: Parameters[0]): Promise { const box = await locator.boundingBox() if (!box) throw new Error('clickAtFraction: expected the element to be measurable') - await locator.click({ position: { x: box.width * fx, y: box.height * fy } }) + await locator.click({ ...opts, position: { x: box.width * fx, y: box.height * fy } }) } diff --git a/frontend/e2e/fixtures/atlasBoard.ts b/frontend/e2e/fixtures/atlasBoard.ts index 89cd0c74..0ae496db 100644 --- a/frontend/e2e/fixtures/atlasBoard.ts +++ b/frontend/e2e/fixtures/atlasBoard.ts @@ -8,7 +8,7 @@ import { ATLAS_KIND_TOPIC, selectKind } from './kindPicker' // plumbing. export function noteCard(page: Page, title: string): Locator { - return page.locator(`[data-testid="atlas-note-card"][aria-label="Flip ${title}"]`) + return page.locator(`[data-testid="atlas-note-card"][aria-label="Open ${title}"]`) } export function groupCard(page: Page, title: string): Locator { @@ -43,29 +43,45 @@ export async function clickCorner(board: Locator, corner: 'top-left' | 'top-righ await board.click({ position }) } -// Flips a note card in place then clicks its back face's Open button -- -// the one path to the card page in the one-map model. Click TOGGLES the -// flip, so this only clicks when the card is currently front-facing -- -// reopening an already-flipped card must not click it back to front -// first. -// -// The flip click is wrapped in a retry (the same expect(...).toPass -// idiom fixtures/canvasNode.ts's clickCanvasNode already established -// for this exact React Flow class): a card's own React Flow node is -// draggable in free/canvas-mode boards, so an occasional native +// Opens a card's own page: the click model (goal 0102) makes a plain +// click SELECT, and a second plain click on the now-selected card +// COMMIT (open) -- this helper plays both clicks, wrapped in the same +// expect(...).toPass retry fixtures/canvasNode.ts's clickCanvasNode +// already established for this exact React Flow class (a card's own +// node is draggable in free-mode boards, so an occasional native // click's mousedown/mouseup pair lands close enough together to read // as a zero-distance micro-drag instead of a click, silently -// swallowing the onClick that would have toggled data-flipped -- -// reproduced directly (not just in a full-suite run) via a throwaway -// repeat-click script, independent of any card-page editing. -export async function openViaFlip(card: Locator): Promise { - await expect(async () => { - if ((await card.getAttribute('data-flipped')) !== 'true') { +// swallowing the selection the second click depends on). +export async function openCard(page: Page, card: Locator): Promise { + const selectedWrapper = page.locator('.react-flow__node.selected').filter({ has: card }) + // A card left selected from an earlier interaction commits on the + // very first click (goal 0102's gesture table) -- only click twice + // when it's starting unselected. + if (await selectedWrapper.count() === 0) { + await expect(async () => { await card.click() - await expect(card).toHaveAttribute('data-flipped', 'true', { timeout: 1_000 }) - } - }).toPass({ timeout: 10_000, intervals: [300] }) - await card.getByTestId('atlas-note-open').click() + await expect(selectedWrapper).toHaveCount(1, { timeout: 1_000 }) + }).toPass({ timeout: 10_000, intervals: [300] }) + } + await card.click() + await expect(page.getByTestId('atlas-page-header')).toBeVisible() +} + +// Closes a card's own page (Escape) and waits for it to be REALLY +// gone, not just its own content -- Primer's Dialog animates its +// backdrop out asynchronously, and a tight open/close/interact cycle +// (this spec's own repeated openCard calls) can outrun that animation, +// leaving a stray backdrop element still covering the board and +// swallowing the very next click as a hit-test miss (reproduced live: +// a right-click immediately after a close landed on +// `.prc-Dialog-Backdrop-*` instead of the card underneath it). Any +// test that closes a card page and immediately does another POINTER +// interaction with the board should use this instead of a bare +// Escape press. +export async function closeCard(page: Page, overlay: Locator): Promise { + await page.keyboard.press('Escape') + await expect(overlay).not.toBeVisible() + await expect(page.locator('[class*="Backdrop"]')).toHaveCount(0) } // Promoted from atlas-containment.spec.ts when atlas-select-group.spec.ts diff --git a/frontend/e2e/fixtures/atlasCards.ts b/frontend/e2e/fixtures/atlasCards.ts index 80645d3a..e85fa3ee 100644 --- a/frontend/e2e/fixtures/atlasCards.ts +++ b/frontend/e2e/fixtures/atlasCards.ts @@ -1,12 +1,9 @@ import type { Locator, Page } from '@playwright/test' // Shared card locators for the atlas spec family. Precise per-card -// matching: a plain hasText substring filter is unreliable since a -// card's own BACK face can legitimately contain another card's title -// (its " -> " link row) -- aria-label carries the -// exact title instead. +// matching: aria-label carries the exact title. export function noteCard(page: Page, title: string): Locator { - return page.locator(`[data-testid="atlas-note-card"][aria-label="Flip ${title}"]`) + return page.locator(`[data-testid="atlas-note-card"][aria-label="Open ${title}"]`) } export function groupCard(page: Page, title: string): Locator { diff --git a/frontend/e2e/mobile.spec.ts b/frontend/e2e/mobile.spec.ts index 4f197e2c..8b99ef9f 100644 --- a/frontend/e2e/mobile.spec.ts +++ b/frontend/e2e/mobile.spec.ts @@ -20,7 +20,7 @@ async function openDrawerAndNavigate(page: import('@playwright/test').Page, link // legitimately contain another card's title (its own " -> " link row) -- aria-label carries the exact title instead. function noteCard(page: import('@playwright/test').Page, title: string) { - return page.locator(`[data-testid="atlas-note-card"][aria-label="Flip ${title}"]`) + return page.locator(`[data-testid="atlas-note-card"][aria-label="Open ${title}"]`) } function groupCard(page: import('@playwright/test').Page, title: string) { @@ -109,30 +109,14 @@ test('Mobile job 4 -- Atlas board glance, drill via a region frame header, and c // scale rounding without accepting a genuinely shrunken target. expect(cardBox?.height ?? 0).toBeGreaterThanOrEqual(43.5) - // Click flips the card in place -- the back's Open affordance is the - // one-map model's own touch target for the full-screen overlay. + // The click model (goal 0102): the first tap selects, the second tap + // on the now-selected card commits -- opening the full-screen + // overlay is the one-map model's own touch target, the card itself + // (already checked >=44px above), never a separate face/button. + await card.click() + const selectedWrapper = page.locator('.react-flow__node.selected').filter({ has: card }) + await expect(selectedWrapper).toHaveCount(1) await card.click() - await expect(card).toHaveAttribute('data-flipped', 'true') - const openButton = card.getByTestId('atlas-note-open') - // The flip is a real CSS rotation: a click launched mid-transition - // trips the actionability check into a scroll-retry loop against - // the transformed board (near-edge cards especially) that never - // re-stabilizes -- poll the button's own box to rest first, the - // same settle-before-acting rule waitForViewportStable applies to - // camera moves. - let prevBox = '' - await expect - .poll(async () => { - const b = await openButton.boundingBox() - const cur = JSON.stringify(b) - const settled = cur !== '' && cur === prevBox - prevBox = cur - return settled - }) - .toBe(true) - const openBox = await openButton.boundingBox() - expect(openBox?.height ?? 0).toBeGreaterThanOrEqual(43.5) - await openButton.click() const overlay = page.getByRole('dialog') await expect(overlay).toBeVisible() From fa633d9d37c25850542d071fea97d82122dbc497 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 01:07:01 -0400 Subject: [PATCH 3/6] test: quarantine atlas-folder-import.spec.ts's own contention-only flake "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 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- frontend/e2e/QUARANTINE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/e2e/QUARANTINE.md b/frontend/e2e/QUARANTINE.md index 0185909d..83c7eea5 100644 --- a/frontend/e2e/QUARANTINE.md +++ b/frontend/e2e/QUARANTINE.md @@ -29,6 +29,7 @@ trace (now auto-captured on retry). | atlas-containment.spec.ts:105 | interaction-race | 2026-08-17 | 2026-09-17 | the Area tool's own marquee draw (a real pointer-capture drag, not React Flow's own node drag) occasionally no-ops on the LAST of several marquees drawn in one long test -- self-heals on retry every time observed; waitForViewportStable already applied at every zoom-changing step, root cause not yet isolated | | workflow-runs-panel.spec.ts:116 | unclear | 2026-08-16 | 2026-09-16 | await first trace | | layout.spec.ts:163 | unclear (contention-only) | 2026-08-16 | 2026-09-16 | fails only under parallel local load | +| atlas-folder-import.spec.ts:113 | unclear (contention-only) | 2026-08-18 | 2026-09-18 | "Add 1 cards" click 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 | | composition-canvas-interactions.spec.ts:25 | interaction-race | 2026-08-17 | 2026-09-17 | clickCanvasNode's own toPass retry loop times out ("element is outside of the viewport" across every retry) selecting the upstream node before the Inspector-composed drag; reproduces in isolation and pre-existing on unmodified main (unrelated to goal 0081 slice A3), not yet traced | | composition-canvas-interactions.spec.ts:92 | CI-only skip | 2026-08-15 | goal 0069's revisit clause | the one honest skip; four fix layers recorded. Reproduced LOCALLY too as of 2026-08-16 (2/2 attempts, on both the pre- and post-0080-burn-down code -- not a burn-down regression), contradicting the in-file comment's "every local mode... passes 10/10"; that comment needs a re-check, not yet done here | From aebb3432beb2dd39569cce7e1c8bd4c6e0c8621c Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 01:23:46 -0400 Subject: [PATCH 4/6] fix: Escape's clear-selection rung raced React Flow's own per-node Escape/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 `` 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 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- frontend/e2e/atlas-gestures.spec.ts | 25 +++++++++++++++++++++ frontend/src/atlas/AtlasBoard.tsx | 16 ++++++++++++- frontend/src/atlas/atlasFocusContainment.ts | 22 ++++++++++++++++++ frontend/src/atlas/useAtlasKeyboardNav.ts | 12 +++++----- frontend/src/atlas/useAtlasSelectionTray.ts | 18 +++++++++------ 5 files changed, 80 insertions(+), 13 deletions(-) diff --git a/frontend/e2e/atlas-gestures.spec.ts b/frontend/e2e/atlas-gestures.spec.ts index bfd0c9f3..c4fdce75 100644 --- a/frontend/e2e/atlas-gestures.spec.ts +++ b/frontend/e2e/atlas-gestures.spec.ts @@ -96,6 +96,31 @@ test('⌘-click commits instantly with no prior selection needed; a plain click await expect(selectedWrapper(page, getting)).toHaveCount(0) }) +test('the Escape ladder: clears a live selection first, then -- with nothing selected -- goes up one level', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(page.getByTestId('atlas-board')).toBeVisible() + + await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() + await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Example area') + + const charter = noteCard(page, 'Project charter') + await charter.click() + await expect(selectedWrapper(page, charter)).toHaveCount(1) + + // First rung: a live selection exists, so Escape clears it and stays + // at this level. + await page.keyboard.press('Escape') + await expect(selectedWrapper(page, charter)).toHaveCount(0) + await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Example area') + + // Second rung: nothing selected, so the SAME key now climbs the + // depth ladder one step -- the same signal ⌘↑ bumps. + await page.keyboard.press('Escape') + await expect(page.getByTestId('atlas-breadcrumb')).not.toContainText('Example area') + await expect(page.getByTestId('atlas-breadcrumb')).toContainText('My space') +}) + test('atlas.up is surface-scoped: listed under "On this page" in the palette on Atlas, absent and inert elsewhere', async ({ page }) => { await page.goto('/') await page.getByRole('link', { name: 'Atlas' }).click() diff --git a/frontend/src/atlas/AtlasBoard.tsx b/frontend/src/atlas/AtlasBoard.tsx index 8cbbcf7f..37c7f0c0 100644 --- a/frontend/src/atlas/AtlasBoard.tsx +++ b/frontend/src/atlas/AtlasBoard.tsx @@ -294,7 +294,7 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare useAtlasKeyboardNav({ cards, readOnly, wrapperRef, cardBoxes: topLevelBoxes, noteBoxes, - selectedIDsRef: selection.selectedIDsRef, setNodes, + setNodes, isGroupCardFn: (card) => isGroupCard(allCards, card), onOpenOverlay, onDrill: handleDrill, getViewport, setViewport, @@ -373,6 +373,20 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare onEdgeMouseLeave={() => setHoveredEdgeID(null)} nodesConnectable={false} deleteKeyCode={null} + // React Flow's own per-node keyboard accessibility (Escape + // unselects the FOCUSED node only, Enter/Space toggles it, + // arrow keys nudge it) is a second, uncoordinated keyboard + // system layered on top of this board's own (useAtlasKeyboardNav, + // useAtlasSelectionTray's Escape ladder, each card's own + // onKeyDown) -- both attached to the same DOM node, both firing + // on the same keydown. Regression: Escape on a focused, + // selected card raced RF's own unselect-then-blur ahead of this + // board's own Escape ladder, so a single press both cleared the + // selection AND climbed a level, since the ladder's own "was + // anything selected" read always found RF had already cleared + // it. Disabled outright -- this board's own hooks are the sole + // keyboard authority. + disableKeyboardA11y // Goal 0092: NOT default Meta -- that made ⌘-click also toggle. multiSelectionKeyCode="Shift" nodesDraggable={isFree && !readOnly} diff --git a/frontend/src/atlas/atlasFocusContainment.ts b/frontend/src/atlas/atlasFocusContainment.ts index 3ace52b0..07ce642a 100644 --- a/frontend/src/atlas/atlasFocusContainment.ts +++ b/frontend/src/atlas/atlasFocusContainment.ts @@ -22,3 +22,25 @@ export function isFocusInsideBoard(wrapperRef: RefObject): b if (!active || active === document.body) return true return !!wrapperRef.current?.contains(active) } + +// The board's own selected node ids, read directly off the DOM's +// `.react-flow__node.selected` wrappers (React Flow's own `data-id` +// convention) rather than the selectedCards/selectedNotes state mirror +// (useAtlasSelection.ts) -- that mirror updates off React Flow's own +// onSelectionChange callback, which can still be mid-flight for one +// more render after the SAME click has already applied the `.selected` +// class (useNodesState's own onNodesChange path, a separate, faster +// mechanism). A keydown handler firing in that gap -- Escape or Enter +// immediately after the click that just selected something -- would +// otherwise read an empty selection. Regression: Escape right after a +// single-card select cleared nothing and climbed a level instead, +// since a single-card selection previously never took this ladder's +// "has a selection" branch under the old >=2 threshold, so the race +// was never reachable before goal 0102's per-card ladder. +export function readSelectedNodeIDs(wrapperRef: RefObject): string[] { + const root = wrapperRef.current + if (!root) return [] + return Array.from(root.querySelectorAll('.react-flow__node.selected')) + .map((el) => el.dataset.id) + .filter((id): id is string => !!id) +} diff --git a/frontend/src/atlas/useAtlasKeyboardNav.ts b/frontend/src/atlas/useAtlasKeyboardNav.ts index 246c8928..2d6ed2ad 100644 --- a/frontend/src/atlas/useAtlasKeyboardNav.ts +++ b/frontend/src/atlas/useAtlasKeyboardNav.ts @@ -4,7 +4,7 @@ import type { Node as RFNode, Viewport } from '@xyflow/react' import type { Card } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import { isEditableTarget } from '../shared/keybinding' import { AtlasService } from '../shared/bindings' -import { isFocusInsideBoard } from './atlasFocusContainment' +import { isFocusInsideBoard, readSelectedNodeIDs } from './atlasFocusContainment' import { nearestInDirection, readingOrder } from './atlasKeyboardNavGeometry' import type { NavBox, NavDirection } from './atlasKeyboardNavGeometry' @@ -37,7 +37,7 @@ const ARROW_DELTA: Record = { export function useAtlasKeyboardNav({ cards, readOnly, wrapperRef, cardBoxes, noteBoxes, - selectedIDsRef, setNodes, + setNodes, isGroupCardFn, onOpenOverlay, onDrill, getViewport, setViewport, }: { @@ -46,7 +46,6 @@ export function useAtlasKeyboardNav({ wrapperRef: RefObject cardBoxes: NavBox[] noteBoxes: NavBox[] - selectedIDsRef: RefObject setNodes: (updater: (nodes: TNode[]) => TNode[]) => void isGroupCardFn: (card: Card) => boolean onOpenOverlay: (id: string) => void @@ -82,7 +81,10 @@ export function useAtlasKeyboardNav({ if (isEditableTarget(e.target)) return if (!isFocusInsideBoard(wrapperRef)) return const { cards: c, readOnly: ro, cardBoxes: cb, noteBoxes: nb, isGroupCardFn: isGroup, onOpenOverlay: openOverlay, onDrill: drill, getViewport: getVp, setViewport: setVp } = latest.current - const sel = selectedIDsRef.current + // Read the DOM directly, not a selection-state mirror -- see + // readSelectedNodeIDs' own header comment for the render-order + // gap a fresh click's own next keypress can otherwise hit. + const sel = readSelectedNodeIDs(wrapperRef) if (e.key === 'Tab') { e.preventDefault() @@ -147,5 +149,5 @@ export function useAtlasKeyboardNav({ window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } - }, [selectedIDsRef, setNodes, wrapperRef]) + }, [setNodes, wrapperRef]) } diff --git a/frontend/src/atlas/useAtlasSelectionTray.ts b/frontend/src/atlas/useAtlasSelectionTray.ts index 2eee383c..76b11419 100644 --- a/frontend/src/atlas/useAtlasSelectionTray.ts +++ b/frontend/src/atlas/useAtlasSelectionTray.ts @@ -3,7 +3,7 @@ import type { RefObject } from 'react' import type { Node } from '@xyflow/react' import { isEditableTarget } from '../shared/keybinding' import { useAppStore } from '../shared/store' -import { isFocusInsideBoard } from './atlasFocusContainment' +import { isFocusInsideBoard, readSelectedNodeIDs } from './atlasFocusContainment' // The selection tray's own state glue + keyboard doors (owner-caught // follow-up to goal 0092: a multi-selection had no visible state and @@ -46,14 +46,14 @@ export function useAtlasSelectionTray({ // click now genuinely selects a single card (goal 0102), and // flashing the Group/Delete tray for every ordinary single-select // would be noisier than the "+ Add" creation tray it replaces. - // Escape's own clear-selection rung below uses a separate >=1 test - // -- it clears ANY live selection, not only a 2+ one. + // Escape's own clear-selection rung below reads the DOM directly + // instead (readSelectedNodeIDs) -- it clears ANY live selection, not + // only a 2+ one, and can't wait on this state mirror's own timing. const hasSelection = selectedCards.length + selectedNotes.length >= 2 - const anySelected = selectedCards.length + selectedNotes.length >= 1 - const latest = useRef({ selectedCards, selectedNotes, hasSelection, anySelected, clearSelection, setNodes, onDeleteSelection, onGroupSelection }) + const latest = useRef({ selectedCards, selectedNotes, hasSelection, clearSelection, setNodes, onDeleteSelection, onGroupSelection }) useEffect(() => { - latest.current = { selectedCards, selectedNotes, hasSelection, anySelected, clearSelection, setNodes, onDeleteSelection, onGroupSelection } + latest.current = { selectedCards, selectedNotes, hasSelection, clearSelection, setNodes, onDeleteSelection, onGroupSelection } }) // Clears BOTH halves: React Flow's own node.selected flags (so the @@ -82,7 +82,11 @@ export function useAtlasSelectionTray({ const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (isEditableTarget(e.target) || !isFocusInsideBoard(wrapperRef)) return - if (latest.current.anySelected) clearAll() + // Read the DOM directly, not the selectedCards/selectedNotes + // state mirror -- see readSelectedNodeIDs' own header comment + // for the render-order gap a fresh click's own Escape can hit. + const ids = readSelectedNodeIDs(wrapperRef) + if (ids.length > 0) clearAll() else useAppStore.getState().requestAtlasUp() return } From 75e29c1feef073526b57f5df24bf551f3b49bb1a Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 01:39:07 -0400 Subject: [PATCH 5/6] fix: plain-click selection ring invisible -- Primer's [role="button"] 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-.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 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- frontend/e2e/atlas-gestures.spec.ts | 42 +++++++++++++++++++ frontend/e2e/atlas-select-group.spec.ts | 10 +++-- frontend/src/atlas/AtlasGroupNode.module.css | 13 +++--- .../src/atlas/AtlasNoteCardNode.module.css | 15 ++++--- .../src/atlas/AtlasRegionChipNode.module.css | 11 +++-- frontend/src/atlas/AtlasStickyNode.module.css | 21 ++++++---- 6 files changed, 88 insertions(+), 24 deletions(-) diff --git a/frontend/e2e/atlas-gestures.spec.ts b/frontend/e2e/atlas-gestures.spec.ts index c4fdce75..c69dd3ab 100644 --- a/frontend/e2e/atlas-gestures.spec.ts +++ b/frontend/e2e/atlas-gestures.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from './fixtures/server' import { groupCard, noteCard } from './fixtures/atlasCards' import { clickCorner, zoomAllTheWayOut } from './fixtures/atlasBoard' +import { contextMenu } from './fixtures/contextMenu' // The click model (goal 0102's gesture table) + surface-scoped // shortcuts (goal 0071 slice): plain click selects/replaces, a second @@ -42,6 +43,47 @@ test('plain click selects (replacing any prior selection); a second click on the await expect(overlay).not.toBeVisible() }) +test('a plain click leaves the selection ring visibly showing on the clicked card while it still holds DOM focus, for a note card and a sticky', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(page.getByTestId('atlas-board')).toBeVisible() + + // Regression: Primer's own [role="button"]:focus:not(:focus-visible) + // reset zeroes any box-shadow scoped to that inner role="button" + // element -- exactly the state a plain mouse click leaves the + // clicked card in (focused, but never :focus-visible from a pointer + // gesture), which made a single-card selection invisible. The ring + // must show on the wrapper, immune to that reset, while the card is + // still focused -- not just once focus moves elsewhere. + const getting = noteCard(page, 'Getting started') + await getting.click() + expect(await page.evaluate(() => document.activeElement?.getAttribute('data-testid'))).toBe('atlas-note-card') + const cardWrapper = selectedWrapper(page, getting) + await expect(cardWrapper).toHaveCount(1) + await expect.poll(() => cardWrapper.evaluate((el) => getComputedStyle(el).boxShadow)).not.toBe('none') + + // A sticky note's own, separately-declared (heavier) ring rule. + const board = page.getByTestId('atlas-board') + await zoomAllTheWayOut(page) + await page.keyboard.press('n') + await clickCorner(board, 'top-right') + const noteTA = page.getByTestId('atlas-sticky-textarea') + await noteTA.fill('ZzE2eStickyRing') + await noteTA.blur() + const sticky = page.locator('[data-testid="atlas-sticky-note"]') + await sticky.click() + expect(await page.evaluate(() => document.activeElement?.getAttribute('data-testid'))).toBe('atlas-sticky-note') + const stickyWrapper = page.locator('.react-flow__node.selected').filter({ has: sticky }) + await expect(stickyWrapper).toHaveCount(1) + await expect.poll(() => stickyWrapper.evaluate((el) => getComputedStyle(el).boxShadow)).not.toBe('none') + + // Cleanup (testing.md's within-file discipline). + const menu = contextMenu(page) + await sticky.click({ button: 'right' }) + await menu.getByText('Delete note', { exact: true }).click() + await expect(sticky).toHaveCount(0) +}) + test('a real double-click reproduces the same select-then-commit outcome as two plain clicks, for both a leaf and a frame body', async ({ page }) => { await page.goto('/') await page.getByRole('link', { name: 'Atlas' }).click() diff --git a/frontend/e2e/atlas-select-group.spec.ts b/frontend/e2e/atlas-select-group.spec.ts index a385625a..4b32fe46 100644 --- a/frontend/e2e/atlas-select-group.spec.ts +++ b/frontend/e2e/atlas-select-group.spec.ts @@ -231,9 +231,13 @@ test('atlas shift-click select: toggle membership, group via member right-click, // Visible selection state (goal 0092 follow-up): both member nodes // carry a real, non-empty outline/ring, not just React Flow's own - // unstyled .selected class. - await expect.poll(() => cardA.evaluate((el) => getComputedStyle(el).boxShadow)).not.toBe('none') - await expect.poll(() => cardB.evaluate((el) => getComputedStyle(el).boxShadow)).not.toBe('none') + // unstyled .selected class. Measured on the wrapper (the ring's + // own carrier), not the inner card -- Primer's [role="button"] + // focus reset can zero a box-shadow scoped to the inner element. + const cardAWrapper = selected.filter({ has: cardA }) + const cardBWrapper = selected.filter({ has: cardB }) + await expect.poll(() => cardAWrapper.evaluate((el) => getComputedStyle(el).boxShadow)).not.toBe('none') + await expect.poll(() => cardBWrapper.evaluate((el) => getComputedStyle(el).boxShadow)).not.toBe('none') // The selection tray replaces the creation tray while 2+ cards are // selected: count label, Group (2+ cards only), Delete, both with diff --git a/frontend/src/atlas/AtlasGroupNode.module.css b/frontend/src/atlas/AtlasGroupNode.module.css index f73498b3..679c7ede 100644 --- a/frontend/src/atlas/AtlasGroupNode.module.css +++ b/frontend/src/atlas/AtlasGroupNode.module.css @@ -14,11 +14,14 @@ cursor: pointer; } -/* Multi-selection outline (owner-caught follow-up to goal 0092): same - box-shadow ring AtlasNoteCardNode.module.css uses, keyed off React - Flow's own .selected on the node's outer wrapper -- never the - frame's own border-width, so a selected frame's size never shifts. */ -:global(.react-flow__node.selected) .frame { +/* Selection ring: on the React Flow node WRAPPER (the type-scoped + class React Flow stamps per nodeTypes key), never an inner element + -- see AtlasNoteCardNode.module.css's own header comment for why + (Primer's [role="button"] focus reset would otherwise zero it right + after every mouse click). Never the frame's own border-width + either, so a selected frame's size never shifts. */ +:global(.react-flow__node-atlas-group.selected) { + border-radius: 14px; box-shadow: 0 0 0 2px var(--borderColor-accent-emphasis), 0 0 0 5px var(--bgColor-accent-muted); diff --git a/frontend/src/atlas/AtlasNoteCardNode.module.css b/frontend/src/atlas/AtlasNoteCardNode.module.css index 56afcbb9..9b0698b3 100644 --- a/frontend/src/atlas/AtlasNoteCardNode.module.css +++ b/frontend/src/atlas/AtlasNoteCardNode.module.css @@ -18,11 +18,16 @@ border-color: var(--borderColor-emphasis); } -/* Multi-selection outline (owner-caught follow-up to goal 0092): keyed - off React Flow's own .selected class on the node's outer wrapper -- - box-shadow, not border-width, so it never shifts this card's fixed - 190x128 footprint or its own hit-testing box. */ -:global(.react-flow__node.selected) .card { +/* Selection ring: on the React Flow node WRAPPER (the type-scoped + class React Flow stamps per nodeTypes key), never an inner element. + Primer's own [role="button"]:focus:not(:focus-visible) reset zeroes + any box-shadow on an inner role="button" element the instant a + plain click leaves it :focus without :focus-visible -- the normal + state right after every mouse click, which is exactly when this + ring needs to show. The wrapper carries no role, so that reset can + never reach it. box-shadow, not border-width, so it never shifts + this card's fixed 190x128 footprint or its own hit-testing box. */ +:global(.react-flow__node-atlas-note.selected) { border-radius: var(--borderRadius-medium); box-shadow: 0 0 0 2px var(--borderColor-accent-emphasis), diff --git a/frontend/src/atlas/AtlasRegionChipNode.module.css b/frontend/src/atlas/AtlasRegionChipNode.module.css index 989ccd38..497ce0be 100644 --- a/frontend/src/atlas/AtlasRegionChipNode.module.css +++ b/frontend/src/atlas/AtlasRegionChipNode.module.css @@ -15,10 +15,13 @@ outline-offset: 1px; } -/* Multi-selection outline (owner-caught follow-up to goal 0092): same - box-shadow ring AtlasNoteCardNode.module.css uses, keyed off React - Flow's own .selected on the node's outer wrapper. */ -:global(.react-flow__node.selected) .chip { +/* Selection ring: on the React Flow node WRAPPER (the type-scoped + class React Flow stamps per nodeTypes key), never an inner element + -- see AtlasNoteCardNode.module.css's own header comment for why + (Primer's [role="button"] focus reset would otherwise zero it right + after every mouse click). */ +:global(.react-flow__node-atlas-region-chip.selected) { + border-radius: 10px; box-shadow: 0 0 0 2px var(--borderColor-accent-emphasis), 0 0 0 5px var(--bgColor-accent-muted); diff --git a/frontend/src/atlas/AtlasStickyNode.module.css b/frontend/src/atlas/AtlasStickyNode.module.css index ab3ebece..ec880e8b 100644 --- a/frontend/src/atlas/AtlasStickyNode.module.css +++ b/frontend/src/atlas/AtlasStickyNode.module.css @@ -17,17 +17,24 @@ cursor: text; } -/* Multi-selection outline (owner-caught follow-up to goal 0092), - heavier than the card ring on purpose: the sticky's attention-tinted - ground swallows the 2px accent line cards get away with on white -- - a wider ring plus the border flipping to the accent keeps a selected - note legible at board zoom. */ -:global(.react-flow__node.selected) .sticky { - border-color: var(--borderColor-accent-emphasis); +/* Selection ring: heavier than the card ring on purpose -- the + sticky's attention-tinted ground swallows the 2px accent line cards + get away with on white. The ring lives on the React Flow node + WRAPPER (the type-scoped class React Flow stamps per nodeTypes + key), never an inner element -- see AtlasNoteCardNode.module.css's + own header comment for why (Primer's [role="button"] focus reset + would otherwise zero it right after every mouse click). The border + color flip stays on the inner element: Primer's reset only touches + box-shadow, so this half already survives there. */ +:global(.react-flow__node-atlas-sticky.selected) { + border-radius: var(--borderRadius-medium); box-shadow: 0 0 0 3px var(--borderColor-accent-emphasis), 0 0 0 7px var(--bgColor-accent-muted); } +:global(.react-flow__node.selected) .sticky { + border-color: var(--borderColor-accent-emphasis); +} /* A pointer click must not flash the webview's OS focus halo (the "blue flash" over the selection ring); keyboard focus keeps its From bd576e50062edf58d1b8b48fe18cb6afd1d442bf Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 02:12:32 -0400 Subject: [PATCH 6/6] test: migrate atlas-delete-relationships.spec.ts off the retired flip 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 ' 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 --- frontend/e2e/atlas-delete-relationships.spec.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/frontend/e2e/atlas-delete-relationships.spec.ts b/frontend/e2e/atlas-delete-relationships.spec.ts index 1b3375ff..a8642341 100644 --- a/frontend/e2e/atlas-delete-relationships.spec.ts +++ b/frontend/e2e/atlas-delete-relationships.spec.ts @@ -1,20 +1,12 @@ import { test, expect } from './fixtures/server' import { deleteViaPageMenu } from './fixtures/atlasPage' -import { openViaFlip } from './fixtures/atlasBoard' +import { noteCard, openCard } from './fixtures/atlasBoard' // Split out of atlas.spec.ts (architecture.md's 500-line convention, // the same split atlas-share.spec.ts/atlas-projections.spec.ts already // established) once the delete-blast-radius toast case (goal 0103) // pushed that file over the limit. -// Precise per-card matching, same reasoning as atlas.spec.ts's own -// local copy: a card's own BACK face can legitimately contain another -// card's title in its "<kind> -> <other title>" link row, so aria-label -// carries the exact title instead of a substring match. -function noteCard(page: import('@playwright/test').Page, title: string) { - return page.locator(`[data-testid="atlas-note-card"][aria-label="Flip ${title}"]`) -} - test('deleting a linked card names the blast radius in the undo toast, and undo restores the link edge', async ({ page }) => { await page.goto('/') await page.getByRole('link', { name: 'Atlas' }).click() @@ -24,7 +16,7 @@ test('deleting a linked card names the blast radius in the undo toast, and undo // "Getting started" carries exactly one seeded link (to "Ada // Lovelace") and no children -- the leaf-with-links case. const gettingStarted = noteCard(page, 'Getting started') - await openViaFlip(gettingStarted) + await openCard(page, gettingStarted) const overlay = page.locator('[data-component="atlas-card-overlay"]') await deleteViaPageMenu(page, overlay) await expect(gettingStarted).toHaveCount(0)