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 | 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-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 " -> " 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) 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..c69dd3ab 100644 --- a/frontend/e2e/atlas-gestures.spec.ts +++ b/frontend/e2e/atlas-gestures.spec.ts @@ -1,19 +1,94 @@ import { test, expect } from './fixtures/server' import { groupCard, noteCard } from './fixtures/atlasCards' +import { clickCorner, zoomAllTheWayOut } from './fixtures/atlasBoard' +import { contextMenu } from './fixtures/contextMenu' -// 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 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() 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 +96,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 +109,58 @@ 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('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 }) => { 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..4b32fe46 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'] }) @@ -232,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 @@ -250,8 +253,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() diff --git a/frontend/src/atlas/AtlasBoard.tsx b/frontend/src/atlas/AtlasBoard.tsx index e3f9e495..37c7f0c0 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, + 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 @@ -390,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/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..679c7ede 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,41 +12,21 @@ border-width: 1.5px; border-style: solid; cursor: pointer; - perspective: 1000px; } -/* 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); } -/* 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 +98,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 +134,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..9b0698b3 100644 --- a/frontend/src/atlas/AtlasNoteCardNode.module.css +++ b/frontend/src/atlas/AtlasNoteCardNode.module.css @@ -1,21 +1,33 @@ -/* 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 { +/* 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), @@ -30,47 +42,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 +136,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 +174,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 +187,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..497ce0be 100644 --- a/frontend/src/atlas/AtlasRegionChipNode.module.css +++ b/frontend/src/atlas/AtlasRegionChipNode.module.css @@ -5,50 +5,27 @@ 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); 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); } -.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 +41,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.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 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..07ce642a --- /dev/null +++ b/frontend/src/atlas/atlasFocusContainment.ts @@ -0,0 +1,46 @@ +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) +} + +// 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/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..2d6ed2ad --- /dev/null +++ b/frontend/src/atlas/useAtlasKeyboardNav.ts @@ -0,0 +1,153 @@ +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, readSelectedNodeIDs } 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, + setNodes, + isGroupCardFn, onOpenOverlay, onDrill, + getViewport, setViewport, +}: { + cards: Card[] + readOnly: boolean + wrapperRef: RefObject + cardBoxes: NavBox[] + noteBoxes: NavBox[] + 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 + // 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() + 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) + } + }, [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..76b11419 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, 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 @@ -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 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 latest = useRef({ selectedCards, selectedNotes, hasSelection, clearSelection, setNodes, onDeleteSelection, onGroupSelection, onUnflip }) + const latest = useRef({ selectedCards, selectedNotes, hasSelection, clearSelection, setNodes, onDeleteSelection, onGroupSelection }) useEffect(() => { - latest.current = { selectedCards, selectedNotes, hasSelection, clearSelection, setNodes, onDeleteSelection, onGroupSelection, onUnflip } + latest.current = { selectedCards, selectedNotes, hasSelection, clearSelection, setNodes, onDeleteSelection, onGroupSelection } }) // Clears BOTH halves: React Flow's own node.selected flags (so the @@ -71,8 +81,13 @@ 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 + // 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 } if (e.metaKey || e.ctrlKey || e.altKey || isEditableTarget(e.target)) return @@ -82,7 +97,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",