From 9ef96a479c5f7bdf8a72d1c13f8763a17cda4316 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 05:54:25 -0400 Subject: [PATCH 1/4] feat: Atlas perspectives -- Compare diff view, seeded reference architecture, MCP perspective param (goal 0095 slice 3, ADR-0041) Compare view (AtlasPerspectiveCompareDialog): a "Compare perspectives..." row in the switcher popover opens a From/To diff over the bound DiffPerspectives wrapper (AtlasService.DiffPerspectives -> the pure atlas.DiffPerspectives helper), rendering added/removed cards and links in titled, counted groups, "No changes" only when all four are empty. Seeded example: a "System landscape" card (Web app/Data store/Sync service) with three seeded perspectives -- Current, Interim, Target -- telling the migration story via which links are members, proving the O(1) property live. MCP: atlas_search_cards/atlas_read_card gain an optional `perspective` name-or-id param, additive, filtering through FilterByPerspective. A handful of pre-existing e2e specs needed fixing/updating alongside the seed addition: exact top-level card counts (coverage stat, select-all) needed bumping by one, and two board-geometry-fragile tests (a stale-position dblclick, a frame-gutter click by fixed pixel offset) needed the same robustness idioms already used elsewhere in this suite (viewport/element stability polling, fraction-based clicks) now that the board's own fit-to-view scale shifts with the seeded card count. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- .../mill/internal/domain/atlas/index.ts | 1 + .../mill/internal/domain/atlas/models.ts | 17 + .../services/atlassvc/atlasservice.ts | 13 + frontend/e2e/atlas-authoring.spec.ts | 8 +- frontend/e2e/atlas-gestures.spec.ts | 32 +- frontend/e2e/atlas-perspectives.spec.ts | 100 +++- frontend/e2e/atlas-projections.spec.ts | 18 +- frontend/e2e/atlas-select-group.spec.ts | 27 +- .../AtlasPerspectiveCompareDialog.module.css | 15 + .../atlas/AtlasPerspectiveCompareDialog.tsx | 149 ++++++ .../src/atlas/AtlasPerspectiveSwitcher.tsx | 31 +- frontend/src/atlas/AtlasToolbar.tsx | 12 +- frontend/src/atlas/AtlasView.tsx | 2 + frontend/src/locales/en/atlas.json | 14 +- internal/domain/atlas/builtin.go | 142 ++++- .../services/atlassvc/atlasperspective.go | 21 + .../atlassvc/atlasperspective_test.go | 88 +++ .../services/atlassvc/atlasservice_builtin.go | 7 +- .../services/mcpsvc/millmcpservice_atlas.go | 83 ++- .../millmcpservice_atlas_perspective_test.go | 111 ++++ internal/services/seeding/seed_fingerprint.go | 4 - .../services/seeding/seed_fingerprints.json | 505 ++++++++++-------- 22 files changed, 1097 insertions(+), 303 deletions(-) create mode 100644 frontend/src/atlas/AtlasPerspectiveCompareDialog.module.css create mode 100644 frontend/src/atlas/AtlasPerspectiveCompareDialog.tsx create mode 100644 internal/services/mcpsvc/millmcpservice_atlas_perspective_test.go diff --git a/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/index.ts index 681db6d6..370d8552 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/index.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/index.ts @@ -16,5 +16,6 @@ export type { MirrorContent, Note, Perspective, + PerspectiveDiff, Position } from "./models.js"; diff --git a/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/models.ts index 8929d97e..fefac9c2 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/models.ts @@ -331,6 +331,23 @@ export interface Perspective { "Seed": seedorigin$0.Origin; } +/** + * PerspectiveDiff is the computable diff between two perspectives + * sharing the same live card set (ADR-0041's O(1) reference- + * architecture property, docs/goals/0095): pure set difference over + * stored membership, nothing derived or stored on disk. Re-parenting + * is deliberately never expressed here -- a card carries exactly one + * ParentID shared by every perspective, so containment itself can + * never differ per view (ADR-0041's own invariant; a revisit needs its + * own ADR). + */ +export interface PerspectiveDiff { + "AddedCardIDs": string[] | null; + "RemovedCardIDs": string[] | null; + "AddedLinkIDs": string[] | null; + "RemovedLinkIDs": string[] | null; +} + /** * Position is a card's location within its PARENT's canvas -- only * meaningful when the parent's EffectiveViewMode is ViewModeCanvas; diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts index f505b28f..10d07a2b 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts @@ -260,6 +260,19 @@ export function DetectSyncRoots(): $CancellablePromise { return $Call.ByID(2360667167); } +/** + * DiffPerspectives computes what changed moving from fromID's own + * membership to toID's (goal 0095 slice 3's Compare view): a thin + * bound wrapper over the pure atlas.DiffPerspectives helper, the same + * "expose the domain package's own pure function through a read-only + * accessor" shape Perspectives() itself already takes. Re-parenting is + * deliberately never expressed here -- ADR-0041's own invariant, since + * a card carries exactly one ParentID shared by every perspective. + */ +export function DiffPerspectives(fromID: string, toID: string): $CancellablePromise { + return $Call.ByID(3383492038, fromID, toID); +} + /** * ExportAtlas serializes the whole Atlas graph as an indented, portable * JSON string -- share it, commit it to git, or import it into another diff --git a/frontend/e2e/atlas-authoring.spec.ts b/frontend/e2e/atlas-authoring.spec.ts index e340b915..c931f335 100644 --- a/frontend/e2e/atlas-authoring.spec.ts +++ b/frontend/e2e/atlas-authoring.spec.ts @@ -81,11 +81,15 @@ test('atlas creation core: tray, placement popover, right-click create, sticky n await expect(page.getByTestId('atlas-jump-no-matches')).toBeVisible() await page.keyboard.press('Escape') + // "My space" seeds four children (goal 0095 slice 3 added "System + // landscape" alongside Getting started/Example area/Scratchpad) -- + // a hand-countable 1/4 linked, 0/4 mirrored (same census + // atlas-projections.spec.ts's own coverage test pins). await page.getByTestId('atlas-open-coverage').click() const coverageDialog = page.locator('[data-component="atlas-coverage-dialog"]') await expect(coverageDialog).toBeVisible() - await expect(coverageDialog.getByTestId('atlas-coverage-link-value')).toHaveText('1/3 linked') - await expect(coverageDialog.getByTestId('atlas-coverage-mirror-value')).toHaveText('0/3 mirrored') + await expect(coverageDialog.getByTestId('atlas-coverage-link-value')).toHaveText('1/4 linked') + await expect(coverageDialog.getByTestId('atlas-coverage-mirror-value')).toHaveText('0/4 mirrored') await page.keyboard.press('Escape') await expect(coverageDialog).not.toBeVisible() diff --git a/frontend/e2e/atlas-gestures.spec.ts b/frontend/e2e/atlas-gestures.spec.ts index c69dd3ab..c49c0c64 100644 --- a/frontend/e2e/atlas-gestures.spec.ts +++ b/frontend/e2e/atlas-gestures.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from './fixtures/server' import { groupCard, noteCard } from './fixtures/atlasCards' import { clickCorner, zoomAllTheWayOut } from './fixtures/atlasBoard' import { contextMenu } from './fixtures/contextMenu' +import { clickAtFraction, waitForViewportStable } from './fixtures/animation' // The click model (goal 0102's gesture table) + surface-scoped // shortcuts (goal 0071 slice): plain click selects/replaces, a second @@ -87,9 +88,28 @@ test('a plain click leaves the selection ring visibly showing on the clicked car 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() - + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + // The board's own initial fitView animates into place, and + // individual note cards carry their own entrance transition + // independent of the viewport's own pan/zoom transform -- a + // dblclick fired before BOTH settle can land on a still-moving + // neighbor instead of the intended card (regression: "Ada + // Lovelace"'s node intercepted a dblclick meant for "Getting + // started" while its own entrance animation was still running). + await waitForViewportStable(board) const getting = noteCard(page, 'Getting started') + let previousBox: string | null = null + await expect + .poll(async () => { + const box = await getting.boundingBox() + const current = box ? `${box.x},${box.y}` : null + const stable = previousBox !== null && current === previousBox + previousBox = current + return stable + }) + .toBe(true) + await getting.dblclick() const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() @@ -98,9 +118,13 @@ test('a real double-click reproduces the same select-then-commit outcome as two await expect(overlay).not.toBeVisible() // Frame body double-click = zoom into the place (padding strip: - // the frame centre belongs to its preview-child nodes). + // the frame centre belongs to its preview-child nodes). A FRACTION + // of the frame's own rendered box (not a fixed pixel offset, same + // idiom atlas-page.spec.ts's identical gutter click already uses) + // stays inside the gutter regardless of the board's own zoom scale, + // which shifts with the seeded card count (goal 0095 slice 3). const exampleArea = groupCard(page, 'Example area') - await exampleArea.dblclick({ position: { x: 6, y: 60 } }) + await clickAtFraction(exampleArea, 0.01, 0.5, { clickCount: 2 }) await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Example area') // ⌘↑ = one step up the depth ladder (atlas.up, Finder's enclosing- diff --git a/frontend/e2e/atlas-perspectives.spec.ts b/frontend/e2e/atlas-perspectives.spec.ts index 8ae792a1..73654f59 100644 --- a/frontend/e2e/atlas-perspectives.spec.ts +++ b/frontend/e2e/atlas-perspectives.spec.ts @@ -13,21 +13,24 @@ import { groupCard, noteCard, openCard } from './fixtures/atlasBoard' import { deleteViaPageMenu } from './fixtures/atlasPage' import { ATLAS_KIND_TOPIC, selectKind } from './fixtures/kindPicker' -// Perspectives (ADR-0041, goal 0095 slice 2): the switcher (which -// absorbed the old Lens popover), board membership filtering incl. the -// ancestry-closure and link rules, arrange-disabled-while-active, -// authoring-adds-to-active, and membership editing from both the -// board's context menu and the card page. The active perspective is -// GLOBAL Atlas session state (AtlasSessionState.activePerspectiveID) -// and every perspective record is read by every board render -- its -// own dedicated server pair (fixtures/server.ts's ATLAS_PERSPECTIVES_* -// ports), never the shared worker pool (testing.md's shared-vs- -// dedicated rule). +// Perspectives (ADR-0041, goal 0095): the switcher (which absorbed the +// old Lens popover), board membership filtering incl. the ancestry- +// closure and link rules, arrange-disabled-while-active, authoring- +// adds-to-active, membership editing from both the board's context +// menu and the card page (slice 2), and the Compare diff view over the +// seeded reference-architecture example (slice 3). The active +// perspective is GLOBAL Atlas session state +// (AtlasSessionState.activePerspectiveID) and every perspective record +// is read by every board render -- its own dedicated server pair +// (fixtures/server.ts's ATLAS_PERSPECTIVES_* ports), never the shared +// worker pool (testing.md's shared-vs-dedicated rule). // // Runs against the seeded "My space" tree (internal/domain/atlas/ -// builtin.go): My space (root) holds Getting started, Scratchpad, and -// Example area (which holds Ada Lovelace and Project charter); a -// seeded link connects Getting started -> Ada Lovelace. +// builtin.go): My space (root) holds Getting started, Scratchpad, +// Example area (which holds Ada Lovelace and Project charter), and +// System landscape (which holds Web app/Data store/Sync service, the +// seeded Current/Interim/Target perspectives' own scope); a seeded +// link connects Getting started -> Ada Lovelace. async function withServer(testInfo: { parallelIndex: number }, run: (page: Awaited>) => Promise): Promise { const idx = testInfo.parallelIndex @@ -68,7 +71,11 @@ async function createPerspective(page: import('@playwright/test').Page, name: st // eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture. test('create, switch, and rename a perspective via the switcher', async ({}, testInfo) => { await withServer(testInfo, async (page) => { - await createPerspective(page, 'Current') + // "Draft", never "Current" -- the seeded reference-architecture + // example (goal 0095 slice 3) already seeds a perspective named + // "Current", and this test's own creation must not collide with + // it in the same popover. + await createPerspective(page, 'Draft') // Switch back to All cards: the switcher label reverts and the // popover's own "All cards" row reads selected. @@ -78,12 +85,12 @@ test('create, switch, and rename a perspective via the switcher', async ({}, tes // Switch back to the created perspective by name. await switcherButton(page).click() - await switcherPopover(page).getByText('Current', { exact: true }).click() - await expect(switcherButton(page)).toHaveText('Current') + await switcherPopover(page).getByText('Draft', { exact: true }).click() + await expect(switcherButton(page)).toHaveText('Draft') // Rename inline via the row's own right-click menu. await switcherButton(page).click() - await switcherPopover(page).getByText('Current', { exact: true }).click({ button: 'right' }) + await switcherPopover(page).getByText('Draft', { exact: true }).click({ button: 'right' }) await expect(contextMenu(page)).toBeVisible() await contextMenu(page).getByText('Rename', { exact: true }).click() const renameInput = page.getByTestId('atlas-perspective-rename-input') @@ -236,3 +243,62 @@ test('membership removes via the board context menu, and deleting a perspective await expect(noteCard(page, 'Scratchpad')).toBeVisible() }) }) + +// The seeded reference-architecture example (goal 0095 slice 3, +// internal/domain/atlas/builtin.go's BuiltInPerspectives): a "System +// landscape" card (a direct child of "My space", never disturbing the +// pre-existing top-level census other specs pin -- e.g. +// atlas-projections.spec.ts's coverage stat) holding "Web app"/"Data +// store"/"Sync service", with three seeded perspectives -- "Current" +// (app + data store, wired directly), "Interim" (adds the sync +// service alongside the old link), "Target" (the old direct link is +// gone, only the new shape remains). +// eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture. +test('the seeded reference-architecture example renders with no regression to the default view, and Compare shows the Current -> Target diff', async ({}, testInfo) => { + await withServer(testInfo, async (page) => { + // No regression: every pre-existing seeded card still renders + // alongside the new "System landscape" card. + await expect(noteCard(page, 'Getting started')).toBeVisible() + await expect(noteCard(page, 'Scratchpad')).toBeVisible() + await expect(groupCard(page, 'Example area')).toBeVisible() + const landscape = groupCard(page, 'System landscape') + await expect(landscape).toBeVisible() + + // The seeded perspectives are scoped to "System landscape" -- drill + // in, then switch to "Interim": all three landscape cards render. + await landscape.getByTestId('atlas-group-header').click() + await expect(page.getByTestId('atlas-breadcrumb')).toContainText('System landscape') + + await switcherButton(page).click() + await switcherPopover(page).getByText('Interim', { exact: true }).click() + await expect(switcherButton(page)).toHaveText('Interim') + await expect(noteCard(page, 'Web app')).toBeVisible() + await expect(noteCard(page, 'Data store')).toBeVisible() + await expect(noteCard(page, 'Sync service')).toBeVisible() + + // Compare Current -> Target: Sync service is the only added card, + // the old direct link is the only removed link, and the new + // shape's two links are added. + await switcherButton(page).click() + await switcherPopover(page).getByText('Compare perspectives', { exact: false }).click() + const dialog = page.locator('[data-component="atlas-perspective-compare-dialog"]') + await expect(dialog).toBeVisible() + await dialog.getByTestId('atlas-compare-from').selectOption({ label: 'Current' }) + await dialog.getByTestId('atlas-compare-to').selectOption({ label: 'Target' }) + + const results = dialog.getByTestId('atlas-compare-results') + await expect(results.getByText('Added cards (1)', { exact: true })).toBeVisible() + await expect(results.getByTestId('atlas-compare-card-row').filter({ hasText: 'Sync service' })).toBeVisible() + await expect(results.getByText('Added links (2)', { exact: true })).toBeVisible() + await expect(results.getByText('Removed links (1)', { exact: true })).toBeVisible() + await expect( + results.getByTestId('atlas-compare-link-row').filter({ hasText: 'Web app → Data store (relates to)' }), + ).toBeVisible() + // "Removed cards" stays empty (every Current card is also a Target + // member) -- an empty group is omitted entirely, never rendered. + await expect(dialog.getByText('Removed cards', { exact: false })).toHaveCount(0) + + await page.keyboard.press('Escape') + await expect(dialog).not.toBeVisible() + }) +}) diff --git a/frontend/e2e/atlas-projections.spec.ts b/frontend/e2e/atlas-projections.spec.ts index 1b4afce9..fa5409f4 100644 --- a/frontend/e2e/atlas-projections.spec.ts +++ b/frontend/e2e/atlas-projections.spec.ts @@ -96,18 +96,20 @@ test('coverage counts a space\'s cards missing a link and missing a mirror, with await page.getByRole('link', { name: 'Atlas' }).click() await expect(page.getByTestId('atlas-board')).toBeVisible() - // "My space" has three seeded children: "Getting started" (an - // outgoing "relates to" link to "Ada Lovelace"), "Example area" and - // "Scratchpad" (neither carries a link of its own) -- a hand- - // countable 1/3 linked. None of the three carries a mirror directly - // at THIS level (the seeded mirror lives one level deeper, on - // "Project charter") -- a hand-countable 0/3 mirrored. + // "My space" has four seeded children: "Getting started" (an + // outgoing "relates to" link to "Ada Lovelace"), "Example area", + // "Scratchpad", and "System landscape" (goal 0095 slice 3's seeded + // perspectives example -- none of the three carries a link of its + // own AT THIS level) -- a hand-countable 1/4 linked. None of the + // four carries a mirror directly at THIS level (the seeded mirror + // lives one level deeper, on "Project charter") -- a hand-countable + // 0/4 mirrored. await page.getByTestId('atlas-open-coverage').click() const dialog = page.locator('[data-component="atlas-coverage-dialog"]') await expect(dialog).toBeVisible() - await expect(dialog.getByTestId('atlas-coverage-link-value')).toHaveText('1/3 linked') - await expect(dialog.getByTestId('atlas-coverage-mirror-value')).toHaveText('0/3 mirrored') + await expect(dialog.getByTestId('atlas-coverage-link-value')).toHaveText('1/4 linked') + await expect(dialog.getByTestId('atlas-coverage-mirror-value')).toHaveText('0/4 mirrored') await dialog.getByTestId('atlas-coverage-link-toggle').click() await expect(dialog.getByTestId('atlas-coverage-missing-item').filter({ hasText: 'Example area' })).toBeVisible() diff --git a/frontend/e2e/atlas-select-group.spec.ts b/frontend/e2e/atlas-select-group.spec.ts index 4b32fe46..777507f1 100644 --- a/frontend/e2e/atlas-select-group.spec.ts +++ b/frontend/e2e/atlas-select-group.spec.ts @@ -308,10 +308,11 @@ test('atlas shift-click select: toggle membership, group via member right-click, await expect(page.getByRole('button', { name: 'Dissolve' })).toBeVisible() await page.getByRole('button', { name: 'Dissolve' }).click() await expect(bareGArea).toHaveCount(0) - // Low-right pane coords, matching this file's own later deselect -- - // absolute page coords near the left edge can land on a tray/menu - // instead of React Flow's own pane handler. - await page.locator('.react-flow__pane').click({ position: { x: 500, y: 450 } }) + // Escape clears the selection (proven earlier in this same test) -- + // a fixed board-content-independent deselect, unlike an absolute + // pane coordinate, which a seeded card landing under that exact + // pixel can turn into a re-select instead of a deselect. + await page.keyboard.press('Escape') await expect(selected).toHaveCount(0) await cardA.click({ modifiers: ['Shift'] }) await cardB.click({ modifiers: ['Shift'] }) @@ -369,10 +370,9 @@ test('atlas shift-click select: toggle membership, group via member right-click, await expect(page.getByRole('button', { name: 'Dissolve' })).toBeVisible() await page.getByRole('button', { name: 'Dissolve' }).click() await expect(groupedArea).toHaveCount(0) - // Deselect on the pane itself, low-right where nothing renders -- - // absolute page coords near the left edge can land on the - // creation tray, which never reaches React Flow's pane handler. - await page.locator('.react-flow__pane').click({ position: { x: 500, y: 450 } }) + // Escape clears the selection -- same fixed, content-position- + // independent deselect as this file's earlier bare-G dissolve. + await page.keyboard.press('Escape') await expect(selected).toHaveCount(0) // Quick delete + undo (goal 0093): Del deletes instantly, no @@ -467,14 +467,15 @@ test('atlas select-all (Cmd+A): guarded inside an editable field, selects every await page.keyboard.press('Escape') // Real dispatch: Cmd+A on the board selects EVERY top-level card at - // this level -- the seeded root ("My space") already carries 3 - // (Example area, Getting started, Scratchpad; internal/domain/atlas/builtin.go), - // plus the 2 just placed. + // this level -- the seeded root ("My space") already carries 4 + // (Example area, Getting started, Scratchpad, System landscape -- + // goal 0095 slice 3; internal/domain/atlas/builtin.go), plus the 2 + // just placed. await page.keyboard.press('Meta+a') - await expect(selected).toHaveCount(5) + await expect(selected).toHaveCount(6) const selectionTray = page.getByTestId('atlas-selection-tray') await expect(selectionTray).toBeVisible() - await expect(page.getByTestId('atlas-selection-count')).toHaveText('5 selected') + await expect(page.getByTestId('atlas-selection-count')).toHaveText('6 selected') // Cleanup (testing.md's within-file discipline): quick delete + // clock-controlled toast expiry, same pattern this file's other diff --git a/frontend/src/atlas/AtlasPerspectiveCompareDialog.module.css b/frontend/src/atlas/AtlasPerspectiveCompareDialog.module.css new file mode 100644 index 00000000..ccda2fbf --- /dev/null +++ b/frontend/src/atlas/AtlasPerspectiveCompareDialog.module.css @@ -0,0 +1,15 @@ +/* Same glyph shape as AtlasJumpDialog.module.css's own -- a small + kind-colored initial badge identifying a diff row's card at a + glance. */ +.glyph { + flex: 0 0 auto; + width: 14px; + height: 14px; + border-radius: 4px; + color: #fff; + font-size: 9px; + font-weight: 650; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/frontend/src/atlas/AtlasPerspectiveCompareDialog.tsx b/frontend/src/atlas/AtlasPerspectiveCompareDialog.tsx new file mode 100644 index 00000000..16299b3f --- /dev/null +++ b/frontend/src/atlas/AtlasPerspectiveCompareDialog.tsx @@ -0,0 +1,149 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ActionList, Dialog, FormControl, Select, Stack, Text } from '@primer/react' +import type { Card, Kind, Link, LinkKind, Perspective, PerspectiveDiff } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import { AtlasService } from '../shared/bindings' +import { kindColorTokens } from './atlasKindColor' +import runbookStyles from '../shared/ListCard.module.css' +import styles from './AtlasPerspectiveCompareDialog.module.css' + +// The Current->Target diff view (goal 0095 slice 3, ADR-0041): read- +// only set difference between two perspectives' own membership, over +// DiffPerspectives (a thin bound wrapper around the pure +// atlas.DiffPerspectives helper -- internal/services/atlassvc/ +// atlasperspective.go). Re-parenting is deliberately never expressed +// here (the ADR's own invariant: a card carries exactly one ParentID +// shared by every perspective) -- rows never navigate in this slice. +export function AtlasPerspectiveCompareDialog({ + open, onClose, perspectives, cards, links, kinds, linkKinds, onError, +}: { + open: boolean + onClose: () => void + perspectives: Perspective[] + cards: Card[] + links: Link[] + kinds: Kind[] + linkKinds: LinkKind[] + onError: (message: string) => void +}) { + const { t } = useTranslation('atlas') + const [fromID, setFromID] = useState('') + const [toID, setToID] = useState('') + const [diff, setDiff] = useState(null) + + // Defaults to the first two perspectives IN ORDER, reset only on the + // closed->open transition -- a ref (not a `perspectives` dependency) + // so an unrelated Atlas store refresh while the dialog is already + // open never clobbers the user's own From/To choice. + const perspectivesRef = useRef(perspectives) + useEffect(() => { perspectivesRef.current = perspectives }) + useEffect(() => { + if (!open) return + const list = perspectivesRef.current + setFromID(list[0]?.ID ?? '') + setToID(list[1]?.ID ?? '') + }, [open]) + + useEffect(() => { + if (!open || !fromID || !toID) return + let cancelled = false + AtlasService.DiffPerspectives(fromID, toID) + .then((d) => { if (!cancelled) setDiff(d) }) + .catch((err) => { if (!cancelled) onError(String(err)) }) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps -- onError is a stable toast setter, not reactive state this fetch depends on. + }, [open, fromID, toID]) + + const cardByID = useMemo(() => new Map(cards.map((c) => [c.ID, c])), [cards]) + const linkByID = useMemo(() => new Map(links.map((l) => [l.ID, l])), [links]) + const kindByID = useMemo(() => new Map(kinds.map((k) => [k.ID, k])), [kinds]) + const linkKindByID = useMemo(() => new Map(linkKinds.map((lk) => [lk.ID, lk])), [linkKinds]) + + if (!open) return null + + const resolvedCards = (ids: string[] | null) => (ids ?? []).map((id) => cardByID.get(id)).filter((c): c is Card => !!c) + const resolvedLinks = (ids: string[] | null) => (ids ?? []).map((id) => linkByID.get(id)).filter((l): l is Link => !!l) + + const addedCards = diff ? resolvedCards(diff.AddedCardIDs) : [] + const removedCards = diff ? resolvedCards(diff.RemovedCardIDs) : [] + const addedLinks = diff ? resolvedLinks(diff.AddedLinkIDs) : [] + const removedLinks = diff ? resolvedLinks(diff.RemovedLinkIDs) : [] + const allEmpty = diff !== null && addedCards.length === 0 && removedCards.length === 0 && addedLinks.length === 0 && removedLinks.length === 0 + + const cardRow = (card: Card) => { + const kind = kindByID.get(card.KindID) + const tokens = kindColorTokens(card.KindID) + return ( + + + + {(kind?.Label ?? '?').charAt(0).toUpperCase()} + + + {card.Title} + + ) + } + + const linkRow = (link: Link) => ( + + {t('compare.linkRow', { + from: cardByID.get(link.FromCardID)?.Title ?? '?', + to: cardByID.get(link.ToCardID)?.Title ?? '?', + kind: linkKindByID.get(link.LinkKindID)?.Label ?? '?', + })} + + ) + + return ( + + + + + {t('compare.fromLabel')} + + + + {t('compare.toLabel')} + + + + + {allEmpty && {t('compare.noChanges')}} + + {!allEmpty && ( + + {addedCards.length > 0 && ( + + {addedCards.map(cardRow)} + + )} + {removedCards.length > 0 && ( + + {removedCards.map(cardRow)} + + )} + {addedLinks.length > 0 && ( + + {addedLinks.map(linkRow)} + + )} + {removedLinks.length > 0 && ( + + {removedLinks.map(linkRow)} + + )} + + )} + + + ) +} diff --git a/frontend/src/atlas/AtlasPerspectiveSwitcher.tsx b/frontend/src/atlas/AtlasPerspectiveSwitcher.tsx index 70998be5..4bd43216 100644 --- a/frontend/src/atlas/AtlasPerspectiveSwitcher.tsx +++ b/frontend/src/atlas/AtlasPerspectiveSwitcher.tsx @@ -2,10 +2,11 @@ import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ActionList, ActionMenu, Checkbox, FormControl, IconButton, TextInput } from '@primer/react' import { CheckIcon, EyeIcon, KebabHorizontalIcon } from '@primer/octicons-react' -import type { Kind, Perspective } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import type { Card, Kind, Link, LinkKind, Perspective } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import { useUISignalStore } from '../shared/uiSignalStore' import { ConfirmDialog } from '../shared/ConfirmDialog' import { ContextMenu, type ContextMenuState } from '../shared/ContextMenu' +import { AtlasPerspectiveCompareDialog } from './AtlasPerspectiveCompareDialog' import styles from './AtlasPerspectiveSwitcher.module.css' // The board toolbar's primary view control (ADR-0041, goal 0095 slice @@ -21,6 +22,7 @@ import styles from './AtlasPerspectiveSwitcher.module.css' export function AtlasPerspectiveSwitcher({ perspectives, activePerspectiveID, onSwitch, onCreate, onRename, onDelete, onToast, presentKinds, hiddenKindIDs, onChangeHidden, + cards, links, kinds, linkKinds, }: { perspectives: Perspective[] activePerspectiveID: string @@ -32,6 +34,14 @@ export function AtlasPerspectiveSwitcher({ presentKinds: Kind[] hiddenKindIDs: string[] onChangeHidden: (hidden: string[]) => void + // The Compare view's own full-graph data (goal 0095 slice 3) -- + // deliberately the UNFILTERED sets (never boardAllCards/presentKinds, + // both scoped to the currently viewed space), since a diff's added/ + // removed members can live anywhere in the tree. + cards: Card[] + links: Link[] + kinds: Kind[] + linkKinds: LinkKind[] }) { const { t } = useTranslation('atlas') const [open, setOpen] = useState(false) @@ -40,6 +50,7 @@ export function AtlasPerspectiveSwitcher({ const [renameDraft, setRenameDraft] = useState('') const [rowMenu, setRowMenu] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) + const [compareOpen, setCompareOpen] = useState(false) const visibleIDs = presentKinds.map((k) => k.ID).filter((id) => !hiddenKindIDs.includes(id)) const active = perspectives.find((p) => p.ID === activePerspectiveID) ?? null @@ -172,6 +183,14 @@ export function AtlasPerspectiveSwitcher({ }} /> + {perspectives.length >= 2 && ( + { setOpen(false); setCompareOpen(true) }} + > + {t('perspective.comparePerspectives')} + + )} {presentKinds.length > 0 && ( <> @@ -199,6 +218,16 @@ export function AtlasPerspectiveSwitcher({ + setCompareOpen(false)} + perspectives={perspectives} + cards={cards} + links={links} + kinds={kinds} + linkKinds={linkKinds} + onError={onToast} + /> {deleteTarget && ( Promise onDeletePerspective: (id: string) => Promise onPerspectiveToast: (message: string) => void + // The Compare view's own full-graph data (goal 0095 slice 3), + // forwarded straight through to AtlasPerspectiveSwitcher -- `cards`/ + // `kinds` above already carry the full (unfiltered) sets. + links: Link[] + linkKinds: LinkKind[] canAddSibling: boolean onCreate: (containment: 'sibling' | 'child', kindID: string, title: string) => Promise onExport: () => void @@ -129,6 +135,10 @@ export function AtlasToolbar({ presentKinds={presentKinds} hiddenKindIDs={hiddenKindIDs} onChangeHidden={onChangeHidden} + cards={cards} + links={links} + kinds={kinds} + linkKinds={linkKinds} /> diff --git a/frontend/src/atlas/AtlasView.tsx b/frontend/src/atlas/AtlasView.tsx index 7be05207..7756dffa 100644 --- a/frontend/src/atlas/AtlasView.tsx +++ b/frontend/src/atlas/AtlasView.tsx @@ -384,6 +384,8 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { onRenamePerspective={renamePerspective} onDeletePerspective={deletePerspective} onPerspectiveToast={quietToast.show} + links={allLinks} + linkKinds={allLinkKinds} canAddSibling={viewedID !== ''} onCreate={createCard} onExport={exportAtlas} diff --git a/frontend/src/locales/en/atlas.json b/frontend/src/locales/en/atlas.json index d3ff23aa..ce2fcf4b 100644 --- a/frontend/src/locales/en/atlas.json +++ b/frontend/src/locales/en/atlas.json @@ -56,7 +56,19 @@ "addedToast": "Added to {{name}}", "removedToast": "Removed from {{name}}", "membershipLabel": "In:", - "addChip": "Add to perspective" + "addChip": "Add to perspective", + "comparePerspectives": "Compare perspectives…" + }, + "compare": { + "title": "Compare perspectives", + "fromLabel": "From", + "toLabel": "To", + "addedCardsHeading": "Added cards ({{count}})", + "removedCardsHeading": "Removed cards ({{count}})", + "addedLinksHeading": "Added links ({{count}})", + "removedLinksHeading": "Removed links ({{count}})", + "noChanges": "No changes between these perspectives.", + "linkRow": "{{from}} → {{to}} ({{kind}})" }, "creationTray": { "ariaLabel": "Creation tools", diff --git a/internal/domain/atlas/builtin.go b/internal/domain/atlas/builtin.go index a023790d..2d4a7e5c 100644 --- a/internal/domain/atlas/builtin.go +++ b/internal/domain/atlas/builtin.go @@ -40,6 +40,12 @@ const ( // refused (LOCKED design §3b -- "a file card is a link path to // somewhere in the filesystem anyway"). kindReferenceID = "atlas-kind-reference" + // kindComponentID (goal 0095 slice 3) is the seeded perspectives + // example's own Kind, dedicated rather than reusing Topic so the + // three landscape cards below never join Topic's own seeded count + // (atlas-jump.spec.ts's "Topic: " scope asserts an exact census of + // the four pre-existing Topic cards). + kindComponentID = "atlas-kind-component" linkKindRelatesToID = "atlas-linkkind-relates-to" @@ -49,9 +55,26 @@ const ( cardContactID = "atlas-card-example-contact" cardDocumentID = "atlas-card-example-document" cardScratchpadID = "atlas-card-scratchpad" + // The seeded perspectives example (goal 0095 slice 3, ADR-0041): a + // tiny reference-architecture landscape nested under its own + // container (never a direct child of "My space" -- every existing + // top-level census, e.g. atlas-projections.spec.ts's coverage + // stat and atlas-scale.spec.ts's dense-fixture edge count, is + // pinned to the pre-existing shape one level up). + cardSystemLandscapeID = "atlas-card-system-landscape" + cardWebAppID = "atlas-card-web-app" + cardDataStoreID = "atlas-card-data-store" + cardSyncServiceID = "atlas-card-sync-service" linkGettingToContactID = "atlas-link-getting-to-contact" linkContactToDocumentID = "atlas-link-contact-to-document" + linkWebToStoreID = "atlas-link-web-to-store" + linkWebToSyncID = "atlas-link-web-to-sync" + linkSyncToStoreID = "atlas-link-sync-to-store" + + perspectiveCurrentID = "atlas-perspective-current" + perspectiveInterimID = "atlas-perspective-interim" + perspectiveTargetID = "atlas-perspective-target" ) // BuiltInKinds returns the seeded example card types -- pure config, @@ -112,6 +135,12 @@ func BuiltInKinds() []Kind { CreatedAt: now, UpdatedAt: now, BuiltIn: true, Seed: seedorigin.Stamp(1), }, + { + ID: kindComponentID, Label: "Component", Icon: "🧩", + Description: "A system or service in an architecture.", + CreatedAt: now, UpdatedAt: now, + BuiltIn: true, Seed: seedorigin.Stamp(1), + }, } } @@ -204,6 +233,46 @@ func BuiltInCards() []Card { CreatedAt: now, UpdatedAt: now, BuiltIn: true, Seed: seedorigin.Stamp(5), // Card gained MirrorChecksum (goal 0088) then DeletedAt (goal 0093) -- shape shifts }, + { + // The seeded perspectives example's own container (goal 0095 + // slice 3): a direct child of "My space" (keeps the single- + // root-card navigation intact) holding the three landscape + // cards below, so their own internal links stay invisible one + // level up (resolveBoardEdges.ts skips an edge whose endpoints + // resolve to the same top-level card) -- the pre-existing + // "My space" child census (atlas-projections.spec.ts's + // coverage stat, atlas-scale.spec.ts's dense-fixture edge + // count) only grows by this one new card, never by three. + ID: cardSystemLandscapeID, KindID: kindComponentID, Title: "System landscape", + Note: "Switch perspectives above to see this move from Current to Target.", + ParentID: cardMySpaceID, ViewMode: ViewModeShelves, + // 960 sits right after the seeded row's rightmost card + // (Scratchpad at 746) -- close enough that this card barely + // widens the fit-to-view bounding box other e2e specs' + // zoom-then-click-by-fraction helpers depend on; a much + // farther placement measurably shifts those fractions. + Position: &Position{X: 960, Y: 80}, + CreatedAt: now, UpdatedAt: now, + BuiltIn: true, Seed: seedorigin.Stamp(1), + }, + { + ID: cardWebAppID, KindID: kindComponentID, Title: "Web app", + ParentID: cardSystemLandscapeID, + CreatedAt: now, UpdatedAt: now, + BuiltIn: true, Seed: seedorigin.Stamp(1), + }, + { + ID: cardDataStoreID, KindID: kindComponentID, Title: "Data store", + ParentID: cardSystemLandscapeID, + CreatedAt: now, UpdatedAt: now, + BuiltIn: true, Seed: seedorigin.Stamp(1), + }, + { + ID: cardSyncServiceID, KindID: kindComponentID, Title: "Sync service", + ParentID: cardSystemLandscapeID, + CreatedAt: now, UpdatedAt: now, + BuiltIn: true, Seed: seedorigin.Stamp(1), + }, } } @@ -224,19 +293,74 @@ func BuiltInLinks() []Link { CreatedAt: now, UpdatedAt: now, BuiltIn: true, Seed: seedorigin.Stamp(1), }, + { + // Current's own link: the web app talks to the data store + // directly. + ID: linkWebToStoreID, FromCardID: cardWebAppID, ToCardID: cardDataStoreID, + LinkKindID: linkKindRelatesToID, + CreatedAt: now, UpdatedAt: now, + BuiltIn: true, Seed: seedorigin.Stamp(1), + }, + { + // Interim/Target's new shape, half one: the web app talks to + // the sync service. + ID: linkWebToSyncID, FromCardID: cardWebAppID, ToCardID: cardSyncServiceID, + LinkKindID: linkKindRelatesToID, + CreatedAt: now, UpdatedAt: now, + BuiltIn: true, Seed: seedorigin.Stamp(1), + }, + { + // Interim/Target's new shape, half two: the sync service + // relays to the data store. + ID: linkSyncToStoreID, FromCardID: cardSyncServiceID, ToCardID: cardDataStoreID, + LinkKindID: linkKindRelatesToID, + CreatedAt: now, UpdatedAt: now, + BuiltIn: true, Seed: seedorigin.Stamp(1), + }, } } -// BuiltInPerspectives returns the seeded example perspectives (ADR- -// 0041, goal 0095) -- deliberately EMPTY in this slice (the seeded -// three-perspective reference-architecture example is goal 0095's -// slice 3): the reconcile/fingerprint plumbing exists and runs on -// every startup like every other family's, it just has nothing to -// insert yet. Zero Perspectives is also the product default itself -// (the "everything" view is the absence of a record), so an empty -// slice here changes nothing observable. +// BuiltInPerspectives returns the seeded three-perspective reference- +// architecture example (ADR-0041, goal 0095 slice 3): "Current", +// "Interim", "Target" over "System landscape"'s own three Component +// cards, membership telling a migration story -- Current is the web +// app wired straight to the data store; Interim adds the sync service +// alongside that old connection; Target drops the old direct link and +// keeps only the new shape. Every card is a member of every +// perspective (the story is entirely which LINKS are visible); only +// MemberLinkIDs differs. func BuiltInPerspectives() []Perspective { - return nil + now := time.Now() + allThree := []string{cardWebAppID, cardDataStoreID, cardSyncServiceID} + return []Perspective{ + { + ID: perspectiveCurrentID, SpaceID: cardSystemLandscapeID, Name: "Current", + Description: "The web app talks straight to the data store.", + Order: 0, + MemberCardIDs: []string{cardWebAppID, cardDataStoreID}, + MemberLinkIDs: []string{linkWebToStoreID}, + CreatedAt: now, UpdatedAt: now, + BuiltIn: true, Seed: seedorigin.Stamp(1), + }, + { + ID: perspectiveInterimID, SpaceID: cardSystemLandscapeID, Name: "Interim", + Description: "The sync service comes online alongside the old connection.", + Order: 1, + MemberCardIDs: allThree, + MemberLinkIDs: []string{linkWebToStoreID, linkWebToSyncID, linkSyncToStoreID}, + CreatedAt: now, UpdatedAt: now, + BuiltIn: true, Seed: seedorigin.Stamp(1), + }, + { + ID: perspectiveTargetID, SpaceID: cardSystemLandscapeID, Name: "Target", + Description: "The old direct connection is gone; the sync service is the only path.", + Order: 2, + MemberCardIDs: allThree, + MemberLinkIDs: []string{linkWebToSyncID, linkSyncToStoreID}, + CreatedAt: now, UpdatedAt: now, + BuiltIn: true, Seed: seedorigin.Stamp(1), + }, + } } // RetiredBuiltInKindIDs names a built-in Kind ID that once shipped in diff --git a/internal/services/atlassvc/atlasperspective.go b/internal/services/atlassvc/atlasperspective.go index 51985081..235ca7aa 100644 --- a/internal/services/atlassvc/atlasperspective.go +++ b/internal/services/atlassvc/atlasperspective.go @@ -236,6 +236,27 @@ func (a *AtlasService) DeletePerspective(id string) error { return nil } +// DiffPerspectives computes what changed moving from fromID's own +// membership to toID's (goal 0095 slice 3's Compare view): a thin +// bound wrapper over the pure atlas.DiffPerspectives helper, the same +// "expose the domain package's own pure function through a read-only +// accessor" shape Perspectives() itself already takes. Re-parenting is +// deliberately never expressed here -- ADR-0041's own invariant, since +// a card carries exactly one ParentID shared by every perspective. +func (a *AtlasService) DiffPerspectives(fromID, toID string) (atlas.PerspectiveDiff, error) { + a.mu.RLock() + defer a.mu.RUnlock() + fromIdx := a.findPerspectiveLocked(fromID) + if fromIdx == -1 { + return atlas.PerspectiveDiff{}, fmt.Errorf("no perspective with id %q", fromID) + } + toIdx := a.findPerspectiveLocked(toID) + if toIdx == -1 { + return atlas.PerspectiveDiff{}, fmt.Errorf("no perspective with id %q", toID) + } + return atlas.DiffPerspectives(a.perspectives[fromIdx], a.perspectives[toIdx]), nil +} + func insertPerspectiveAt(perspectives []atlas.Perspective, idx int, p atlas.Perspective) []atlas.Perspective { if idx < 0 || idx > len(perspectives) { idx = len(perspectives) diff --git a/internal/services/atlassvc/atlasperspective_test.go b/internal/services/atlassvc/atlasperspective_test.go index 0af83809..49dbf38f 100644 --- a/internal/services/atlassvc/atlasperspective_test.go +++ b/internal/services/atlassvc/atlasperspective_test.go @@ -263,6 +263,94 @@ func TestAtlasSession_ActivePerspective_Degrades(t *testing.T) { } } +// --- Diff (goal 0095 slice 3) --- + +func TestDiffPerspectives_ReportsAddedAndRemovedMembers(t *testing.T) { + a := newBlankAtlasService(t) + k, err := a.CreateKind("Widget", "", "", nil) + if err != nil { + t.Fatalf("CreateKind: %v", err) + } + lk, err := a.CreateLinkKind("relates to", "") + if err != nil { + t.Fatalf("CreateLinkKind: %v", err) + } + root, err := a.CreateCard(k.ID, "Root", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(root): %v", err) + } + x, err := a.CreateCard(k.ID, "X", "", nil, root.ID, nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(x): %v", err) + } + y, err := a.CreateCard(k.ID, "Y", "", nil, root.ID, nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(y): %v", err) + } + + from, err := a.CreatePerspective(root.ID, "From", "") + if err != nil { + t.Fatalf("CreatePerspective(from): %v", err) + } + if _, err := a.AddToPerspective(from.ID, x.ID); err != nil { + t.Fatalf("AddToPerspective: %v", err) + } + to, err := a.CreatePerspective(root.ID, "To", "") + if err != nil { + t.Fatalf("CreatePerspective(to): %v", err) + } + if _, err := a.AddToPerspective(to.ID, y.ID); err != nil { + t.Fatalf("AddToPerspective: %v", err) + } + if _, err := a.AddToPerspective(to.ID, x.ID); err != nil { + t.Fatalf("AddToPerspective: %v", err) + } + + // Authoring-while-active is the only path that joins a LINK to a + // perspective in this slice -- activate "to" so creating the link + // here auto-joins it there, never "from". + if err := a.SetAtlasSession(AtlasSessionState{ActivePerspectiveID: to.ID}); err != nil { + t.Fatalf("SetAtlasSession: %v", err) + } + link, err := a.CreateLink(x.ID, y.ID, lk.ID, "") + if err != nil { + t.Fatalf("CreateLink: %v", err) + } + + diff, err := a.DiffPerspectives(from.ID, to.ID) + if err != nil { + t.Fatalf("DiffPerspectives: %v", err) + } + if !containsID(diff.AddedCardIDs, y.ID) || containsID(diff.AddedCardIDs, x.ID) { + t.Errorf("AddedCardIDs = %v, want exactly %q", diff.AddedCardIDs, y.ID) + } + if len(diff.RemovedCardIDs) != 0 { + t.Errorf("RemovedCardIDs = %v, want none (x stays a member of both)", diff.RemovedCardIDs) + } + // The link touches y, which only becomes a member of "to" -- so it + // only starts rendering there, an added link, never removed. + if !containsID(diff.AddedLinkIDs, link.ID) { + t.Errorf("AddedLinkIDs = %v, want %q", diff.AddedLinkIDs, link.ID) + } + if len(diff.RemovedLinkIDs) != 0 { + t.Errorf("RemovedLinkIDs = %v, want none", diff.RemovedLinkIDs) + } +} + +func TestDiffPerspectives_UnknownID_Errors(t *testing.T) { + a := newBlankAtlasService(t) + p, err := a.CreatePerspective("", "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if _, err := a.DiffPerspectives("does-not-exist", p.ID); err == nil { + t.Error("DiffPerspectives() with an unknown 'from' id = nil error, want an error") + } + if _, err := a.DiffPerspectives(p.ID, "does-not-exist"); err == nil { + t.Error("DiffPerspectives() with an unknown 'to' id = nil error, want an error") + } +} + func containsID(ids []string, id string) bool { for _, got := range ids { if got == id { diff --git a/internal/services/atlassvc/atlasservice_builtin.go b/internal/services/atlassvc/atlasservice_builtin.go index 83bf4a32..59e88327 100644 --- a/internal/services/atlassvc/atlasservice_builtin.go +++ b/internal/services/atlassvc/atlasservice_builtin.go @@ -278,10 +278,9 @@ func (a *AtlasService) reconcileLinksLocked(tombstones map[string]bool, now time // reconcilePerspectivesLocked mirrors reconcileLinksLocked's insert/ // upgrade/leave-alone-once-Modified/skip-tombstoned algorithm for -// Perspectives (goal 0095) -- a no-op today since -// atlas.BuiltInPerspectives() ships empty (slice 3 adds the seeded -// example), but runs every startup so a future addition top-ups an -// existing install exactly like every other family already does. +// Perspectives (goal 0095): tops up the seeded three-perspective +// reference-architecture example onto an existing install exactly +// like every other family already does. func (a *AtlasService) reconcilePerspectivesLocked(tombstones map[string]bool, now time.Time) bool { byID := make(map[string]int, len(a.perspectives)) for i, p := range a.perspectives { diff --git a/internal/services/mcpsvc/millmcpservice_atlas.go b/internal/services/mcpsvc/millmcpservice_atlas.go index cb9bd25b..1c633776 100644 --- a/internal/services/mcpsvc/millmcpservice_atlas.go +++ b/internal/services/mcpsvc/millmcpservice_atlas.go @@ -42,6 +42,25 @@ func (m *MillMCPService) requireAtlas() error { return nil } +// resolvePerspective looks up nameOrID against m.atlas.Perspectives(), +// by id first (exact) then by Name (case-insensitive) -- the same +// either-works contract atlas_search_cards/atlas_read_card's own +// `perspective` param documents (goal 0095 slice 3). +func (m *MillMCPService) resolvePerspective(nameOrID string) (atlas.Perspective, error) { + all := m.atlas.Perspectives() + for _, p := range all { + if p.ID == nameOrID { + return p, nil + } + } + for _, p := range all { + if strings.EqualFold(p.Name, nameOrID) { + return p, nil + } + } + return atlas.Perspective{}, fmt.Errorf("no perspective named or with id %q", nameOrID) +} + // --- wire shapes (goal 0083's locked read contract) --- type atlasKindFieldOut struct { @@ -79,6 +98,9 @@ type atlasSearchCardsArgs struct { Query string `json:"query" jsonschema:"the search text -- case-insensitive substring match over the card's title, note, and every field value"` KindID string `json:"kindId,omitempty" jsonschema:"optional: only cards of this Kind (see atlas_list_kinds for IDs)"` ParentID string `json:"parentId,omitempty" jsonschema:"optional: only direct children of this card"` + // Perspective (goal 0095 slice 3, ADR-0041) is additive: omitted, + // search covers every card exactly as before. + Perspective string `json:"perspective,omitempty" jsonschema:"optional: a perspective's name or id -- only cards that perspective shows are searched"` } type atlasSearchCardsResult struct { @@ -122,6 +144,9 @@ type atlasCardOut struct { type atlasReadCardArgs struct { CardID string `json:"cardId" jsonschema:"the card's ID (from atlas_search_cards, or the mill://atlas/cards resource)"` + // Perspective (goal 0095 slice 3, ADR-0041) is additive: omitted, + // Children/Links cover every card/link exactly as before. + Perspective string `json:"perspective,omitempty" jsonschema:"optional: a perspective's name or id -- scopes the returned Children and Links to only what that perspective shows; the card must itself be one of its members"` } // registerAtlasResources wires mill://atlas/cards -- one read-only @@ -174,23 +199,39 @@ func (m *MillMCPService) registerAtlasTools() { mcp.AddTool(m.server, &mcp.Tool{ Name: "atlas_search_cards", - Description: "Search Atlas cards by a case-insensitive substring match over title, note, and every field value (the same matching a human's own Atlas jump search uses). Optionally scope to one Kind or one parent card. Read-only.", + Description: "Search Atlas cards by a case-insensitive substring match over title, note, and every field value (the same matching a human's own Atlas jump search uses). Optionally scope to one Kind, one parent card, or one perspective's own members. Read-only.", }, func(_ context.Context, _ *mcp.CallToolRequest, in atlasSearchCardsArgs) (*mcp.CallToolResult, any, error) { if err := m.requireAtlas(); err != nil { return nil, nil, err } - res, err := jsonResult(m.searchAtlasCards(in)) + var persp *atlas.Perspective + if in.Perspective != "" { + p, err := m.resolvePerspective(in.Perspective) + if err != nil { + return nil, nil, err + } + persp = &p + } + res, err := jsonResult(m.searchAtlasCards(in, persp)) return res, nil, err }) mcp.AddTool(m.server, &mcp.Tool{ Name: "atlas_read_card", - Description: "One card's full content: title, note, summary/status (when its Kind declares those fields), every field value, source URL, mirror path, its containment chain (parent then grandparent... root-ward), its direct children, and every link touching it in both directions (with the other card's ID/title). Read-only.", + Description: "One card's full content: title, note, summary/status (when its Kind declares those fields), every field value, source URL, mirror path, its containment chain (parent then grandparent... root-ward), its direct children, and every link touching it in both directions (with the other card's ID/title); optionally scoped to one perspective's own members. Read-only.", }, func(_ context.Context, _ *mcp.CallToolRequest, in atlasReadCardArgs) (*mcp.CallToolResult, any, error) { if err := m.requireAtlas(); err != nil { return nil, nil, err } - out, err := m.readAtlasCard(in.CardID) + var persp *atlas.Perspective + if in.Perspective != "" { + p, err := m.resolvePerspective(in.Perspective) + if err != nil { + return nil, nil, err + } + persp = &p + } + out, err := m.readAtlasCard(in.CardID, persp) if err != nil { return nil, nil, err } @@ -269,13 +310,17 @@ func atlasNoteSnippet(note, q string) string { // inside a card's Fields map -- there is no separate Card.Summary/ // Card.Status column (internal/domain/atlas/card.go), so matching every // field value is what makes summary/status searchable at all. -func (m *MillMCPService) searchAtlasCards(in atlasSearchCardsArgs) atlasSearchCardsResult { +func (m *MillMCPService) searchAtlasCards(in atlasSearchCardsArgs, persp *atlas.Perspective) atlasSearchCardsResult { q := strings.ToLower(strings.TrimSpace(in.Query)) result := atlasSearchCardsResult{Matches: []atlasSearchMatch{}} if q == "" { return result } - for _, c := range m.atlas.Cards() { + cards := m.atlas.Cards() + if persp != nil { + cards, _ = atlas.FilterByPerspective(cards, nil, *persp) + } + for _, c := range cards { if in.KindID != "" && c.KindID != in.KindID { continue } @@ -314,8 +359,9 @@ func (m *MillMCPService) searchAtlasCards(in atlasSearchCardsArgs) atlasSearchCa // own overlay/context-block renderer (atlasservice_share.go) draws // from, just reshaped for an MCP client instead of a paste-ready text // block. -func (m *MillMCPService) readAtlasCard(cardID string) (atlasCardOut, error) { +func (m *MillMCPService) readAtlasCard(cardID string, persp *atlas.Perspective) (atlasCardOut, error) { cards := m.atlas.Cards() + links := m.atlas.Links() byID := make(map[string]atlas.Card, len(cards)) for _, c := range cards { byID[c.ID] = c @@ -325,6 +371,25 @@ func (m *MillMCPService) readAtlasCard(cardID string) (atlasCardOut, error) { return atlasCardOut{}, fmt.Errorf("no card with id %q", cardID) } + // Perspective scoping (goal 0095 slice 3): Children/Links narrow to + // what persp shows; ParentChain stays the full ancestry regardless + // (a member's ancestors are already members too, ADR-0041's + // ancestry-closure invariant, so this is never a real divergence). + visibleCards, visibleLinks := cards, links + if persp != nil { + visibleCards, visibleLinks = atlas.FilterByPerspective(cards, links, *persp) + member := false + for _, c := range visibleCards { + if c.ID == cardID { + member = true + break + } + } + if !member { + return atlasCardOut{}, fmt.Errorf("card %q is not a member of perspective %q", cardID, persp.Name) + } + } + out := atlasCardOut{ ID: card.ID, KindID: card.KindID, Title: card.Title, Note: card.Note, Fields: card.Fields, SourceURL: card.Source, MirrorPath: card.MirrorPath, @@ -356,13 +421,13 @@ func (m *MillMCPService) readAtlasCard(cardID string) (atlasCardOut, error) { out.ParentChain = chain } - for _, c := range cards { + for _, c := range visibleCards { if c.ParentID == cardID { out.Children = append(out.Children, atlasChildEntry{ID: c.ID, Title: c.Title, KindID: c.KindID}) } } - for _, l := range m.atlas.Links() { + for _, l := range visibleLinks { switch cardID { case l.FromCardID: out.Links = append(out.Links, atlasLinkEntry{ diff --git a/internal/services/mcpsvc/millmcpservice_atlas_perspective_test.go b/internal/services/mcpsvc/millmcpservice_atlas_perspective_test.go new file mode 100644 index 00000000..99c40aec --- /dev/null +++ b/internal/services/mcpsvc/millmcpservice_atlas_perspective_test.go @@ -0,0 +1,111 @@ +package mcpsvc + +import ( + "encoding/json" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// The `perspective` param (goal 0095 slice 3, ADR-0041): additive over +// atlas_search_cards/atlas_read_card, split out of +// millmcpservice_atlas_test.go (architecture.md's 500-line convention) +// -- runs against the seeded "Current"/"Interim"/"Target" reference- +// architecture example (internal/domain/atlas/builtin.go's +// BuiltInPerspectives). + +func TestAtlasMCP_SearchCards_PerspectiveParam_ScopesToMembers(t *testing.T) { + h := newAtlasMCPHarness(t, "127.0.0.1:18110") + + // Absent: unchanged, finds "Sync service" regardless of perspective. + text := h.call(t, "atlas_search_cards", map[string]any{"query": "service"}) + var out atlasSearchCardsResult + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatalf("atlas_search_cards result is not the typed JSON: %v", err) + } + if len(out.Matches) != 1 || out.Matches[0].Title != "Sync service" { + t.Fatalf("unscoped search(service) = %+v, want exactly Sync service", out.Matches) + } + + // "Current" never gained the sync service -- scoped search finds nothing. + text = h.call(t, "atlas_search_cards", map[string]any{"query": "service", "perspective": "Current"}) + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatalf("atlas_search_cards result is not the typed JSON: %v", err) + } + if len(out.Matches) != 0 { + t.Errorf("search(service) scoped to Current = %+v, want no matches", out.Matches) + } + + // "Interim" added the sync service alongside the old connection. + text = h.call(t, "atlas_search_cards", map[string]any{"query": "service", "perspective": "Interim"}) + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatalf("atlas_search_cards result is not the typed JSON: %v", err) + } + if len(out.Matches) != 1 || out.Matches[0].Title != "Sync service" { + t.Errorf("search(service) scoped to Interim = %+v, want exactly Sync service", out.Matches) + } +} + +func TestAtlasMCP_SearchCards_PerspectiveParam_UnknownPerspective_Errors(t *testing.T) { + h := newAtlasMCPHarness(t, "127.0.0.1:18111") + res, err := h.session.CallTool(h.ctx, &mcp.CallToolParams{ + Name: "atlas_search_cards", + Arguments: map[string]any{"query": "a", "perspective": "does-not-exist"}, + }) + if err != nil { + t.Fatalf("transport error: %v", err) + } + if !res.IsError { + t.Error("atlas_search_cards with an unknown perspective must return an error result") + } +} + +func TestAtlasMCP_ReadCard_PerspectiveParam_ScopesLinks(t *testing.T) { + h := newAtlasMCPHarness(t, "127.0.0.1:18112") + webApp := h.cardByTitle(t, "Web app") + + // Absent: unchanged, both the old direct link and the new shape show. + text := h.call(t, "atlas_read_card", map[string]any{"cardId": webApp.ID}) + var out atlasCardOut + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatalf("atlas_read_card result is not the typed JSON: %v", err) + } + if len(out.Links) != 2 { + t.Fatalf("unscoped Web app Links = %+v, want 2 (data store direct + sync service)", out.Links) + } + + // "Current": only the old direct link to the data store. + text = h.call(t, "atlas_read_card", map[string]any{"cardId": webApp.ID, "perspective": "Current"}) + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatalf("atlas_read_card result is not the typed JSON: %v", err) + } + if len(out.Links) != 1 || out.Links[0].OtherTitle != "Data store" { + t.Errorf("Web app Links scoped to Current = %+v, want exactly the link to Data store", out.Links) + } + + // "Target": the old direct link is gone, only the new shape remains. + text = h.call(t, "atlas_read_card", map[string]any{"cardId": webApp.ID, "perspective": "Target"}) + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatalf("atlas_read_card result is not the typed JSON: %v", err) + } + if len(out.Links) != 1 || out.Links[0].OtherTitle != "Sync service" { + t.Errorf("Web app Links scoped to Target = %+v, want exactly the link to Sync service", out.Links) + } +} + +func TestAtlasMCP_ReadCard_PerspectiveParam_NonMemberCard_Errors(t *testing.T) { + h := newAtlasMCPHarness(t, "127.0.0.1:18113") + syncService := h.cardByTitle(t, "Sync service") + + // Sync service never joined "Current". + res, err := h.session.CallTool(h.ctx, &mcp.CallToolParams{ + Name: "atlas_read_card", + Arguments: map[string]any{"cardId": syncService.ID, "perspective": "Current"}, + }) + if err != nil { + t.Fatalf("transport error: %v", err) + } + if !res.IsError { + t.Error("atlas_read_card for a card outside the named perspective must return an error result") + } +} diff --git a/internal/services/seeding/seed_fingerprint.go b/internal/services/seeding/seed_fingerprint.go index bc4b7f85..2ff40af7 100644 --- a/internal/services/seeding/seed_fingerprint.go +++ b/internal/services/seeding/seed_fingerprint.go @@ -141,10 +141,6 @@ func AllSeedFingerprints() map[string]SeedFingerprint { l.ID, l.CreatedAt, l.UpdatedAt, l.Seed = "", time.Time{}, time.Time{}, seedorigin.Origin{} out[keyFor("atlaslink", id)] = SeedFingerprint{SeedRevision: rev, Fingerprint: fingerprintContent(l)} } - // atlas.BuiltInPerspectives() ships empty this slice (goal 0095's - // seeded example is slice 3) -- the loop still runs so a future - // addition is caught by this same committed-record discipline from - // day one, not bolted on later. for _, p := range atlas.BuiltInPerspectives() { id := p.ID rev := p.Seed.SeedRevision diff --git a/internal/services/seeding/seed_fingerprints.json b/internal/services/seeding/seed_fingerprints.json index f8557e17..77505f55 100644 --- a/internal/services/seeding/seed_fingerprints.json +++ b/internal/services/seeding/seed_fingerprints.json @@ -1,230 +1,275 @@ -{ - "aiprovider:example-local-ollama": { - "fingerprint": "75218e8e0727e6cbf04afd8ff25f8a461ccfea9c77fdccfb06ac824333fe4100", - "seedRevision": 1 - }, - "atlascard:atlas-card-example-area": { - "fingerprint": "a6eb22e7ae842f08fcd0b6004d10a7dd33cd298f63790dd35cfd2b7608ced2e4", - "seedRevision": 5 - }, - "atlascard:atlas-card-example-contact": { - "fingerprint": "315a9a9df7d4670eaab81dffb8273ab6e31842d81963fc5cb9a67786f76a598f", - "seedRevision": 4 - }, - "atlascard:atlas-card-example-document": { - "fingerprint": "4dfe77f81c4c3d72f669047c444fb9683cc03982e08dde9f4fbaa6ed9c4e10c5", - "seedRevision": 5 - }, - "atlascard:atlas-card-getting-started": { - "fingerprint": "013b4878544094f8f8630cb1ff84a52acc2833a42a9b5bf14e0a142bb478c5cd", - "seedRevision": 5 - }, - "atlascard:atlas-card-my-space": { - "fingerprint": "b29d4770ec62fe881f0205380f5e684c181bdffd4b93dd220850184ed94117e7", - "seedRevision": 5 - }, - "atlascard:atlas-card-scratchpad": { - "fingerprint": "b0330a8234b5f5ccd7768520bfbe70e72e0561795dc024ea29a89feea1e093a1", - "seedRevision": 5 - }, - "atlaskind:atlas-kind-contact": { - "fingerprint": "b4a22502670484312692a4118167d8a4e7395a0d59b39f590683376288a3e732", - "seedRevision": 2 - }, - "atlaskind:atlas-kind-document": { - "fingerprint": "61d42bc082711822ae67493bc63b25f4045a5881ee703c0ea342b78991c9b76a", - "seedRevision": 2 - }, - "atlaskind:atlas-kind-intake": { - "fingerprint": "ec06f5c0e094665ffd4c3160a23288c8c2ff0a455f736c8c4f774b7e2cf524e5", - "seedRevision": 2 - }, - "atlaskind:atlas-kind-reference": { - "fingerprint": "8ddd7806e05137eda5b4ed3bb52af907a2679e1184e069ad21eeacf52104cc89", - "seedRevision": 1 - }, - "atlaskind:atlas-kind-topic": { - "fingerprint": "325f0697cec657797baefab1f253e8e6203a71badbce734cf2f8b2580432a17e", - "seedRevision": 2 - }, - "atlaslink:atlas-link-contact-to-document": { - "fingerprint": "93fbc8aef570739859e999310dc4f6862d0141fc3899d8bafaa14ff219970dae", - "seedRevision": 1 - }, - "atlaslink:atlas-link-getting-to-contact": { - "fingerprint": "1bb44b5b7395f6d37dab829e4a7657dc61b012b7c7169efc52c922193eb2889a", - "seedRevision": 1 - }, - "atlaslinkkind:atlas-linkkind-relates-to": { - "fingerprint": "5efd2d4f46ceb01ae229e997102fe11191367b9ee46ac03939ffe278d4dec379", - "seedRevision": 1 - }, - "decision:example-approve-decision": { - "fingerprint": "b29aebfd8e13eeb833999fac0c970922830f2c61b514441c1edfa410b92b1e34", - "seedRevision": 3 - }, - "decision:example-deny-decision": { - "fingerprint": "aa54d1a9131fef88fa3276c604e24a845c9e3765e06dca979ecf4e5b3f24b604", - "seedRevision": 3 - }, - "decision:example-manual-review-decision": { - "fingerprint": "3ecb2ea4b17b61db87e7ab8f636991f8ff47df30c41597837233f070f8d5ea37", - "seedRevision": 3 - }, - "execenv:example-safe-sandbox-execenv": { - "fingerprint": "aa9a964c09faafbd8e7ebbeff3e75aa438878bdb9e1207c4dcd5c0bbba8de0bc", - "seedRevision": 1 - }, - "list:example-country-codes-list": { - "fingerprint": "e5f0f240a11b3186184fc845f0a5b43e0a866f354c90c6cb22286a3ca4a4b755", - "seedRevision": 4 - }, - "list:example-task-tracker-list": { - "fingerprint": "7dce628bee6d21080ce535a42affe766655281db28fa08eb7f24845bd25f8cb3", - "seedRevision": 1 - }, - "mcpserver:example-reference-mcpserver": { - "fingerprint": "039af69260ea3c243cabc1f58ef0fb40524dbd4743b5c9f19e8224d9b1796549", - "seedRevision": 1 - }, - "request:example-apikey-httpbin": { - "fingerprint": "78f0254ea9bf750793fcf49eb23979defa44ae9e5387d246d18d819dd78f986e", - "seedRevision": 1 - }, - "request:example-bearer-httpbin": { - "fingerprint": "ecfac51fff4f10c02f71beb592f143b8b34c400388728daa1593aa919f991bc2", - "seedRevision": 1 - }, - "request:example-hmac-httpbin": { - "fingerprint": "bdc7a35da096a1debf62358ec5c030423ffa71b7503857104ce8ca3d513e2ca1", - "seedRevision": 2 - }, - "request:example-none-httpbin": { - "fingerprint": "afbadde9cf95232f3a2521b7e40fd9fe6740e1dbae3df8a598c74beb26a584cf", - "seedRevision": 1 - }, - "request:example-oauth1-postman-echo": { - "fingerprint": "15ab86dd91d184008729bc181a80d7c3efe89572141b7c3d3455891bd4d8cb6a", - "seedRevision": 1 - }, - "request:example-oauth2-spotify": { - "fingerprint": "0c86b49dcaae77b60f050a7bb839aafc118f6af7b10897bc12657631c690359d", - "seedRevision": 1 - }, - "request:example-queryparam-httpbin": { - "fingerprint": "d9e35d3d3d3bcf5bbb3f11775b82255ca335462c3854b5f1dffa5012707a4f61", - "seedRevision": 1 - }, - "workflow:backup-mill-data-workflow": { - "fingerprint": "33182d6efa709f8e0e2ca012b2f1d90827afb85b1974a5e6b8af01b8827cab55", - "seedRevision": 1 - }, - "workflow:clipboard-html-to-markdown-workflow": { - "fingerprint": "acb12fc22f02f89de6038b85ace772147cb5f6e74b523b4f9f9ff7ffff350397", - "seedRevision": 1 - }, - "workflow:example-ai-classify-branch-workflow": { - "fingerprint": "251cb4a6272e3e7303a61a2cad8c988dca8c44edb324bef908c063eca2cc926c", - "seedRevision": 2 - }, - "workflow:example-ai-summarize-workflow": { - "fingerprint": "18645ae0dd50fb623432f102af0ac2e17d44007a9b19fbc71d34d52e0c7efe6e", - "seedRevision": 2 - }, - "workflow:example-branch-to-decision-workflow": { - "fingerprint": "a029c7c1d3ee9f7da229f81689b341d455a51a6dfe8efe488158c5ac137b2bc0", - "seedRevision": 3 - }, - "workflow:example-card-create-link-workflow": { - "fingerprint": "7b2ad643f36f99b161876ddf497a868473cf13b709575b59f4e24ac1e005ef5a", - "seedRevision": 1 - }, - "workflow:example-card-intake-workflow": { - "fingerprint": "73a0918515c0e87d051ca9d4d6f74c6f84547baa8aed005e326ff45d261fe004", - "seedRevision": 1 - }, - "workflow:example-child-echo-workflow": { - "fingerprint": "a8e0f1f1296edc30b6568515f6852adfb9c5fdc27ebc43c64acb0aa08efccef8", - "seedRevision": 2 - }, - "workflow:example-clipboard-inspector-workflow": { - "fingerprint": "c1dd4b14828d9a8957b6a667633d8597742adc69f0d2e4e735671da7ad4ed287", - "seedRevision": 2 - }, - "workflow:example-codeexec-workflow": { - "fingerprint": "885b37e75639c22e576436b7272754c86854bb0d896fffd677c43c61c1e13586", - "seedRevision": 2 - }, - "workflow:example-decision-with-review-workflow": { - "fingerprint": "ae0b9f6b15aa9d44fe854cdf78393de1e4c179292d3b757eebfb23117fc4d07c", - "seedRevision": 3 - }, - "workflow:example-disabled-filesystem-watch-workflow": { - "fingerprint": "03ac020114b1559111e84abde9ec0d33718b2e24dbaab7f1b2072e41fd3e6017", - "seedRevision": 2 - }, - "workflow:example-disabled-schedule-workflow": { - "fingerprint": "f1d5e2d5143c8942eeb222b82c9a6978269f0749b2613c19fddeb441898c52da", - "seedRevision": 2 - }, - "workflow:example-filemove-workflow": { - "fingerprint": "d835c0706e34b5eb10e1e35363856a861bb3c2652066cac30e88ae25af3dff06", - "seedRevision": 1 - }, - "workflow:example-forward-approvals-workflow": { - "fingerprint": "228b33ff965d175c2156ecce6c91a56ed0c3f3526e47fa818c310100777b981d", - "seedRevision": 2 - }, - "workflow:example-guarded-http-workflow": { - "fingerprint": "06ff189f9ff1362321c0187466a2336f34cb6a16217ee3c129900ab2da6e2b9f", - "seedRevision": 2 - }, - "workflow:example-list-lookup-workflow": { - "fingerprint": "bf671926b7026069f6dd72158696f2d3f6ed8328984859878581beb56fede134", - "seedRevision": 2 - }, - "workflow:example-list-pinned-workflow": { - "fingerprint": "3a54d24a83030bc221bf639791434b9cf7ec0c1386ad2fb877bcc0c9858fc708", - "seedRevision": 1 - }, - "workflow:example-list-search-workflow": { - "fingerprint": "d37857488ba5f230401d5fa5033b3c7837dcc59473b5ef1ac70c94a97536bdc1", - "seedRevision": 2 - }, - "workflow:example-list-write-workflow": { - "fingerprint": "4ed20ba60b7602a75b0bb7490f3a2780faab1be1c7906618f1c578408be174d3", - "seedRevision": 1 - }, - "workflow:example-mcp-echo-workflow": { - "fingerprint": "a24f6a0a19fa008c61e7f8baa641b1de67b9b80ad2a0cab642d9853af0de7666", - "seedRevision": 2 - }, - "workflow:example-parent-workflow": { - "fingerprint": "0b531dd2a145e37acc71cdb1da26365800aef467a905aacd440d70b4720a07b2", - "seedRevision": 2 - }, - "workflow:example-review-workflow": { - "fingerprint": "57aa0cc6f06c52b8160163bd7ae6c94fb05ba6eb3bd5a94ff2f4982fb3375fe2", - "seedRevision": 2 - }, - "workflow:example-run-receipt-workflow": { - "fingerprint": "44312916e5911567eb2d1811a77418ae9bf9195b38cb0102bdbd3d36fe11afc6", - "seedRevision": 1 - }, - "workflow:example-saved-page-to-markdown-workflow": { - "fingerprint": "3cd5e1bab8414c4d6ae353830b2a27cc9fa5f49747d8d540a752d955f1b162a8", - "seedRevision": 2 - }, - "workflow:example-scratch-capture-workflow": { - "fingerprint": "58474919963eb4da78947e7f46802b2c75d2c0e51f4e685b5c7e94774d00550c", - "seedRevision": 2 - }, - "workflow:example-step-failure-workflow": { - "fingerprint": "c4c6feedce67ba803cd59737c0ca5836cbca536dd79d591e94601c05a19aafdf", - "seedRevision": 1 - }, - "workflow:load-sample-html-workflow": { - "fingerprint": "add39815b73d840c233f266f01043b9cbec60ed37d04cba12a8306e85ce23214", - "seedRevision": 1 - } -} + { + "aiprovider:example-local-ollama": { + "seedRevision": 1, + "fingerprint": "75218e8e0727e6cbf04afd8ff25f8a461ccfea9c77fdccfb06ac824333fe4100" + }, + "atlascard:atlas-card-data-store": { + "seedRevision": 1, + "fingerprint": "35bf876e76601acf2f0b9216a43b0270dbe764170e040b2d6f7c8ed57aa4db7d" + }, + "atlascard:atlas-card-example-area": { + "seedRevision": 5, + "fingerprint": "a6eb22e7ae842f08fcd0b6004d10a7dd33cd298f63790dd35cfd2b7608ced2e4" + }, + "atlascard:atlas-card-example-contact": { + "seedRevision": 4, + "fingerprint": "315a9a9df7d4670eaab81dffb8273ab6e31842d81963fc5cb9a67786f76a598f" + }, + "atlascard:atlas-card-example-document": { + "seedRevision": 5, + "fingerprint": "4dfe77f81c4c3d72f669047c444fb9683cc03982e08dde9f4fbaa6ed9c4e10c5" + }, + "atlascard:atlas-card-getting-started": { + "seedRevision": 5, + "fingerprint": "013b4878544094f8f8630cb1ff84a52acc2833a42a9b5bf14e0a142bb478c5cd" + }, + "atlascard:atlas-card-my-space": { + "seedRevision": 5, + "fingerprint": "b29d4770ec62fe881f0205380f5e684c181bdffd4b93dd220850184ed94117e7" + }, + "atlascard:atlas-card-scratchpad": { + "seedRevision": 5, + "fingerprint": "b0330a8234b5f5ccd7768520bfbe70e72e0561795dc024ea29a89feea1e093a1" + }, + "atlascard:atlas-card-sync-service": { + "seedRevision": 1, + "fingerprint": "0c5d5c30133975d03290d7522dc3d71146968e3f6fdf3e158493c81c2921a9a7" + }, + "atlascard:atlas-card-system-landscape": { + "seedRevision": 1, + "fingerprint": "b0779404a5d92a2b5591e367dad464f4d0020cae88f8de3924bb0b15185f0f68" + }, + "atlascard:atlas-card-web-app": { + "seedRevision": 1, + "fingerprint": "7c70a8d7935ba5a15f199c9d10a8f11faf2998746b26e488ca47cc5a2c29ddb7" + }, + "atlaskind:atlas-kind-component": { + "seedRevision": 1, + "fingerprint": "c0717ac09c75dcef29c2a06c08d0d4a6e60832bb9385db4eb327f0fb069205c1" + }, + "atlaskind:atlas-kind-contact": { + "seedRevision": 2, + "fingerprint": "b4a22502670484312692a4118167d8a4e7395a0d59b39f590683376288a3e732" + }, + "atlaskind:atlas-kind-document": { + "seedRevision": 2, + "fingerprint": "61d42bc082711822ae67493bc63b25f4045a5881ee703c0ea342b78991c9b76a" + }, + "atlaskind:atlas-kind-intake": { + "seedRevision": 2, + "fingerprint": "ec06f5c0e094665ffd4c3160a23288c8c2ff0a455f736c8c4f774b7e2cf524e5" + }, + "atlaskind:atlas-kind-reference": { + "seedRevision": 1, + "fingerprint": "8ddd7806e05137eda5b4ed3bb52af907a2679e1184e069ad21eeacf52104cc89" + }, + "atlaskind:atlas-kind-topic": { + "seedRevision": 2, + "fingerprint": "325f0697cec657797baefab1f253e8e6203a71badbce734cf2f8b2580432a17e" + }, + "atlaslink:atlas-link-contact-to-document": { + "seedRevision": 1, + "fingerprint": "93fbc8aef570739859e999310dc4f6862d0141fc3899d8bafaa14ff219970dae" + }, + "atlaslink:atlas-link-getting-to-contact": { + "seedRevision": 1, + "fingerprint": "1bb44b5b7395f6d37dab829e4a7657dc61b012b7c7169efc52c922193eb2889a" + }, + "atlaslink:atlas-link-sync-to-store": { + "seedRevision": 1, + "fingerprint": "8b1293286837f7b9c6528bf0f25279b3a4acc021f34281478cac10703c66f231" + }, + "atlaslink:atlas-link-web-to-store": { + "seedRevision": 1, + "fingerprint": "5fb2388428d5a7436480d3811fe8cec1dd1126a5fac3e8ea95f1057933088b12" + }, + "atlaslink:atlas-link-web-to-sync": { + "seedRevision": 1, + "fingerprint": "c7e6f53b5fcf4d61cdb4594ae30f443b972641cb92e63bb5c979a9ebd5da897a" + }, + "atlaslinkkind:atlas-linkkind-relates-to": { + "seedRevision": 1, + "fingerprint": "5efd2d4f46ceb01ae229e997102fe11191367b9ee46ac03939ffe278d4dec379" + }, + "atlasperspective:atlas-perspective-current": { + "seedRevision": 1, + "fingerprint": "0da2062c14d7435ce318021dbd16fa829114d0d668df03d7f02bf6a235861b80" + }, + "atlasperspective:atlas-perspective-interim": { + "seedRevision": 1, + "fingerprint": "40781bca500aa24304fb503d00c619de84b9dcda9413c55e30c922d029521674" + }, + "atlasperspective:atlas-perspective-target": { + "seedRevision": 1, + "fingerprint": "0accb341af068cb4b92d7ae8246235015aa86a09392a0e2a804638500fbc7dc6" + }, + "decision:example-approve-decision": { + "seedRevision": 3, + "fingerprint": "b29aebfd8e13eeb833999fac0c970922830f2c61b514441c1edfa410b92b1e34" + }, + "decision:example-deny-decision": { + "seedRevision": 3, + "fingerprint": "aa54d1a9131fef88fa3276c604e24a845c9e3765e06dca979ecf4e5b3f24b604" + }, + "decision:example-manual-review-decision": { + "seedRevision": 3, + "fingerprint": "3ecb2ea4b17b61db87e7ab8f636991f8ff47df30c41597837233f070f8d5ea37" + }, + "execenv:example-safe-sandbox-execenv": { + "seedRevision": 1, + "fingerprint": "aa9a964c09faafbd8e7ebbeff3e75aa438878bdb9e1207c4dcd5c0bbba8de0bc" + }, + "list:example-country-codes-list": { + "seedRevision": 4, + "fingerprint": "e5f0f240a11b3186184fc845f0a5b43e0a866f354c90c6cb22286a3ca4a4b755" + }, + "list:example-task-tracker-list": { + "seedRevision": 1, + "fingerprint": "7dce628bee6d21080ce535a42affe766655281db28fa08eb7f24845bd25f8cb3" + }, + "mcpserver:example-reference-mcpserver": { + "seedRevision": 1, + "fingerprint": "039af69260ea3c243cabc1f58ef0fb40524dbd4743b5c9f19e8224d9b1796549" + }, + "request:example-apikey-httpbin": { + "seedRevision": 1, + "fingerprint": "78f0254ea9bf750793fcf49eb23979defa44ae9e5387d246d18d819dd78f986e" + }, + "request:example-bearer-httpbin": { + "seedRevision": 1, + "fingerprint": "ecfac51fff4f10c02f71beb592f143b8b34c400388728daa1593aa919f991bc2" + }, + "request:example-hmac-httpbin": { + "seedRevision": 2, + "fingerprint": "bdc7a35da096a1debf62358ec5c030423ffa71b7503857104ce8ca3d513e2ca1" + }, + "request:example-none-httpbin": { + "seedRevision": 1, + "fingerprint": "afbadde9cf95232f3a2521b7e40fd9fe6740e1dbae3df8a598c74beb26a584cf" + }, + "request:example-oauth1-postman-echo": { + "seedRevision": 1, + "fingerprint": "15ab86dd91d184008729bc181a80d7c3efe89572141b7c3d3455891bd4d8cb6a" + }, + "request:example-oauth2-spotify": { + "seedRevision": 1, + "fingerprint": "0c86b49dcaae77b60f050a7bb839aafc118f6af7b10897bc12657631c690359d" + }, + "request:example-queryparam-httpbin": { + "seedRevision": 1, + "fingerprint": "d9e35d3d3d3bcf5bbb3f11775b82255ca335462c3854b5f1dffa5012707a4f61" + }, + "workflow:backup-mill-data-workflow": { + "seedRevision": 1, + "fingerprint": "33182d6efa709f8e0e2ca012b2f1d90827afb85b1974a5e6b8af01b8827cab55" + }, + "workflow:clipboard-html-to-markdown-workflow": { + "seedRevision": 1, + "fingerprint": "acb12fc22f02f89de6038b85ace772147cb5f6e74b523b4f9f9ff7ffff350397" + }, + "workflow:example-ai-classify-branch-workflow": { + "seedRevision": 2, + "fingerprint": "251cb4a6272e3e7303a61a2cad8c988dca8c44edb324bef908c063eca2cc926c" + }, + "workflow:example-ai-summarize-workflow": { + "seedRevision": 2, + "fingerprint": "18645ae0dd50fb623432f102af0ac2e17d44007a9b19fbc71d34d52e0c7efe6e" + }, + "workflow:example-branch-to-decision-workflow": { + "seedRevision": 3, + "fingerprint": "a029c7c1d3ee9f7da229f81689b341d455a51a6dfe8efe488158c5ac137b2bc0" + }, + "workflow:example-card-create-link-workflow": { + "seedRevision": 1, + "fingerprint": "7b2ad643f36f99b161876ddf497a868473cf13b709575b59f4e24ac1e005ef5a" + }, + "workflow:example-card-intake-workflow": { + "seedRevision": 1, + "fingerprint": "73a0918515c0e87d051ca9d4d6f74c6f84547baa8aed005e326ff45d261fe004" + }, + "workflow:example-child-echo-workflow": { + "seedRevision": 2, + "fingerprint": "a8e0f1f1296edc30b6568515f6852adfb9c5fdc27ebc43c64acb0aa08efccef8" + }, + "workflow:example-clipboard-inspector-workflow": { + "seedRevision": 2, + "fingerprint": "c1dd4b14828d9a8957b6a667633d8597742adc69f0d2e4e735671da7ad4ed287" + }, + "workflow:example-codeexec-workflow": { + "seedRevision": 2, + "fingerprint": "885b37e75639c22e576436b7272754c86854bb0d896fffd677c43c61c1e13586" + }, + "workflow:example-decision-with-review-workflow": { + "seedRevision": 3, + "fingerprint": "ae0b9f6b15aa9d44fe854cdf78393de1e4c179292d3b757eebfb23117fc4d07c" + }, + "workflow:example-disabled-filesystem-watch-workflow": { + "seedRevision": 2, + "fingerprint": "03ac020114b1559111e84abde9ec0d33718b2e24dbaab7f1b2072e41fd3e6017" + }, + "workflow:example-disabled-schedule-workflow": { + "seedRevision": 2, + "fingerprint": "f1d5e2d5143c8942eeb222b82c9a6978269f0749b2613c19fddeb441898c52da" + }, + "workflow:example-filemove-workflow": { + "seedRevision": 1, + "fingerprint": "d835c0706e34b5eb10e1e35363856a861bb3c2652066cac30e88ae25af3dff06" + }, + "workflow:example-forward-approvals-workflow": { + "seedRevision": 2, + "fingerprint": "228b33ff965d175c2156ecce6c91a56ed0c3f3526e47fa818c310100777b981d" + }, + "workflow:example-guarded-http-workflow": { + "seedRevision": 2, + "fingerprint": "06ff189f9ff1362321c0187466a2336f34cb6a16217ee3c129900ab2da6e2b9f" + }, + "workflow:example-list-lookup-workflow": { + "seedRevision": 2, + "fingerprint": "bf671926b7026069f6dd72158696f2d3f6ed8328984859878581beb56fede134" + }, + "workflow:example-list-pinned-workflow": { + "seedRevision": 1, + "fingerprint": "3a54d24a83030bc221bf639791434b9cf7ec0c1386ad2fb877bcc0c9858fc708" + }, + "workflow:example-list-search-workflow": { + "seedRevision": 2, + "fingerprint": "d37857488ba5f230401d5fa5033b3c7837dcc59473b5ef1ac70c94a97536bdc1" + }, + "workflow:example-list-write-workflow": { + "seedRevision": 1, + "fingerprint": "4ed20ba60b7602a75b0bb7490f3a2780faab1be1c7906618f1c578408be174d3" + }, + "workflow:example-mcp-echo-workflow": { + "seedRevision": 2, + "fingerprint": "a24f6a0a19fa008c61e7f8baa641b1de67b9b80ad2a0cab642d9853af0de7666" + }, + "workflow:example-parent-workflow": { + "seedRevision": 2, + "fingerprint": "0b531dd2a145e37acc71cdb1da26365800aef467a905aacd440d70b4720a07b2" + }, + "workflow:example-review-workflow": { + "seedRevision": 2, + "fingerprint": "57aa0cc6f06c52b8160163bd7ae6c94fb05ba6eb3bd5a94ff2f4982fb3375fe2" + }, + "workflow:example-run-receipt-workflow": { + "seedRevision": 1, + "fingerprint": "44312916e5911567eb2d1811a77418ae9bf9195b38cb0102bdbd3d36fe11afc6" + }, + "workflow:example-saved-page-to-markdown-workflow": { + "seedRevision": 2, + "fingerprint": "3cd5e1bab8414c4d6ae353830b2a27cc9fa5f49747d8d540a752d955f1b162a8" + }, + "workflow:example-scratch-capture-workflow": { + "seedRevision": 2, + "fingerprint": "58474919963eb4da78947e7f46802b2c75d2c0e51f4e685b5c7e94774d00550c" + }, + "workflow:example-step-failure-workflow": { + "seedRevision": 1, + "fingerprint": "c4c6feedce67ba803cd59737c0ca5836cbca536dd79d591e94601c05a19aafdf" + }, + "workflow:load-sample-html-workflow": { + "seedRevision": 1, + "fingerprint": "add39815b73d840c233f266f01043b9cbec60ed37d04cba12a8306e85ce23214" + } + } + From 1be07a17e63a3f8ae9fb72126fc9905e8e1992ec Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 06:20:21 -0400 Subject: [PATCH 2/4] test: frame-gutter clicks measure a settled box -- clickFrameGutter replaces the fraction idiom fitView animates node geometry after board load; a bounding box measured mid-animation yields a click offset (y = staleHeight/2) that lands outside the settled frame on the pane, and Playwright re-resolves the element but never a caller-supplied position, so every retry misses forever. All seven gutter-click sites now go through one fixtures helper that polls for box stability first and uses a border-safe fixed x (a zoomed-out board's whole gutter is ~5px, so a width-proportional offset overshoots onto the first child card). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- frontend/e2e/atlas-gestures.spec.ts | 6 ++--- frontend/e2e/atlas-page-edit.spec.ts | 5 ++--- frontend/e2e/atlas-page.spec.ts | 9 ++++---- frontend/e2e/atlas-scale.spec.ts | 7 +++--- frontend/e2e/fixtures/atlasBoard.ts | 33 ++++++++++++++++++++++++++++ 5 files changed, 46 insertions(+), 14 deletions(-) diff --git a/frontend/e2e/atlas-gestures.spec.ts b/frontend/e2e/atlas-gestures.spec.ts index c49c0c64..26a3a34a 100644 --- a/frontend/e2e/atlas-gestures.spec.ts +++ b/frontend/e2e/atlas-gestures.spec.ts @@ -1,8 +1,8 @@ import { test, expect } from './fixtures/server' import { groupCard, noteCard } from './fixtures/atlasCards' -import { clickCorner, zoomAllTheWayOut } from './fixtures/atlasBoard' +import { clickCorner, zoomAllTheWayOut, clickFrameGutter } from './fixtures/atlasBoard' import { contextMenu } from './fixtures/contextMenu' -import { clickAtFraction, waitForViewportStable } from './fixtures/animation' +import { waitForViewportStable } from './fixtures/animation' // The click model (goal 0102's gesture table) + surface-scoped // shortcuts (goal 0071 slice): plain click selects/replaces, a second @@ -124,7 +124,7 @@ test('a real double-click reproduces the same select-then-commit outcome as two // stays inside the gutter regardless of the board's own zoom scale, // which shifts with the seeded card count (goal 0095 slice 3). const exampleArea = groupCard(page, 'Example area') - await clickAtFraction(exampleArea, 0.01, 0.5, { clickCount: 2 }) + await clickFrameGutter(exampleArea, { clickCount: 2 }) await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Example area') // ⌘↑ = one step up the depth ladder (atlas.up, Finder's enclosing- diff --git a/frontend/e2e/atlas-page-edit.spec.ts b/frontend/e2e/atlas-page-edit.spec.ts index 9c848e16..bb7767bf 100644 --- a/frontend/e2e/atlas-page-edit.spec.ts +++ b/frontend/e2e/atlas-page-edit.spec.ts @@ -10,8 +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 { clickAtFraction } from './fixtures/animation' +import { groupCard, noteCard, openCard, clickFrameGutter } from './fixtures/atlasBoard' // Atlas card page read-is-edit + chip navigation (goal 0081 slice A5, // LOCKED design §5b): every field editable in place with a per-save @@ -147,7 +146,7 @@ test('atlas card page: read-is-edit fields, kind-gated mirror controls, page lin // 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, { modifiers: ['Meta'] }) + await clickFrameGutter(exampleAreaFrame, { modifiers: ['Meta'] }) await expect(overlay).toBeVisible() const childRow = overlay.getByTestId('atlas-page-child').filter({ hasText: 'Ada Lovelace' }) await expect(childRow).toBeVisible() diff --git a/frontend/e2e/atlas-page.spec.ts b/frontend/e2e/atlas-page.spec.ts index f94871bb..a682b8d9 100644 --- a/frontend/e2e/atlas-page.spec.ts +++ b/frontend/e2e/atlas-page.spec.ts @@ -1,7 +1,6 @@ import { test, expect } from './fixtures/server' import { deleteViaPageMenu } from './fixtures/atlasPage' -import { clickAtFraction } from './fixtures/animation' -import { openCard } from './fixtures/atlasBoard' +import { openCard, clickFrameGutter } 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, @@ -123,7 +122,7 @@ test('a region frame\'s body click selects it (never drills); ⌘-click opens th // edge and its first column of children, running the full height // 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 clickFrameGutter(exampleArea) await expect(exampleAreaWrapper).toHaveCount(1) // The board never re-roots off a plain body click -- the header // remains the only unconditional drill affordance. @@ -135,7 +134,7 @@ test('a region frame\'s body click selects it (never drills); ⌘-click opens th // ⌘-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'] }) + await clickFrameGutter(exampleArea, { 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') @@ -182,7 +181,7 @@ test('a child\'s mirror preview renders inline in the parent page; the card\'s o // 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, { modifiers: ['Meta'] }) + await clickFrameGutter(exampleAreaFrame, { 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' }) diff --git a/frontend/e2e/atlas-scale.spec.ts b/frontend/e2e/atlas-scale.spec.ts index 010063eb..426051b2 100644 --- a/frontend/e2e/atlas-scale.spec.ts +++ b/frontend/e2e/atlas-scale.spec.ts @@ -8,7 +8,8 @@ import { spawnMillServer, type SpawnedServer, } from './fixtures/server' -import { clickAtFraction, waitForViewportStable } from './fixtures/animation' +import { waitForViewportStable } from './fixtures/animation' +import { clickFrameGutter } 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) @@ -181,7 +182,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, { modifiers: ['Meta'] }) + await clickFrameGutter(velocity, { modifiers: ['Meta'] }) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() await expect.poll(() => pageChildCount(overlay)).toBe(12) @@ -208,7 +209,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, { modifiers: ['Meta'] }) + await clickFrameGutter(velocity, { 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/fixtures/atlasBoard.ts b/frontend/e2e/fixtures/atlasBoard.ts index 0ae496db..6eae6885 100644 --- a/frontend/e2e/fixtures/atlasBoard.ts +++ b/frontend/e2e/fixtures/atlasBoard.ts @@ -107,3 +107,36 @@ export async function deleteCardViaMenu(page: Page, menu: Locator, title: string await menu.getByText('Delete', { exact: true }).click() await expect(noteCard(page, title)).toHaveCount(0) } + +// Clicks a region frame's blank left gutter -- the GROUP_PADDING strip +// between the frame's left edge and its first child column, the one +// reliably blank band a frame has at every child count. Two traps this +// helper exists to close, both hit by the fraction-click idiom it +// replaces: +// 1. The position must be computed from a SETTLED bounding box. +// fitView animates node geometry after board load; a box measured +// mid-animation yields a click offset (notably y = staleHeight/2) +// that lands outside the settled frame -- on the pane -- and stays +// wrong forever, because Playwright re-resolves the element but +// never the caller-supplied position. +// 2. The x offset must clear the frame's border without overshooting +// the gutter: on a zoomed-out board the whole gutter is ~5px, so a +// width-proportional offset drifts onto the first child card while +// a too-small one hits the border. A fixed 3-4px sits inside the +// gutter at every zoom the suite produces. +export async function clickFrameGutter(frame: Locator, opts?: Parameters[0]): Promise { + let previous = '' + await expect + .poll(async () => { + const box = await frame.boundingBox() + const key = box ? [box.x, box.y, box.width, box.height].map(v => Math.round(v)).join(',') : 'none' + const stable = previous === key && key !== 'none' + previous = key + return stable + }, { timeout: 10_000 }) + .toBe(true) + const box = await frame.boundingBox() + if (!box) throw new Error('clickFrameGutter: expected the frame to be measurable') + const x = Math.max(3, Math.min(4, box.width * 0.02)) + await frame.click({ ...opts, position: { x, y: box.height * 0.5 } }) +} From e267064b16d8f59bfcfd3ee5abdade4d09286a08 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 07:22:44 -0400 Subject: [PATCH 3/4] test: seed-tolerant assertions for the specs the seeded landscape exposed The 0095 seeded example widens every board the suite sees; three specs had seed-shape assumptions baked in. The breadcrumb sibling dropdown now asserts its count as a pattern (the test pins the dropdown's shape, not the seed catalogue). Mobile job 4 lands attention via the jump dialog before drilling -- at a phone viewport the seeded board exceeds what min-zoom can fit, the target frame can sit off-screen, and Playwright's scroll-into-view fights React Flow's transform forever; the jump camera-fly is the app's own door for reaching an off-screen node. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- frontend/e2e/atlas-breadcrumb-siblings.spec.ts | 7 ++++--- frontend/e2e/mobile.spec.ts | 11 +++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/atlas-breadcrumb-siblings.spec.ts b/frontend/e2e/atlas-breadcrumb-siblings.spec.ts index bdb84a88..59461e1a 100644 --- a/frontend/e2e/atlas-breadcrumb-siblings.spec.ts +++ b/frontend/e2e/atlas-breadcrumb-siblings.spec.ts @@ -17,8 +17,9 @@ test('a breadcrumb segment opens a dropdown of its level\'s siblings, current on // "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). + // only itself. The child count is asserted as a pattern, not an + // exact number: seeds evolve (goal 0095 added a whole seeded area) + // and this test pins the dropdown's SHAPE, not the seed catalogue. const mySpaceCrumb = page.getByTestId('atlas-breadcrumb').getByTestId('atlas-breadcrumb-item').filter({ hasText: 'My space' }) await mySpaceCrumb.click() @@ -26,7 +27,7 @@ test('a breadcrumb segment opens a dropdown of its level\'s siblings, current on 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') + await expect(mySpaceRow).toContainText(/\d+ cards/) // Clicking the current place navigates to it (reproducing the old // direct-navigate behavior) -- the crumb collapses back to "My diff --git a/frontend/e2e/mobile.spec.ts b/frontend/e2e/mobile.spec.ts index 8b99ef9f..15f256b0 100644 --- a/frontend/e2e/mobile.spec.ts +++ b/frontend/e2e/mobile.spec.ts @@ -95,6 +95,17 @@ test('Mobile job 4 -- Atlas board glance, drill via a region frame header, and c // The single seeded root auto-enters "My space" (Free mode); the // group-frame glance lives one drill down, in "Example area". await expect(page.getByTestId('atlas-board')).toBeVisible() + // At this viewport the seeded board is wider than min-zoom can fit, + // so "Example area" can sit off-screen and Playwright's + // scroll-into-view fights React Flow's transform (element never + // stable). The jump dialog is the app's own door for exactly this -- + // Enter flies the camera to the match -- so land attention first, + // then drill. + await page.keyboard.press('Meta+k') + await page.getByTestId('atlas-jump-input').fill('Example area') + await expect(page.locator('[data-component="atlas-jump-dialog"]').getByTestId('atlas-jump-result').first()).toBeVisible() + await page.keyboard.press('Enter') + await expect(page.locator('[data-component="atlas-jump-dialog"]')).toHaveCount(0) await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() await expect(page.getByTestId('atlas-board')).toBeVisible() From ef8cfa62c2d2a5f475006240707b0a0ecb40ee5c Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 07:50:29 -0400 Subject: [PATCH 4/4] test: page-scale drill uses clickFrameGutter; cleanup tolerates server-flush race The fixed {x:6,y:60} gutter click lands outside the frame once fitView zooms the denser board out further (the same settled-box class clickFrameGutter closes); rmSync gains retries because the just-stopped server can still be flushing when cleanup runs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- frontend/e2e/atlas-page-scale.spec.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/frontend/e2e/atlas-page-scale.spec.ts b/frontend/e2e/atlas-page-scale.spec.ts index d77c592f..ff575961 100644 --- a/frontend/e2e/atlas-page-scale.spec.ts +++ b/frontend/e2e/atlas-page-scale.spec.ts @@ -9,6 +9,7 @@ import { spawnMillServer, type SpawnedServer, } from './fixtures/server' +import { clickFrameGutter } from './fixtures/atlasBoard' // The card PAGE at scale (goal 0073 slice B): a card holding many // children must cap its own Contents column the same way a board @@ -76,14 +77,12 @@ 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: ⌘-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'] }) + // Open the page: ⌘-click on the frame's own gutter opens its page + // directly (goal 0102's instant-commit path). clickFrameGutter + // measures a SETTLED box and a border-safe offset -- a fixed + // pixel position lands outside the frame once fitView zooms the + // denser board out further. + await clickFrameGutter(mirrorStack, { modifiers: ['Meta'] }) const overlay = page.locator('[data-component="atlas-card-overlay"]') await expect(overlay).toBeVisible() @@ -108,6 +107,9 @@ test('a card page at scale caps its entries with an honest expander and lazy-loa } finally { await browser.close() server?.stop() - rmSync(dir, { recursive: true, force: true }) + // maxRetries: the just-stopped server can still be flushing its + // settings/db files when cleanup runs -- a bare rmSync races it + // to ENOTEMPTY. + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) } })