From 548d852370137d3784f0fd4d3ee6bc496efaeec9 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 04:10:38 -0400 Subject: [PATCH 1/3] feat: the calm card page -- create-loop audit, minimap, sibling breadcrumbs (goal 0106 slice B) The card page reads as a document, not a form: a compact property strip (kind label, a Kind-declared "status" field as a chip, freshness dot), a borderless type-to-write note, and every other optional field (kind fields, source/mirror path, typed-link slot rows, Actions) collapses to a one-line "+ Add" invitation when empty and expands focused on click -- a title-only card's page now shows title + strip + write-invitation and nothing else, including Contents, which mounts only when the card has children or a mirror. The placement popover's 'create'-mode resting view drops its Kind/ Title labels and Submit/Cancel row (kind chip + title input only, Enter commits, Escape cancels) and its title input is now genuinely autofocused (AnchoredOverlay's own focus trap was grabbing the kind chip first); 'promote'/'area' modes keep their existing form. The board gains React Flow's own MiniMap (bottom-right, styled to tokens, moved to shared/ since composition/ and atlas/ both need it now), a control-strip toggle button + palette command (localStorage- persisted, default on), and every breadcrumb segment becomes a sibling-dropdown trigger (name + card count, current one selected, clicking any entry navigates). 0104's keyboard-nav key table (Tab/Shift-Tab, Option-arrows, Enter, arrows, Escape) is now advertised in the command registry and Shortcuts Help overlay, hintOnly + paletteHidden since the real handling stays in useAtlasKeyboardNav.ts. Fixes two real bugs the new minimap surfaced: three e2e flows placed cards/notes/clicks in the board's bottom-right corner, which the minimap now occupies -- moved to corners it doesn't cover, and clickCorner's own 'bottom-right' option (now unsafe) is removed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- frontend/e2e/atlas-authoring.spec.ts | 18 ++- .../e2e/atlas-breadcrumb-siblings.spec.ts | 67 ++++++++ frontend/e2e/atlas-capture.spec.ts | 6 +- frontend/e2e/atlas-containment.spec.ts | 14 +- frontend/e2e/atlas-folder-import.spec.ts | 6 +- frontend/e2e/atlas-gestures.spec.ts | 100 ++++++++++++ frontend/e2e/atlas-minimap.spec.ts | 62 ++++++++ frontend/e2e/atlas-page-edit.spec.ts | 21 ++- frontend/e2e/atlas-page.spec.ts | 82 +++++++++- frontend/e2e/atlas-scale.spec.ts | 7 +- frontend/e2e/atlas-select-group.spec.ts | 5 +- frontend/e2e/atlas-session-restore.spec.ts | 4 +- frontend/e2e/atlas-slots.spec.ts | 17 ++- frontend/e2e/atlas.spec.ts | 6 +- frontend/e2e/command-palette.spec.ts | 1 + frontend/e2e/fixtures/atlasBoard.ts | 45 +++++- frontend/e2e/help-overlay.spec.ts | 29 ++++ frontend/src/atlas/AtlasBoard.tsx | 10 +- .../src/atlas/AtlasBoardMinimapButton.tsx | 20 +++ frontend/src/atlas/AtlasBreadcrumb.tsx | 71 +++++++-- .../src/atlas/AtlasCardActions.module.css | 9 ++ frontend/src/atlas/AtlasCardActions.tsx | 58 ++++--- frontend/src/atlas/AtlasCardOverlay.tsx | 9 ++ frontend/src/atlas/AtlasCardPage.module.css | 67 +++++++- frontend/src/atlas/AtlasCardPageContents.tsx | 13 +- frontend/src/atlas/AtlasCardPageFields.tsx | 143 +++++++++++++----- frontend/src/atlas/AtlasCardPropertyStrip.tsx | 90 +++++++++++ frontend/src/atlas/AtlasFieldsForm.tsx | 5 + frontend/src/atlas/AtlasPlacementPopover.tsx | 116 +++++++++----- frontend/src/atlas/AtlasSlotRows.module.css | 9 ++ frontend/src/atlas/AtlasSlotRows.tsx | 142 ++++++++++------- frontend/src/atlas/atlasCardPageContent.ts | 22 ++- frontend/src/atlas/useAtlasMinimapToggle.ts | 43 ++++++ .../src/composition/CompositionCanvas.tsx | 2 +- frontend/src/composition/ThemedMiniMap.tsx | 29 ---- frontend/src/locales/en/atlas.json | 11 +- frontend/src/shared/ThemedMiniMap.tsx | 29 ++++ frontend/src/shared/atlasBoardCommands.ts | 71 +++++++++ frontend/src/shared/uiSignalStore.ts | 9 ++ 39 files changed, 1213 insertions(+), 255 deletions(-) create mode 100644 frontend/e2e/atlas-breadcrumb-siblings.spec.ts create mode 100644 frontend/e2e/atlas-minimap.spec.ts create mode 100644 frontend/src/atlas/AtlasBoardMinimapButton.tsx create mode 100644 frontend/src/atlas/AtlasCardPropertyStrip.tsx create mode 100644 frontend/src/atlas/useAtlasMinimapToggle.ts delete mode 100644 frontend/src/composition/ThemedMiniMap.tsx create mode 100644 frontend/src/shared/ThemedMiniMap.tsx diff --git a/frontend/e2e/atlas-authoring.spec.ts b/frontend/e2e/atlas-authoring.spec.ts index e340b915..d3c3e90f 100644 --- a/frontend/e2e/atlas-authoring.spec.ts +++ b/frontend/e2e/atlas-authoring.spec.ts @@ -10,7 +10,7 @@ import { } from './fixtures/server' import { contextMenu, rightClickEmptyArea } from './fixtures/contextMenu' import { ATLAS_KIND_CONTACT, ATLAS_KIND_TOPIC, selectKind } from './fixtures/kindPicker' -import { clickCorner, groupCard, noteCard, zoomAllTheWayOut } from './fixtures/atlasBoard' +import { clickCorner, groupCard, noteCard, submitCreatePopover, zoomAllTheWayOut } from './fixtures/atlasBoard' // Atlas creation core (goal 0081 slice A1): the tray, its placement // popover, right-click create, sticky notes, and the note promotion @@ -137,9 +137,21 @@ test('atlas creation core: tray, placement popover, right-click create, sticky n await clickCorner(board, 'top-right') await expect(cardTool).toHaveAttribute('data-armed', 'false') await expect(popover).toBeVisible() + + // The create loop (goal 0106 slice B contract item 4: C -> click -> + // title -> Enter -> board) audited: the popover's own resting chrome + // is the kind chip + title input ONLY -- no "Kind"/"Title" labels, + // no visible Submit/Cancel row (Enter/Escape are the only commit/ + // cancel paths). The title input is already focused. + await expect(popover.getByText('Kind', { exact: true })).toHaveCount(0) + await expect(popover.getByText('Title', { exact: true })).toHaveCount(0) + await expect(popover.getByTestId('atlas-placement-submit')).toHaveCount(0) + await expect(popover.getByTestId('atlas-placement-cancel')).toHaveCount(0) + await expect(popover.getByTestId('atlas-placement-title')).toBeFocused() + await selectKind(popover, ATLAS_KIND_TOPIC) await popover.getByTestId('atlas-placement-title').fill('ZzE2eRootCard') - await popover.getByTestId('atlas-placement-submit').click() + await submitCreatePopover(popover) await expect(popover).not.toBeVisible() await expect(noteCard(page, 'ZzE2eRootCard')).toBeVisible() // Regression: one confirm creates exactly ONE card. StrictMode @@ -166,7 +178,7 @@ test('atlas creation core: tray, placement popover, right-click create, sticky n await expect(popover).toBeVisible() await selectKind(popover, ATLAS_KIND_CONTACT) await popover.getByTestId('atlas-placement-title').fill('ZzE2eAreaCard') - await popover.getByTestId('atlas-placement-submit').click() + await submitCreatePopover(popover) await expect(popover).not.toBeVisible() await expect(noteCard(page, 'ZzE2eAreaCard')).toBeVisible() diff --git a/frontend/e2e/atlas-breadcrumb-siblings.spec.ts b/frontend/e2e/atlas-breadcrumb-siblings.spec.ts new file mode 100644 index 00000000..bdb84a88 --- /dev/null +++ b/frontend/e2e/atlas-breadcrumb-siblings.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from './fixtures/server' +import { groupCard } from './fixtures/atlasCards' + +// The breadcrumb's own sibling dropdown (goal 0106 slice B contract +// item 5): each real segment is a dropdown trigger listing its own +// level's sibling places (name + card count), current one selected -- +// clicking any entry (including the current one) navigates, the same +// capability the crumb's old direct-navigate-on-click carried. + +test('a breadcrumb segment opens a dropdown of its level\'s siblings, current one selected, with card counts', 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') + + // "My space" is the seeded space's own root -- its siblings are + // every other root-level card, which at the single-root default is + // only itself (3 top-level children: Example area, Getting started, + // Scratchpad). + const mySpaceCrumb = page.getByTestId('atlas-breadcrumb').getByTestId('atlas-breadcrumb-item').filter({ hasText: 'My space' }) + await mySpaceCrumb.click() + + const dropdown = page.getByTestId('atlas-breadcrumb-siblings') + await expect(dropdown).toBeVisible() + const mySpaceRow = dropdown.getByTestId('atlas-breadcrumb-sibling').filter({ hasText: 'My space' }) + await expect(mySpaceRow).toBeVisible() + await expect(mySpaceRow).toContainText('3 cards') + + // Clicking the current place navigates to it (reproducing the old + // direct-navigate behavior) -- the crumb collapses back to "My + // space" alone. + await mySpaceRow.click() + await expect(dropdown).toHaveCount(0) + await expect(page.getByTestId('atlas-breadcrumb')).not.toContainText('Example area') + await expect(page.getByTestId('atlas-breadcrumb')).toContainText('My space') +}) + +test('clicking a DIFFERENT sibling in the dropdown navigates laterally', 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') + + // "Example area"'s own siblings are the OTHER top-level children of + // "My space" -- Getting started, Scratchpad -- neither of which is a + // place with its own children, so clicking one lands on an empty + // board (its own drilled-into space), not "Example area"'s own + // contents. + const exampleAreaCrumb = page.getByTestId('atlas-breadcrumb').getByTestId('atlas-breadcrumb-item').filter({ hasText: 'Example area' }) + await exampleAreaCrumb.click() + const dropdown = page.getByTestId('atlas-breadcrumb-siblings') + await expect(dropdown).toBeVisible() + + const gettingRow = dropdown.getByTestId('atlas-breadcrumb-sibling').filter({ hasText: 'Getting started' }) + await expect(gettingRow).toBeVisible() + await expect(gettingRow).toContainText('0 cards') + await gettingRow.click() + + await expect(dropdown).toHaveCount(0) + await expect(page.getByTestId('atlas-breadcrumb')).toContainText('My space') + await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Getting started') + await expect(page.getByTestId('atlas-breadcrumb')).not.toContainText('Example area') +}) diff --git a/frontend/e2e/atlas-capture.spec.ts b/frontend/e2e/atlas-capture.spec.ts index b2075eb2..495e9e7d 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, openCard, zoomAllTheWayOut } from './fixtures/atlasBoard' +import { clickCorner, openCard, submitCreatePopover, zoomAllTheWayOut } from './fixtures/atlasBoard' import { deleteViaPageMenu } from './fixtures/atlasPage' // Atlas capture doors (goal 0081 slice A3, LOCKED design §2b/§3b): @@ -55,7 +55,7 @@ test('paste text opens the placement popover prefilled with title and note', asy await expect(popover).toBeVisible() await expect(popover.getByTestId('atlas-placement-title')).toHaveValue('Q3 migration checklist') - await popover.getByTestId('atlas-placement-submit').click() + await submitCreatePopover(popover) await expect(popover).not.toBeVisible() const card = page.locator('[data-testid="atlas-note-card"]').filter({ hasText: 'Q3 migration checklist' }) @@ -86,7 +86,7 @@ test('paste HTML converts to Markdown before prefilling the popover', async ({ p // by the same first-line rule paste-as-plain-text uses. await expect(popover.getByTestId('atlas-placement-title')).toHaveValue(/Vendor policy/) - await popover.getByTestId('atlas-placement-submit').click() + await submitCreatePopover(popover) await expect(popover).not.toBeVisible() const card = page.locator('[data-testid="atlas-note-card"]').filter({ hasText: 'Vendor policy' }) diff --git a/frontend/e2e/atlas-containment.spec.ts b/frontend/e2e/atlas-containment.spec.ts index e836dc85..383bd39a 100644 --- a/frontend/e2e/atlas-containment.spec.ts +++ b/frontend/e2e/atlas-containment.spec.ts @@ -11,7 +11,7 @@ import { } from './fixtures/server' import { contextMenu } from './fixtures/contextMenu' import { ATLAS_KIND_TOPIC, selectKind } from './fixtures/kindPicker' -import { armAndPlaceTopicCard, deleteCardViaMenu, groupCard, noteCard } from './fixtures/atlasBoard' +import { armAndPlaceTopicCard, cancelCreatePopover, deleteCardViaMenu, groupCard, noteCard, submitCreatePopover } from './fixtures/atlasBoard' import { waitForViewportStable } from './fixtures/animation' // A LIGHTER zoom-out than fixtures/atlasBoard.ts's own zoomAllTheWayOut @@ -174,7 +174,7 @@ test('atlas containment: area drawing, marker-box grouping, drag filing, dissolv await expect(popover).toBeVisible() await selectKind(popover, ATLAS_KIND_TOPIC) await popover.getByTestId('atlas-placement-title').fill('ZzC2eInterior') - await popover.getByTestId('atlas-placement-submit').click() + await submitCreatePopover(popover) await expect(popover).not.toBeVisible() await page.mouse.wheel(0, 300) await waitForViewportStable(board) @@ -239,16 +239,20 @@ test('atlas containment: area drawing, marker-box grouping, drag filing, dissolv // board point (not the shared clickCorner helper) -- several zoom // changes have accumulated by now, so a FRACTION of the board's // current bounds stays reliably empty regardless of the exact - // zoom level, the way a fixed named corner no longer does. --- + // zoom level, the way a fixed named corner no longer does. y=0.3 + // (not a bottom corner): the board's own minimap/Controls/creation + // tray chrome (goal 0106 slice B) all live in the bottom band, so + // the vertical middle is the one fraction guaranteed clear of all + // three regardless of which corner a bottom-anchored point picks. --- await page.keyboard.press('c') const kindCheckBox = await board.boundingBox() if (!kindCheckBox) throw new Error('board has no bounding box') - await board.click({ position: { x: kindCheckBox.width * 0.95, y: kindCheckBox.height * 0.95 } }) + await board.click({ position: { x: kindCheckBox.width * 0.95, y: kindCheckBox.height * 0.3 } }) await expect(popover).toBeVisible() await popover.getByTestId('atlas-placement-kind').click() await expect(popover.getByTestId(`atlas-placement-kind-option-${ATLAS_KIND_TOPIC}`)).toContainText('Something being tracked or worked through.') await popover.getByTestId(`atlas-placement-kind-option-${ATLAS_KIND_TOPIC}`).click() - await popover.getByTestId('atlas-placement-cancel').click() + await cancelCreatePopover(popover) await expect(popover).not.toBeVisible() // --- Rider (a): a zoomed-into space with zero cards and zero diff --git a/frontend/e2e/atlas-folder-import.spec.ts b/frontend/e2e/atlas-folder-import.spec.ts index 6a1ff859..1b073962 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 { openCard } from './fixtures/atlasBoard' +import { clickBreadcrumbSegment, openCard } from './fixtures/atlasBoard' // Synced-folder onboarding (docs/goals/0067) over real Go bindings // (Wails3 server mode): AtlasService.PickFolder's own MILL_TEST_ @@ -96,7 +96,7 @@ test('add from folder: scan, partial accept, containment, and mirror rendering a await deleteViaPageMenu(page, overlay) await expect(summaryCard).not.toBeVisible() - await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() + await clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }), 'My space') for (const card of [noteCard(page, 'Reports'), notesCard, logoCard]) { await openCard(page, card) await expect(overlay).toBeVisible() @@ -168,7 +168,7 @@ test('add from folder: an already-imported file stays flagged and default-unchec await deleteViaPageMenu(page, overlay) await expect(overlay).not.toBeVisible() - await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() + await clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }), 'My space') const firstNotesCard = noteCard(page, 'Meeting Notes') await openCard(page, firstNotesCard) await expect(overlay).toBeVisible() diff --git a/frontend/e2e/atlas-gestures.spec.ts b/frontend/e2e/atlas-gestures.spec.ts index c69dd3ab..84badb80 100644 --- a/frontend/e2e/atlas-gestures.spec.ts +++ b/frontend/e2e/atlas-gestures.spec.ts @@ -187,3 +187,103 @@ test('atlas.up is surface-scoped: listed under "On this page" in the palette on await expect(page.getByTestId('composition-view')).toBeVisible() }) +// The two 0104 residual e2e cases goal 0106 slice B absorbs (useAtlasKeyboardNav.ts's +// key table), plus a verify-only case for arrows-pan-with-no-selection +// (already implemented in slice A -- reported, not built here). + +test('Tab cycles focus and selection across top-level cards in reading order', 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') + const getting = noteCard(page, 'Getting started') + + // Focus lands inside the board wrapper (a click), then Escape's + // first rung clears the live selection while DOM focus stays on the + // clicked card -- the same mechanism the Escape-ladder test above + // already proves -- so the FIRST Tab below starts from "nothing + // selected", not from this card as an anchor. + await getting.click() + await page.keyboard.press('Escape') + await expect(selectedWrapper(page, getting)).toHaveCount(0) + + // Reading order is left-to-right (atlasKeyboardNavGeometry.ts): the + // seed's own X positions put Example area (80) before Getting + // started (532), both on the same row (internal/domain/atlas/ + // builtin.go). + await page.keyboard.press('Tab') + await expect(selectedWrapper(page, exampleArea)).toHaveCount(1) + + await page.keyboard.press('Tab') + await expect(selectedWrapper(page, exampleArea)).toHaveCount(0) + await expect(selectedWrapper(page, getting)).toHaveCount(1) + + await page.keyboard.press('Shift+Tab') + await expect(selectedWrapper(page, getting)).toHaveCount(0) + await expect(selectedWrapper(page, exampleArea)).toHaveCount(1) +}) + +test('arrow-nudge persists the selected card\'s position', 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 gettingNode = page.locator('.react-flow__node').filter({ has: getting }) + await getting.click() + await expect(selectedWrapper(page, getting)).toHaveCount(1) + + // React Flow writes translate(x,y) in flow coords directly onto the + // node element's own style -- camera-independent, unlike + // boundingBox() (same technique atlas.spec.ts's own arrange-persists + // test already established). + const before = (await gettingNode.evaluate((el) => (el as HTMLElement).style.transform)) ?? '' + for (let i = 0; i < 5; i++) await page.keyboard.press('ArrowRight') + + let after = before + await expect.poll(async () => { + after = (await gettingNode.evaluate((el) => (el as HTMLElement).style.transform)) ?? '' + return after + }).not.toBe(before) + + // The Go-side SetPosition write is batched to keyup, not per-pixel -- + // a reload renders the SAME persisted position, proving the nudge + // actually round-tripped through the backend, not just the live DOM. + await page.reload() + await expect(page.getByTestId('atlas-view')).toBeVisible() + await expect(gettingNode).toBeVisible() + await expect.poll(async () => gettingNode.evaluate((el) => (el as HTMLElement).style.transform), { timeout: 10_000 }).toBe(after) + + // Cleanup: nudge back to the seeded position (testing.md's + // within-file discipline -- no later test in this file depends on + // it, but leaving drift around is still unnecessary). + await getting.click() + await expect(selectedWrapper(page, getting)).toHaveCount(1) + for (let i = 0; i < 5; i++) await page.keyboard.press('ArrowLeft') + await expect.poll(async () => gettingNode.evaluate((el) => (el as HTMLElement).style.transform)).toBe(before) +}) + +// Verify-only (goal 0106 slice B's residual audit): arrows with NO +// selection pan the camera instead of nudging -- already implemented +// in slice A (useAtlasKeyboardNav.ts), not new work here. +test('arrows with no selection pan the camera instead of nudging', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + + const getting = noteCard(page, 'Getting started') + await getting.click() + await page.keyboard.press('Escape') + await expect(selectedWrapper(page, getting)).toHaveCount(0) + + const viewport = board.locator('.react-flow__viewport') + const before = await viewport.evaluate((el) => (el as HTMLElement).style.transform) + await page.keyboard.press('ArrowRight') + await expect.poll(async () => viewport.evaluate((el) => (el as HTMLElement).style.transform)).not.toBe(before) + + // Cleanup: pan back so this test leaves the camera where it found it. + await page.keyboard.press('ArrowLeft') +}) + diff --git a/frontend/e2e/atlas-minimap.spec.ts b/frontend/e2e/atlas-minimap.spec.ts new file mode 100644 index 00000000..34a36e43 --- /dev/null +++ b/frontend/e2e/atlas-minimap.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from './fixtures/server' + +// The board's own minimap (goal 0106 slice B contract item 5): React +// Flow's own MiniMap, styled to tokens (ThemedMiniMap.tsx), a +// control-strip toggle button alongside zoom, a palette command, and +// localStorage persistence (default ON) -- each browser context here +// starts with fresh storage (Playwright's own default per-test +// isolation), so every test below reads the untouched default. + +test('the minimap renders by default, bottom-right of the board, in the same control cluster as zoom', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + + await expect(board.locator('.react-flow__minimap')).toBeVisible() + const toggle = board.getByTestId('atlas-minimap-toggle') + await expect(toggle).toBeVisible() + await expect(toggle).toHaveAttribute('aria-pressed', 'true') + // Same cluster as the zoom/fit-view/lock buttons, not a separate + // panel -- `has` must be built off `page`, not a locator already + // scoped through a separate ancestor (`board`), or the filter never + // matches. + await expect(page.locator('.react-flow__controls').filter({ has: page.getByTestId('atlas-minimap-toggle') })).toHaveCount(1) + await expect(board.locator('.react-flow__controls-zoomin')).toBeVisible() +}) + +test('the toggle button hides and shows the minimap, and the choice persists across a reload', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + + const toggle = board.getByTestId('atlas-minimap-toggle') + await toggle.click() + await expect(board.locator('.react-flow__minimap')).toHaveCount(0) + await expect(toggle).toHaveAttribute('aria-pressed', 'false') + + await page.reload() + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(board).toBeVisible() + await expect(board.locator('.react-flow__minimap')).toHaveCount(0) + await expect(board.getByTestId('atlas-minimap-toggle')).toHaveAttribute('aria-pressed', 'false') +}) + +test('the "Toggle minimap" palette command flips the same toggle the button does', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + await expect(board.locator('.react-flow__minimap')).toBeVisible() + + await page.keyboard.press('Meta+/') + const palette = page.getByRole('dialog', { name: 'Command palette' }) + await expect(palette).toBeVisible() + await palette.getByRole('combobox').fill('Toggle minimap') + await palette.getByRole('option', { name: 'Toggle minimap' }).click() + await expect(palette).toHaveCount(0) + + await expect(board.locator('.react-flow__minimap')).toHaveCount(0) + await expect(board.getByTestId('atlas-minimap-toggle')).toHaveAttribute('aria-pressed', 'false') +}) diff --git a/frontend/e2e/atlas-page-edit.spec.ts b/frontend/e2e/atlas-page-edit.spec.ts index 9c848e16..6030c69a 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, openCard } from './fixtures/atlasBoard' +import { clickBreadcrumbSegment, groupCard, noteCard, openCard } from './fixtures/atlasBoard' import { clickAtFraction } from './fixtures/animation' // Atlas card page read-is-edit + chip navigation (goal 0081 slice A5, @@ -91,12 +91,15 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin await page.keyboard.press('Escape') // --- Links via the page's own slot-row Add control: "Scratchpad" - // carries no links yet -- pick "Getting started" from the row's - // 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". --- + // carries no links yet, so its one link-kind row starts collapsed + // behind its own "+ Add" invitation (goal 0106 slice B contract + // item 3) -- click it to reveal the select+Add control, pick + // "Getting started", 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 openCard(page, noteCard(page, 'Scratchpad')) await expect(overlay).toBeVisible() + await overlay.locator('[data-testid^="atlas-slot-add-row-"]').first().click() const addSelect = overlay.locator('[data-testid^="atlas-slot-add-select-"]').first() const addButton = overlay.locator('[data-testid^="atlas-slot-add-button-"]').first() await addSelect.selectOption({ label: 'Getting started' }) @@ -193,7 +196,13 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin await expect(overlay).toBeVisible() await expect(actions.getByTestId('atlas-page-action-row')).toHaveCount(1) + // Source is pre-seeded (already filled, so it renders directly); + // Mirror path starts empty and collapsed behind its own "+ Add" + // invitation (goal 0106 slice B contract item 3) -- clicking it + // expands the real control the fill below needs. await expect(overlay.getByTestId('atlas-page-source')).toBeVisible() + await expect(overlay.getByTestId('atlas-page-add-mirror-path')).toBeVisible() + await overlay.getByTestId('atlas-page-add-mirror-path').click() await expect(overlay.getByTestId('atlas-page-mirror-path')).toBeVisible() await expect(overlay.getByTestId('atlas-overlay-reveal-file')).toHaveCount(0) @@ -233,7 +242,7 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin // --- Delete via the kebab menu + confirm (rider (a) supersedes // the old edit-section's bare Delete button): create a throwaway // card, delete it, confirm it's gone. --- - await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() + await clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }), 'My space') const title = 'ZzE2eAtlasPageEditDelete' await page.getByTestId('atlas-add-button').click() await page.getByTestId('atlas-add-child').click() diff --git a/frontend/e2e/atlas-page.spec.ts b/frontend/e2e/atlas-page.spec.ts index f94871bb..975dd59a 100644 --- a/frontend/e2e/atlas-page.spec.ts +++ b/frontend/e2e/atlas-page.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from './fixtures/server' import { deleteViaPageMenu } from './fixtures/atlasPage' import { clickAtFraction } from './fixtures/animation' -import { openCard } from './fixtures/atlasBoard' +import { armAndPlaceTopicCard, clickBreadcrumbSegment, 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, @@ -20,6 +20,72 @@ function groupCard(page: import('@playwright/test').Page, title: string) { return page.locator('[data-testid="atlas-group-card"]').filter({ has: page.locator(`[aria-label="Zoom into ${title}"]`) }) } +test('a title-only card\'s page renders calm: title + property strip + write-invitation, nothing else (goal 0106 slice B contract item 3)', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + const popover = page.getByTestId('atlas-placement-popover') + + const title = 'ZzE2eCalmPageOnly' + await armAndPlaceTopicCard(page, board, popover, 0.05, 0.9, title) + await openCard(page, noteCard(page, title)) + const overlay = page.locator('[data-component="atlas-card-overlay"]') + await expect(overlay).toBeVisible() + await expect(overlay.getByTestId('atlas-page-title')).toHaveValue(title) + + // Property strip: kind label + the Topic kind's own "status" field + // as a chip (its declared Default, since a fresh card carries no + // explicit Fields value yet) -- no freshness dot on an unmirrored + // card. + const strip = overlay.getByTestId('atlas-page-property-strip') + await expect(strip).toBeVisible() + await expect(strip.getByTestId('atlas-page-kind-label')).toHaveText('Topic') + await expect(strip.getByTestId('atlas-page-status-chip')).toHaveText('Open') + await expect(strip.getByTestId('atlas-page-freshness-dot')).toHaveCount(0) + + // Borderless write-invitation: empty, placeholder only. + const note = overlay.getByTestId('atlas-page-note') + await expect(note).toHaveValue('') + await expect(note).toHaveAttribute('placeholder', 'Write anything…') + expect(await note.evaluate((el) => getComputedStyle(el).borderStyle)).toBe('none') + + // The Topic kind's OTHER declared field (Summary) collapses to its + // own one-line invitation -- status itself never appears here, since + // it's already the strip's own chip above. + await expect(overlay.getByTestId('atlas-page-add-field')).toHaveText('+ Add Summary') + + // Kind-gated Source/Mirror path never render at all for a non-mirror + // kind, filled OR collapsed. + await expect(overlay.getByTestId('atlas-page-source')).toHaveCount(0) + await expect(overlay.getByTestId('atlas-page-add-source')).toHaveCount(0) + await expect(overlay.getByTestId('atlas-page-mirror-path')).toHaveCount(0) + await expect(overlay.getByTestId('atlas-page-add-mirror-path')).toHaveCount(0) + + // Actions: bare add row only -- no "Actions" heading, no hint text. + const actions = overlay.getByTestId('atlas-page-actions') + await expect(actions.getByTestId('atlas-page-add-action')).toBeVisible() + await expect(actions.getByTestId('atlas-page-action-row')).toHaveCount(0) + await expect(actions).not.toContainText('Actions') + await expect(actions).not.toContainText("Each action receives") + + // Links: the seeded space's one link kind starts collapsed too -- + // no chips, no inline select/Add control visible yet. + const slotRows = overlay.getByTestId('atlas-slot-rows') + await expect(slotRows.locator('[data-testid^="atlas-slot-add-row-"]')).toHaveCount(1) + await expect(slotRows.locator('[data-testid^="atlas-slot-add-select-"]')).toHaveCount(0) + await expect(slotRows.locator('[data-testid^="atlas-slot-chip"]')).toHaveCount(0) + + // Contents: never mounted at all for a childless, unmirrored card -- + // no "Nothing inside yet" fallback (deleted by this same goal). + await expect(overlay.getByTestId('atlas-page-contents')).toHaveCount(0) + await expect(overlay.getByText('Nothing inside yet')).toHaveCount(0) + + // Cleanup (testing.md's within-file discipline). + await deleteViaPageMenu(page, overlay) + await expect(overlay).not.toBeVisible() +}) + test('the page header shows a kind glyph, title, file tag, and Close; the seeded Contact card gets a circular glyph', async ({ page }) => { await page.goto('/') await page.getByRole('link', { name: 'Atlas' }).click() @@ -160,6 +226,10 @@ test('a child\'s mirror preview renders inline in the parent page; the card\'s o const overlay = page.locator('[data-component="atlas-card-overlay"]') await openCard(page, charterCard) await expect(overlay).toBeVisible() + // Mirror path starts empty, collapsed behind its own "+ Add" + // invitation (goal 0106 slice B contract item 3) -- click it to + // reveal the real control. + await overlay.getByTestId('atlas-page-add-mirror-path').click() await overlay.getByTestId('atlas-page-mirror-path').fill(file) await overlay.getByTestId('atlas-page-mirror-path').blur() await expect(overlay.getByTestId('atlas-page-saved-tick')).toBeVisible() @@ -180,7 +250,7 @@ test('a child\'s mirror preview renders inline in the parent page; the card\'s o // 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() + await clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }), 'My space') const exampleAreaFrame = groupCard(page, 'Example area') await clickAtFraction(exampleAreaFrame, 0.01, 0.5, { modifiers: ['Meta'] }) await expect(overlay).toBeVisible() @@ -273,7 +343,7 @@ test('a group entry inside a page re-roots the board to a deeper card, and the b // 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() + await clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }), 'My space') const breadcrumb = page.getByTestId('atlas-breadcrumb') await expect(breadcrumb).not.toContainText('Reports') await expect(page.getByTestId('atlas-region-chip').filter({ hasText: 'Reports' })).toBeVisible() @@ -327,19 +397,19 @@ test('a group entry inside a page re-roots the board to a deeper card, and the b await l3Chip.dblclick() await expect(breadcrumbReports).toHaveCount(3) await deleteViaCommit(noteCard(page, 'Q1 Summary')) - await page.getByTestId('atlas-breadcrumb').getByText('Reports').nth(1).click() + await clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('Reports').nth(1), 'Reports') await deleteViaCommit(noteCard(page, 'Reports')) // L2's other child (its own 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 clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('Reports').first(), 'Reports') 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 clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }), 'My space') await deleteViaCommit(noteCard(page, 'Reports')) await expect(breadcrumb).not.toContainText('Reports') }) diff --git a/frontend/e2e/atlas-scale.spec.ts b/frontend/e2e/atlas-scale.spec.ts index 010063eb..7a9d7178 100644 --- a/frontend/e2e/atlas-scale.spec.ts +++ b/frontend/e2e/atlas-scale.spec.ts @@ -9,6 +9,7 @@ import { type SpawnedServer, } from './fixtures/server' import { clickAtFraction, waitForViewportStable } from './fixtures/animation' +import { clickBreadcrumbSegment } from './fixtures/atlasBoard' // Atlas at real-world density (goal 0073): the one-map board against // the deterministic dense fixture (61 cards, 25 links, nested areas) @@ -117,7 +118,7 @@ test('a dense area previews bounded: capped tiles, region chips, a truthful ghos await velocity.getByTestId('atlas-group-header').click() await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Velocity') await expect(page.locator('.react-flow__edge')).toHaveCount(4) - await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() + await clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }), 'My space') await expect(page.getByTestId('atlas-breadcrumb')).not.toContainText('Velocity') // The click model (goal 0102): a chip answers a single click like @@ -140,7 +141,7 @@ test('a dense area previews bounded: capped tiles, region chips, a truthful ghos // "Getting started" in the SAME ROW as "Example area" (they share // an artery) instead of exiling it to a leaves-band below -- // adjacency is what keeps arteries out of frame bodies. - await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() + await clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }), 'My space') 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"]') }) @@ -202,7 +203,7 @@ test('a dense area previews bounded: capped tiles, region chips, a truthful ghos await importDialog.getByRole('checkbox', { name: 'Q1 Summary' }).uncheck() await importDialog.getByRole('button', { name: 'Add 4 cards' }).click() await expect(importDialog).not.toBeVisible() - await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() + await clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }), 'My space') await expect(page.getByTestId('atlas-breadcrumb')).not.toContainText('Velocity') // Past the cap: 11 visible (limit-1) plus an honest "Show 5 more" diff --git a/frontend/e2e/atlas-select-group.spec.ts b/frontend/e2e/atlas-select-group.spec.ts index 4b32fe46..0949e9bc 100644 --- a/frontend/e2e/atlas-select-group.spec.ts +++ b/frontend/e2e/atlas-select-group.spec.ts @@ -207,7 +207,10 @@ test('atlas shift-click select: toggle membership, group via member right-click, await page.keyboard.press('n') const noteBB = await board.boundingBox() if (!noteBB) throw new Error('board box missing for note placement') - await page.mouse.click(noteBB.x + noteBB.width - 60, noteBB.y + noteBB.height - 80) + // Bottom-LEFT, not bottom-right: the board's own minimap (goal + // 0106 slice B) now occupies the bottom-right corner, offset off + // the true left edge to clear React Flow's own Controls strip there. + await page.mouse.click(noteBB.x + 80, noteBB.y + noteBB.height - 80) const noteTA = page.getByTestId('atlas-sticky-textarea') await noteTA.fill('ZzK2eStickySel') await noteTA.blur() diff --git a/frontend/e2e/atlas-session-restore.spec.ts b/frontend/e2e/atlas-session-restore.spec.ts index acbd85d1..e884c527 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, openCard } from './fixtures/atlasBoard' +import { clickBreadcrumbSegment, 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 @@ -55,7 +55,7 @@ test('session restore: the viewed level and open card survive a reload (goal 009 // restore that only ever re-adds state would ghost the page back. await page.keyboard.press('Escape') await expect(overlay).not.toBeVisible() - await page.getByTestId('atlas-breadcrumb').getByText('My space').click() + await clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('My space'), 'My space') await expect(page.getByTestId('atlas-breadcrumb')).not.toContainText('Example area') await page.reload() await expect(page.getByTestId('atlas-board')).toBeVisible() diff --git a/frontend/e2e/atlas-slots.spec.ts b/frontend/e2e/atlas-slots.spec.ts index a8712a52..50a35965 100644 --- a/frontend/e2e/atlas-slots.spec.ts +++ b/frontend/e2e/atlas-slots.spec.ts @@ -11,7 +11,7 @@ import { } from './fixtures/server' import { contextMenu } from './fixtures/contextMenu' import { ATLAS_KIND_TOPIC, selectKind } from './fixtures/kindPicker' -import { clickCorner, closeCard, noteCard, openCard, zoomAllTheWayOut } from './fixtures/atlasBoard' +import { clickCorner, closeCard, noteCard, openCard, submitCreatePopover, zoomAllTheWayOut } from './fixtures/atlasBoard' // 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 @@ -111,7 +111,7 @@ test('atlas typed link slots: page slot rows, hover-handle slot-drag linking, ch await expect(popover).toBeVisible() await selectKind(popover, ATLAS_KIND_TOPIC) await popover.getByTestId('atlas-placement-title').fill(title) - await popover.getByTestId('atlas-placement-submit').click() + await submitCreatePopover(popover) await expect(popover).not.toBeVisible() await expect(noteCard(page, title)).toBeVisible() } @@ -215,15 +215,18 @@ test('atlas typed link slots: page slot rows, hover-handle slot-drag linking, ch // 0.5/0.85 (bottom-center) sits under the creation tray's own // chrome, which silently swallowed this card's later right-click // (reproduced: cleanup hung waiting on a context menu that never - // opened) -- bottom-right instead, clear of both the tray and the - // top corners clickCorner already claimed for cards A/B. + // opened); bottom-right also collides now (the board's own + // minimap, goal 0106 slice B, occupies that corner) -- bottom-left + // instead, offset off the true edge to clear React Flow's own + // Controls strip there, clear of the tray and the top corners + // clickCorner already claimed for cards A/B. await dragBetween(page, { x: anchorBox.x + anchorBox.width / 2, y: anchorBox.y + anchorBox.height / 2 }, - { x: boardBox.x + boardBox.width * 0.85, y: boardBox.y + boardBox.height * 0.85 }) + { x: boardBox.x + boardBox.width * 0.15, y: boardBox.y + boardBox.height * 0.85 }) await expect(popover).toBeVisible() await selectKind(popover, ATLAS_KIND_TOPIC) await popover.getByTestId('atlas-placement-title').fill('ZzE2eSlotGuided') - await popover.getByTestId('atlas-placement-submit').click() + await submitCreatePopover(popover) await expect(popover).not.toBeVisible() await expect(noteCard(page, 'ZzE2eSlotGuided')).toBeVisible() await openCard(page, cardA) @@ -249,7 +252,7 @@ test('atlas typed link slots: page slot rows, hover-handle slot-drag linking, ch await expect(popover).toBeVisible() await selectKind(popover, ATLAS_KIND_TOPIC) await popover.getByTestId('atlas-placement-title').fill('ZzE2eSlotAddLinked') - await popover.getByTestId('atlas-placement-submit').click() + await submitCreatePopover(popover) await expect(popover).not.toBeVisible() await expect(noteCard(page, 'ZzE2eSlotAddLinked')).toBeVisible() await openCard(page, cardB) diff --git a/frontend/e2e/atlas.spec.ts b/frontend/e2e/atlas.spec.ts index 1e6fa128..466d4a52 100644 --- a/frontend/e2e/atlas.spec.ts +++ b/frontend/e2e/atlas.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from './fixtures/server' import { deleteViaPageMenu } from './fixtures/atlasPage' import { ATLAS_KIND_TOPIC, selectKind } from './fixtures/kindPicker' -import { openCard } from './fixtures/atlasBoard' +import { clickBreadcrumbSegment, 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 @@ -92,7 +92,7 @@ test('the seeded single root auto-enters "My space"; drilling into a region fram // Explicit back: the "My space" crumb (there is no "All spaces" one // to fall back to further) returns to the auto-entered root. - await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() + await clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }), 'My space') await expect(page.getByTestId('atlas-breadcrumb')).not.toContainText('Example area') await expect(exampleArea).toBeVisible() }) @@ -348,7 +348,7 @@ test('a sibling card created into a Free-mode space lands clear of both leaf not await page.getByTestId('atlas-create-title').fill(title) await page.getByRole('button', { name: 'Create' }).click() - await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() + await clickBreadcrumbSegment(page, page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }), 'My space') const newCard = noteCard(page, title) await expect(newCard).toBeVisible() diff --git a/frontend/e2e/command-palette.spec.ts b/frontend/e2e/command-palette.spec.ts index 029c71e9..38e2dc9b 100644 --- a/frontend/e2e/command-palette.spec.ts +++ b/frontend/e2e/command-palette.spec.ts @@ -240,6 +240,7 @@ test('every new Atlas command is reachable from the palette by its label', async 'Add cards from a folder', 'Copy space as context', 'Copy space links', + 'Toggle minimap', ] for (const label of labels) { await page.keyboard.press('Meta+/') diff --git a/frontend/e2e/fixtures/atlasBoard.ts b/frontend/e2e/fixtures/atlasBoard.ts index 0ae496db..ded5b4db 100644 --- a/frontend/e2e/fixtures/atlasBoard.ts +++ b/frontend/e2e/fixtures/atlasBoard.ts @@ -33,13 +33,14 @@ export async function zoomAllTheWayOut(page: Page): Promise { // bottom-center) -- use each corner for AT MOST ONE placement per // test file so a fixed screen pixel's flow-space mapping never // collides with something the test itself just created there. -export async function clickCorner(board: Locator, corner: 'top-left' | 'top-right' | 'bottom-right'): Promise { +// Deliberately no 'bottom-right' option: the board's own minimap +// (goal 0106 slice B) now occupies that corner, so it's never a +// reliably-empty click target -- callers needing a bottom-right-ish +// point compute their own, clear of the minimap's footprint. +export async function clickCorner(board: Locator, corner: 'top-left' | 'top-right'): Promise { const box = await board.boundingBox() if (!box) throw new Error('board has no bounding box') - const position = - corner === 'top-left' ? { x: 12, y: 12 } - : corner === 'top-right' ? { x: box.width - 12, y: 12 } - : { x: box.width - 12, y: box.height - 12 } + const position = corner === 'top-left' ? { x: 12, y: 12 } : { x: box.width - 12, y: 12 } await board.click({ position }) } @@ -94,11 +95,27 @@ export async function armAndPlaceTopicCard(page: Page, board: Locator, popover: await expect(popover).toBeVisible() await selectKind(popover, ATLAS_KIND_TOPIC) await popover.getByTestId('atlas-placement-title').fill(title) - await popover.getByTestId('atlas-placement-submit').click() + await submitCreatePopover(popover) await expect(popover).not.toBeVisible() await expect(noteCard(page, title)).toBeVisible() } +// The placement popover's own 'create' mode carries no visible Submit/ +// Cancel row (goal 0106 slice B contract item 4: Enter/Escape are the +// only commit/cancel paths, matching the C -> click -> title -> Enter +// loop itself) -- every test driving 'create' mode (the tray/right- +// click/paste/slot-guided doors) confirms this way now. 'promote'/ +// 'area' mode keep their own labeled form + button row untouched, so +// tests in those modes keep clicking atlas-placement-submit/-cancel +// directly rather than using these two helpers. +export async function submitCreatePopover(popover: Locator): Promise { + await popover.getByTestId('atlas-placement-title').press('Enter') +} + +export async function cancelCreatePopover(popover: Locator): Promise { + await popover.getByTestId('atlas-placement-title').press('Escape') +} + // Instant, no confirm (goal 0093's quick-delete-with-undo guard) -- // the card vanishes as soon as the menu item is clicked. export async function deleteCardViaMenu(page: Page, menu: Locator, title: string): Promise { @@ -107,3 +124,19 @@ export async function deleteCardViaMenu(page: Page, menu: Locator, title: string await menu.getByText('Delete', { exact: true }).click() await expect(noteCard(page, title)).toHaveCount(0) } + +// A breadcrumb segment now opens a SIBLING dropdown on click (goal +// 0106 slice B contract item 5) rather than navigating directly -- the +// segment's own place is always present in that dropdown, marked +// selected, so clicking IT there reproduces the crumb's old direct- +// navigate-on-click behavior. `segment` is whatever Locator the caller +// already built (its own `.nth()`/`.first()`/`{ exact }` disambiguation +// among same-titled crumbs stays exactly as before); `label` is that +// same segment's own title, used to find its entry inside the freshly- +// opened, scoped dropdown -- unambiguous there regardless of how many +// OTHER crumbs in the trail share that title, since only ONE level's +// siblings are ever listed at a time. +export async function clickBreadcrumbSegment(page: Page, segment: Locator, label: string): Promise { + await segment.click() + await page.getByTestId('atlas-breadcrumb-siblings').getByText(label, { exact: true }).click() +} diff --git a/frontend/e2e/help-overlay.spec.ts b/frontend/e2e/help-overlay.spec.ts index b949cbd8..9da49a3a 100644 --- a/frontend/e2e/help-overlay.spec.ts +++ b/frontend/e2e/help-overlay.spec.ts @@ -127,3 +127,32 @@ test('the overlay shows hint chips for the new Atlas commands, and omits unbound await page.keyboard.press('Escape') }) + +test('the board keyboard-nav key table (goal 0104) is fully advertised here, each unreachable via the palette', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(page.getByTestId('atlas-view')).toBeVisible() + + await page.keyboard.press('?') + const dialog = helpDialog(page) + await expect(dialog).toBeVisible() + + await expect(dialog.locator('[data-command-id="atlas.focusNext"]')).toContainText('TAB') + await expect(dialog.locator('[data-command-id="atlas.focusPrevious"]')).toContainText('TAB') + await expect(dialog.locator('[data-command-id="atlas.focusDirection"]')).toContainText('⌥→') + await expect(dialog.locator('[data-command-id="atlas.openFocused"]')).toContainText('↩') + await expect(dialog.locator('[data-command-id="atlas.nudgeSelection"]')).toContainText('→') + await expect(dialog.locator('[data-command-id="atlas.escapeLadder"]')).toBeVisible() + + await page.keyboard.press('Escape') + + // paletteHidden (real handling stays in useAtlasKeyboardNav.ts's own + // window listener; a palette click would be a dead click since none + // of these have a live board target the palette can supply). + await page.keyboard.press('Meta+/') + const palette = page.getByRole('dialog', { name: 'Command palette' }) + await expect(palette).toBeVisible() + await palette.getByRole('combobox').fill('Focus next card') + await expect(palette.getByRole('option', { name: 'Focus next card' })).toHaveCount(0) + await page.keyboard.press('Escape') +}) diff --git a/frontend/src/atlas/AtlasBoard.tsx b/frontend/src/atlas/AtlasBoard.tsx index 37c7f0c0..3e75002f 100644 --- a/frontend/src/atlas/AtlasBoard.tsx +++ b/frontend/src/atlas/AtlasBoard.tsx @@ -26,6 +26,9 @@ import { useAtlasSelection } from './useAtlasSelection' import { useAtlasSelectAll } from './useAtlasSelectAll' import { useAtlasSelectionTray } from './useAtlasSelectionTray' import { useAtlasKeyboardNav } from './useAtlasKeyboardNav' +import { useAtlasMinimapToggle } from './useAtlasMinimapToggle' +import { AtlasBoardMinimapButton } from './AtlasBoardMinimapButton' +import { ThemedMiniMap } from '../shared/ThemedMiniMap' import { useAtlasSlotDrag } from './useAtlasSlotDrag' import { AtlasSlotDragLine } from './AtlasSlotDragLine' import { buildBoardCardNodes } from './atlasBuildBoardNodes' @@ -291,6 +294,8 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare const { trayRef, hasSelection: haveSelection, onGroup: onTrayGroup, onDelete: onTrayDelete } = useAtlasSelectionTray({ selectedCards: selection.selectedCards, selectedNotes: selection.selectedNotes, clearSelection: selection.clearSelection, setNodes, onDeleteSelection, onGroupSelection, wrapperRef }) + const minimap = useAtlasMinimapToggle() + useAtlasKeyboardNav({ cards, readOnly, wrapperRef, cardBoxes: topLevelBoxes, noteBoxes, @@ -451,7 +456,10 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare fitViewOptions={{ maxZoom: 1, padding: 0.25, duration: reduceMotion ? 0 : 250 }} > - + + + + {minimap.visible && } {marqueeStyle &&
} {slotDrag.dragLine && } diff --git a/frontend/src/atlas/AtlasBoardMinimapButton.tsx b/frontend/src/atlas/AtlasBoardMinimapButton.tsx new file mode 100644 index 00000000..2bf4b081 --- /dev/null +++ b/frontend/src/atlas/AtlasBoardMinimapButton.tsx @@ -0,0 +1,20 @@ +import { ControlButton } from '@xyflow/react' +import { AppsIcon } from '@primer/octicons-react' + +// The board's own minimap on/off button (goal 0106 slice B contract +// item 5) -- split out of AtlasBoard.tsx at the 500-line seam +// (architecture.md). Must render as a DIRECT child of React Flow's +// to seat in its own zoom/fit-view button cluster; the +// ThemedMiniMap panel itself renders as ReactFlow's own sibling, not +// nested here, so AtlasBoard.tsx still renders that half directly. +export function AtlasBoardMinimapButton({ visible, onToggle, ariaLabel }: { + visible: boolean + onToggle: () => void + ariaLabel: string +}) { + return ( + + + + ) +} diff --git a/frontend/src/atlas/AtlasBreadcrumb.tsx b/frontend/src/atlas/AtlasBreadcrumb.tsx index 8d7b878a..91008116 100644 --- a/frontend/src/atlas/AtlasBreadcrumb.tsx +++ b/frontend/src/atlas/AtlasBreadcrumb.tsx @@ -1,7 +1,8 @@ +import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { Breadcrumbs } from '@primer/react' +import { ActionList, AnchoredOverlay, Breadcrumbs } from '@primer/react' import type { Card } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' -import { buildBreadcrumbPath, singleRootCard } from './atlasGrouping' +import { buildBreadcrumbPath, childrenOf, singleRootCard } from './atlasGrouping' // root -> ... -> current, every ancestor clickable (docs/goals/0061): // Primer's own Breadcrumbs (frontend.md: adopt a kit component over a @@ -10,7 +11,9 @@ import { buildBreadcrumbPath, singleRootCard } from './atlasGrouping' // its own "All spaces" crumb when 2+ root cards actually exist to // choose between (egocentric-root auto-entry, goal 0069) -- with // exactly one root card, that card IS the top and the path already -// starts there. +// starts there. The meta crumb stays a plain navigate-on-click link +// (goal 0106 slice B): it names no single real place with siblings of +// its own, unlike every other segment below. export function AtlasBreadcrumb({ cards, viewedID, onNavigate }: { cards: Card[] viewedID: string @@ -38,16 +41,62 @@ export function AtlasBreadcrumb({ cards, viewedID, onNavigate }: { )} {path.map((card) => ( - { e.preventDefault(); onNavigate(card.ID) }} - > - {card.Title} - + ))}
) } + +// Each real segment is a dropdown trigger (goal 0106 slice B contract +// item 5, "structural context" lateral moves): its own place is always +// among the siblings it lists, marked selected -- clicking IT there +// reproduces the crumb's own former direct-navigate-on-click behavior, +// so the menu interposes lateral choice without removing the original +// capability. Siblings share this segment's ParentID -- the same set +// AtlasView's own childrenOf(allCards, viewedID) already computes for +// the board itself, applied one level up the chain here. +function AtlasBreadcrumbSegment({ card, cards, selected, onNavigate }: { + card: Card + cards: Card[] + selected: boolean + onNavigate: (id: string) => void +}) { + const { t } = useTranslation('atlas') + const [open, setOpen] = useState(false) + const siblings = childrenOf(cards, card.ParentID) + + return ( + setOpen(true)} + onClose={() => setOpen(false)} + renderAnchor={(anchorProps) => ( + { anchorProps.onClick?.(e); e.preventDefault() }} + > + {card.Title} + + )} + overlayProps={{ 'aria-label': t('breadcrumbSiblingsAriaLabel'), 'data-testid': 'atlas-breadcrumb-siblings' } as never} + > + + {siblings.map((sibling) => ( + { setOpen(false); onNavigate(sibling.ID) }} + > + {sibling.Title} + {t('board.cardsCount', { count: childrenOf(cards, sibling.ID).length })} + + ))} + + + ) +} diff --git a/frontend/src/atlas/AtlasCardActions.module.css b/frontend/src/atlas/AtlasCardActions.module.css index ab862f23..53295a7a 100644 --- a/frontend/src/atlas/AtlasCardActions.module.css +++ b/frontend/src/atlas/AtlasCardActions.module.css @@ -21,3 +21,12 @@ text-overflow: ellipsis; white-space: nowrap; } + +/* The bare "+ Add action" invitation (goal 0106 slice B contract item + 3): quiet, muted text -- never competing with real content, matching + AtlasCardPage.module.css's own .quietAdd for the fields column. */ +.quietAdd { + color: var(--fgColor-muted); + align-self: flex-start; + padding-left: 0; +} diff --git a/frontend/src/atlas/AtlasCardActions.tsx b/frontend/src/atlas/AtlasCardActions.tsx index 41411657..d9ed70fa 100644 --- a/frontend/src/atlas/AtlasCardActions.tsx +++ b/frontend/src/atlas/AtlasCardActions.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Button, FormControl, IconButton, Text } from '@primer/react' +import { Button, IconButton, Text } from '@primer/react' import { XIcon } from '@primer/octicons-react' import { AtlasService, CompositionService } from '../shared/bindings' import { EntityRefField } from '../configure/EntityRefField' @@ -15,6 +15,16 @@ import styles from './AtlasCardActions.module.css' // declared-Attributes convention trigger-atlas-card fires with // (cardId/kindId/cardTitle, changeType "action"); the hint line makes // that contract visible instead of implicit. +// +// Collapses to its bare add row when empty (goal 0106 slice B contract +// item 3): zero actions means no heading, no hint text, just the one +// "+ Add action" invitation; the heading/hint return the instant a +// first action exists. This is a LIVE condition on actionWorkflowIDs +// itself, not a one-time click-to-reveal state -- removing the last +// action collapses the block back down immediately, unlike the other +// page fields' sticky-once-expanded behavior (AtlasCardPageFields.tsx), +// since there's no "value that would be lost" here to preserve +// visibility for. export function AtlasCardActions({ cardID, actionWorkflowIDs, onActionsChanged }: { cardID: string actionWorkflowIDs: string[] @@ -49,30 +59,30 @@ export function AtlasCardActions({ cardID, actionWorkflowIDs, onActionsChanged } if (next !== actionWorkflowIDs) onActionsChanged(next) } + const hasActions = actionWorkflowIDs.length > 0 + return ( - - {t('overlay.actionsLabel')} -
- {actionWorkflowIDs.map((id) => ( -
- {labels.get(id) ?? id} - {runState[id] === 'started' && {t('overlay.runStarted')}} - {runState[id] === 'error' && {t('overlay.actionRunError')}} - - remove(id)} /> -
- ))} - {adding ? ( - - ) : ( - - )} - {t('overlay.actionsHint')} -
-
+ remove(id)} /> + + ))} + {adding ? ( + + ) : ( + + )} + {hasActions && {t('overlay.actionsHint')}} + ) } diff --git a/frontend/src/atlas/AtlasCardOverlay.tsx b/frontend/src/atlas/AtlasCardOverlay.tsx index 8f7dc203..565d4076 100644 --- a/frontend/src/atlas/AtlasCardOverlay.tsx +++ b/frontend/src/atlas/AtlasCardOverlay.tsx @@ -9,6 +9,7 @@ import { useAppStore } from '../shared/store' import { atlasCardShareActions } from './atlasCardShare' import { useCardPageNav } from './atlasCardPageNav' import { AtlasCardPageHeader } from './AtlasCardPageHeader' +import { AtlasCardPropertyStrip } from './AtlasCardPropertyStrip' import { AtlasCardPageFields } from './AtlasCardPageFields' import { AtlasCardPageContents } from './AtlasCardPageContents' import { AtlasCardPageMetaRail } from './AtlasCardPageMetaRail' @@ -254,7 +255,15 @@ export function AtlasCardOverlay({ card, kinds, allCards, links, linkKinds, onCl renderBody={() => (
+ setFields((prev) => ({ ...prev, [key]: value }))} + onFieldsCommit={commitField} + /> new Set(prev).add(childID)) } + // The calm page renders this whole column ONLY when there's + // something to show (goal 0106 slice B contract item 3): no "Nothing + // inside yet" fallback -- a childless, unmirrored card's page simply + // never mounts this section, matching the acceptance bar's "no + // empty sections." Placed after every hook above so a card that + // gains/loses its last child while this page stays open (a live + // refreshAtlas() update, not a remount) never changes this + // component's own hook-call order between renders. + if (isEmpty) return null + return (
{card.MirrorPath && ( @@ -142,8 +151,6 @@ export function AtlasCardPageContents({ card, allCards, kinds, onOpenGroupEntry, {t('page.showMore', { count: hiddenCount })} )} - - {isEmpty && {t('page.emptyContents')}}
) } diff --git a/frontend/src/atlas/AtlasCardPageFields.tsx b/frontend/src/atlas/AtlasCardPageFields.tsx index bf5951ff..92ff6c8d 100644 --- a/frontend/src/atlas/AtlasCardPageFields.tsx +++ b/frontend/src/atlas/AtlasCardPageFields.tsx @@ -1,22 +1,31 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { FormControl, Text, TextInput, Textarea } from '@primer/react' +import { Button, FormControl, Text, TextInput, Textarea } from '@primer/react' import type { Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import { AtlasCardActions } from './AtlasCardActions' import { AtlasFieldsForm } from './AtlasFieldsForm' -import { isMirrorKind } from './atlasCardPageContent' +import { isMirrorKind, statusFieldOf } from './atlasCardPageContent' import runbookStyles from '../shared/ListCard.module.css' import styles from './AtlasCardPage.module.css' // The page's own editable fields (goal 0081 slice A5, LOCKED design -// §5b "read is edit"): every card-level/kind field as its typed -// control, in place -- no separate edit-mode disclosure, no Save -// button. Note/kind-Fields/Source/Mirror path/Refresh workflow each -// commit through their own onCommit* callback at the point that -// matches their control's interaction model (blur for continuous -// typing, change for a discrete select/checkbox) -- AtlasCardOverlay -// owns the actual persistence + per-field error/tick state, this -// component only wires each control to it. +// §5b "read is edit"; reworked into the calm document layout by goal +// 0106 slice B contract item 3): the Note is a borderless type-to-write +// area (a placeholder line when empty, never a labeled box); every +// OTHER optional field (a kind's declared Fields, Source, Mirror path) +// starts collapsed behind a one-line "+ Add {field}" invitation when +// it carries no value, and expands into its normal control -- focused +// -- the moment that line is clicked. A field that already has a value +// renders its control immediately, same as before. Every control still +// commits through its own onCommit* callback at the point that matches +// its interaction model (blur for continuous typing, change for a +// discrete select/checkbox) -- AtlasCardOverlay owns the actual +// persistence + per-field error/tick state, this component only wires +// each control to it. The caller keys this whole component by the +// displayed card's own id, so a chip navigation to a DIFFERENT card +// always starts every field's collapse/expand state fresh, seeded from +// that card's own values -- never carried over from whichever card's +// page was open a moment ago. export function AtlasCardPageFields({ kind, note, noteError, onNoteChange, onNoteCommit, fields, fieldErrors, onFieldsChange, onFieldsCommit, @@ -48,6 +57,37 @@ export function AtlasCardPageFields({ const { t } = useTranslation('atlas') const noteRef = useRef(null) const showMirrorFields = kind ? isMirrorKind(kind.ID) : false + // The status field renders as the property strip's own chip instead + // (AtlasCardPropertyStrip.tsx) -- excluded here so it's never shown + // twice. + const statusKey = statusFieldOf(kind)?.Key + const kindFields = (kind?.Fields ?? []).filter((f) => f.Key !== statusKey) + + const [expanded, setExpanded] = useState>(() => { + const seed = new Set() + for (const field of kindFields) if (fields[field.Key]) seed.add(field.Key) + if (source) seed.add('source') + if (mirrorPath) seed.add('mirrorPath') + return seed + }) + // The just-clicked "+ Add" line's own key, consumed by the focus + // effect below then cleared -- distinct from `expanded` itself so a + // field already expanded at mount (it already carries a value) never + // steals focus, only a field expanded BY A CLICK does. + const [justExpandedKey, setJustExpandedKey] = useState(null) + const expand = (key: string) => { + setExpanded((prev) => new Set(prev).add(key)) + setJustExpandedKey(key) + } + + useEffect(() => { + if (!justExpandedKey) return + const selector = justExpandedKey === 'source' ? '[data-testid="atlas-page-source"]' + : justExpandedKey === 'mirrorPath' ? '[data-testid="atlas-page-mirror-path"]' + : `[data-field-key="${justExpandedKey}"]` + document.querySelector(selector)?.focus() + setJustExpandedKey(null) + }, [justExpandedKey]) // Auto-grow (LOCKED design §5b's "Note (multiline, auto-growing)"): // a textarea's own scrollHeight already accounts for its wrapped @@ -61,42 +101,67 @@ export function AtlasCardPageFields({ el.style.height = `${el.scrollHeight}px` }, [note]) + const expandedKindFields = kindFields.filter((f) => expanded.has(f.Key)) + const collapsedKindFields = kindFields.filter((f) => !expanded.has(f.Key)) + return (
- - {t('overlay.noteLabel')} -