diff --git a/frontend/e2e/atlas-perspectives.spec.ts b/frontend/e2e/atlas-perspectives.spec.ts new file mode 100644 index 00000000..8ae792a1 --- /dev/null +++ b/frontend/e2e/atlas-perspectives.spec.ts @@ -0,0 +1,238 @@ +import { chromium, expect, test } from '@playwright/test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { + ATLAS_PERSPECTIVES_MCP_BASE_PORT, + ATLAS_PERSPECTIVES_SERVER_BASE_PORT, + spawnMillServer, + type SpawnedServer, +} from './fixtures/server' +import { contextMenu } from './fixtures/contextMenu' +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). +// +// 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. + +async function withServer(testInfo: { parallelIndex: number }, run: (page: Awaited>) => Promise): Promise { + const idx = testInfo.parallelIndex + const dir = mkdtempSync(path.join(tmpdir(), `mill-e2e-atlas-perspectives-${idx}-`)) + const settingsPath = path.join(dir, 'settings.json') + const executionDbPath = path.join(dir, 'execution.db') + const backupDir = path.join(dir, 'backups') + const port = ATLAS_PERSPECTIVES_SERVER_BASE_PORT + idx + const mcpPort = ATLAS_PERSPECTIVES_MCP_BASE_PORT + idx + + let server: SpawnedServer | undefined + const browser = await chromium.launch() + try { + server = await spawnMillServer({ port, mcpPort, settingsPath, executionDbPath, backupDir }) + const page = await browser.newPage() + await page.goto(`${server.baseURL}/`) + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(page.getByTestId('atlas-board')).toBeVisible() + await run(page) + } finally { + await browser.close() + await server?.stop() + rmSync(dir, { recursive: true, force: true }) + } +} + +const switcherButton = (page: import('@playwright/test').Page) => page.getByTestId('atlas-perspective-switcher-open') +const switcherPopover = (page: import('@playwright/test').Page) => page.getByTestId('atlas-perspective-switcher-popover') + +async function createPerspective(page: import('@playwright/test').Page, name: string): Promise { + await switcherButton(page).click() + await expect(switcherPopover(page)).toBeVisible() + await page.getByTestId('atlas-perspective-new-input').fill(name) + await page.getByTestId('atlas-perspective-new-input').press('Enter') + await expect(switcherButton(page)).toHaveText(name) +} + +// 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') + + // Switch back to All cards: the switcher label reverts and the + // popover's own "All cards" row reads selected. + await switcherButton(page).click() + await switcherPopover(page).getByText('All cards', { exact: true }).click() + await expect(switcherButton(page)).toHaveText('All cards') + + // 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') + + // 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 expect(contextMenu(page)).toBeVisible() + await contextMenu(page).getByText('Rename', { exact: true }).click() + const renameInput = page.getByTestId('atlas-perspective-rename-input') + await expect(renameInput).toBeVisible() + await renameInput.fill('Renamed') + await renameInput.press('Enter') + await expect(switcherButton(page)).toHaveText('Renamed') + }) +}) + +// eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture. +test('a perspective filters the board to its members closed under ancestry, and hides a link whose endpoint is filtered out; Auto-arrange disables while active', async ({}, testInfo) => { + await withServer(testInfo, async (page) => { + await createPerspective(page, 'Filtered') + + // Nothing is a member yet -- the board renders empty. + await expect(noteCard(page, 'Getting started')).not.toBeVisible() + await expect(noteCard(page, 'Scratchpad')).not.toBeVisible() + await expect(groupCard(page, 'Example area')).not.toBeVisible() + + // Arrange-disabled-while-active (ADR-0041): aria-disabled + the + // exact tooltip copy, re-enabled once back on All cards. + const arrange = page.getByTestId('atlas-auto-arrange') + await expect(arrange).toHaveAttribute('aria-disabled', 'true') + await expect(arrange).toHaveAttribute('title', 'Arranging works on all cards. Switch to All cards first.') + + // Switch to All cards to reach "Getting started" and add it via + // the board's own right-click "Add to perspective". + await switcherButton(page).click() + await switcherPopover(page).getByText('All cards', { exact: true }).click() + await expect(arrange).not.toHaveAttribute('aria-disabled', 'true') + + await noteCard(page, 'Getting started').click({ button: 'right' }) + await expect(contextMenu(page)).toBeVisible() + await contextMenu(page).getByText('Add to perspective', { exact: false }).click() + await expect(contextMenu(page)).toBeVisible() + await contextMenu(page).getByText('Filtered', { exact: true }).click() + await expect(page.getByTestId('atlas-quiet-toast')).toContainText('Added to Filtered') + + // Reach "Ada Lovelace" (nested inside Example area) and add her via + // her own card page's membership chip -- still on All cards. + await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() + await openCard(page, noteCard(page, 'Ada Lovelace')) + const overlay = page.locator('[data-component="atlas-card-overlay"]') + await expect(overlay).toBeVisible() + // The add menu's own ActionMenu.Overlay portals outside the + // Dialog's DOM subtree (the same reason fixtures/atlasPage.ts's + // deleteViaPageMenu queries the kebab's menu item off `page`, not + // `overlay`) -- the item is queried off `page` here too. + await overlay.getByTestId('atlas-page-perspective-add').click() + await page.getByRole('menuitem', { name: 'Filtered', exact: true }).click() + await expect(overlay.getByTestId('atlas-page-perspective-membership')).toContainText('Filtered') + await page.keyboard.press('Escape') + await expect(overlay).not.toBeVisible() + await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click() + + // Switch to Filtered: Getting started (direct add) AND Example area + // (ancestry closure from Ada's own add) both render; Project + // charter (Ada's sibling, never added) and the seeded Getting + // started -> Ada Lovelace link (never made a perspective member + // itself) both stay hidden -- the endpoint-visibility half of the + // link rule already covers this: Ada's own top-level ancestor + // (Example area) IS visible here, but the raw link record was + // never added to Filtered's own MemberLinkIDs. + await switcherButton(page).click() + await switcherPopover(page).getByText('Filtered', { exact: true }).click() + await expect(noteCard(page, 'Getting started')).toBeVisible() + await expect(groupCard(page, 'Example area')).toBeVisible() + await expect(noteCard(page, 'Scratchpad')).not.toBeVisible() + await expect(page.locator('.react-flow__edge')).toHaveCount(0) + + await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() + await expect(noteCard(page, 'Ada Lovelace')).toBeVisible() + await expect(noteCard(page, 'Project charter')).not.toBeVisible() + }) +}) + +// eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture. +test('authoring a card while a perspective is active adds it automatically; the edit reflects after switching back (the O(1) property)', async ({}, testInfo) => { + await withServer(testInfo, async (page) => { + await createPerspective(page, 'Authoring') + + const title = 'ZzE2ePerspectiveAuthored' + await page.getByTestId('atlas-add-button').click() + await page.getByTestId('atlas-add-child').click() + await selectKind(page, ATLAS_KIND_TOPIC, 'atlas-create-kind') + await page.getByTestId('atlas-create-title').fill(title) + await page.getByRole('button', { name: 'Create' }).click() + + // Authoring-adds-to-active (server-side, keyed off the session's + // own ActivePerspectiveID) -- the new card renders immediately on + // the still-filtered board, no separate add step. + const newCard = noteCard(page, title) + await expect(newCard).toBeVisible() + + // Edit it while the perspective is still active. + await openCard(page, newCard) + const overlay = page.locator('[data-component="atlas-card-overlay"]') + await overlay.getByTestId('atlas-page-note').fill('Edited while a perspective was active.') + await overlay.getByTestId('atlas-page-note').blur() + await expect(overlay.getByTestId('atlas-page-saved-tick')).toBeVisible() + await page.keyboard.press('Escape') + await expect(overlay).not.toBeVisible() + + // One record, every view: the same edit shows on All cards too. + await switcherButton(page).click() + await switcherPopover(page).getByText('All cards', { exact: true }).click() + await openCard(page, newCard) + await expect(page.getByTestId('atlas-page-note')).toHaveValue('Edited while a perspective was active.') + + // Cleanup (testing.md's within-file discipline) -- overlay already open. + await deleteViaPageMenu(page, page.locator('[data-component="atlas-card-overlay"]')) + await expect(newCard).toHaveCount(0) + }) +}) + +// eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture. +test('membership removes via the board context menu, and deleting a perspective returns the board to All cards', async ({}, testInfo) => { + await withServer(testInfo, async (page) => { + await createPerspective(page, 'Removable') + + await switcherButton(page).click() + await switcherPopover(page).getByText('All cards', { exact: true }).click() + await noteCard(page, 'Scratchpad').click({ button: 'right' }) + await expect(contextMenu(page)).toBeVisible() + await contextMenu(page).getByText('Add to perspective', { exact: false }).click() + await contextMenu(page).getByText('Removable', { exact: true }).click() + await expect(page.getByTestId('atlas-quiet-toast')).toContainText('Added to Removable') + + await switcherButton(page).click() + await switcherPopover(page).getByText('Removable', { exact: true }).click() + await expect(noteCard(page, 'Scratchpad')).toBeVisible() + + await noteCard(page, 'Scratchpad').click({ button: 'right' }) + await expect(contextMenu(page)).toBeVisible() + await contextMenu(page).getByText('Remove from perspective', { exact: false }).click() + await contextMenu(page).getByText('Removable', { exact: true }).click() + await expect(page.getByTestId('atlas-quiet-toast')).toContainText('Removed from Removable') + await expect(noteCard(page, 'Scratchpad')).not.toBeVisible() + + // Delete the perspective: blocked-delete-with-confirm posture -- + // ConfirmDialog names it, confirming lands back on All cards. + await switcherButton(page).click() + await switcherPopover(page).getByText('Removable', { exact: true }).click({ button: 'right' }) + await expect(contextMenu(page)).toBeVisible() + await contextMenu(page).getByText('Delete', { exact: true }).click() + await expect(page.getByRole('heading', { name: 'Delete Removable?' })).toBeVisible() + await page.getByRole('button', { name: 'Delete', exact: true }).click() + await expect(switcherButton(page)).toHaveText('All cards') + await expect(noteCard(page, 'Scratchpad')).toBeVisible() + }) +}) diff --git a/frontend/e2e/atlas.spec.ts b/frontend/e2e/atlas.spec.ts index 1056f99e..1e6fa128 100644 --- a/frontend/e2e/atlas.spec.ts +++ b/frontend/e2e/atlas.spec.ts @@ -275,7 +275,7 @@ test('create a child card, edit + persist it via the card page, then delete it', await expect(newCard).not.toBeVisible() }) -test('the lens hides a kind within a space', async ({ page }) => { +test('the perspective switcher\'s Hide kinds section hides a kind within a space', async ({ page }) => { await page.goto('/') await page.getByRole('link', { name: 'Atlas' }).click() await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() @@ -284,8 +284,8 @@ test('the lens hides a kind within a space', async ({ page }) => { const contactCard = noteCard(page, 'Ada Lovelace') await expect(contactCard).toBeVisible() - await page.getByTestId('atlas-lens-open').click() - await expect(page.locator('[data-component="atlas-lens-dialog"]')).toBeVisible() + await page.getByTestId('atlas-perspective-switcher-open').click() + await expect(page.getByTestId('atlas-perspective-switcher-popover')).toBeVisible() await page.getByRole('checkbox', { name: /Contact/ }).uncheck() await page.keyboard.press('Escape') @@ -293,13 +293,13 @@ test('the lens hides a kind within a space', async ({ page }) => { // Restore: re-show the kind so the space's lens doesn't leak into a // later test in this same file/worker. - await page.getByTestId('atlas-lens-open').click() + await page.getByTestId('atlas-perspective-switcher-open').click() await page.getByRole('checkbox', { name: /Contact/ }).check() await page.keyboard.press('Escape') await expect(contactCard).toBeVisible() }) -test('lens-hiding a kind never removes a region frame of that kind -- containment is a role, not a type', async ({ page }) => { +test('hiding a kind never removes a region frame of that kind -- containment is a role, not a type', async ({ page }) => { await page.goto('/') await page.getByRole('link', { name: 'Atlas' }).click() const exampleArea = groupCard(page, 'Example area') @@ -311,7 +311,7 @@ test('lens-hiding a kind never removes a region frame of that kind -- containmen // the Topic LEAVES (Getting started, Scratchpad) but keep the area // frame -- a place holding cards stays on the board regardless of // its own kind. - await page.getByTestId('atlas-lens-open').click() + await page.getByTestId('atlas-perspective-switcher-open').click() await page.getByRole('checkbox', { name: /Topic/ }).uncheck() await page.keyboard.press('Escape') @@ -320,7 +320,7 @@ test('lens-hiding a kind never removes a region frame of that kind -- containmen await expect(exampleArea).toBeVisible() // Restore for later tests in this worker. - await page.getByTestId('atlas-lens-open').click() + await page.getByTestId('atlas-perspective-switcher-open').click() await page.getByRole('checkbox', { name: /Topic/ }).check() await page.keyboard.press('Escape') await expect(gettingStarted).toBeVisible() @@ -431,31 +431,6 @@ test('Update now on the seeded mirror card runs its workflow through the normal await expect(overlay).not.toBeVisible() }) -test('the lens depth toggle persists server-side across a reload', async ({ page }) => { - await page.goto('/') - await page.getByRole('link', { name: 'Atlas' }).click() - await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() - await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Example area') - - await page.getByTestId('atlas-lens-open').click() - await expect(page.locator('[data-component="atlas-lens-dialog"]')).toBeVisible() - await page.getByRole('button', { name: 'Peek into children' }).click() - await page.keyboard.press('Escape') - - await page.reload() - await expect(atlasView(page)).toBeVisible() - await groupCard(page, 'Example area').getByTestId('atlas-group-header').click() - await page.getByTestId('atlas-lens-open').click() - await expect(page.getByRole('button', { name: 'Peek into children', pressed: true })).toBeVisible() - await page.keyboard.press('Escape') - - // Restore: clear the peek toggle so it doesn't leak into a later test - // in this same file/worker (testing.md's within-file cleanup rule). - await page.getByTestId('atlas-lens-open').click() - await page.getByRole('button', { name: 'This level only' }).click() - await page.keyboard.press('Escape') -}) - test('Quick Panel finds a seeded Atlas card by title', async ({ page }) => { const mainPage = await page.context().newPage() try { diff --git a/frontend/e2e/command-palette.spec.ts b/frontend/e2e/command-palette.spec.ts index ec948efd..029c71e9 100644 --- a/frontend/e2e/command-palette.spec.ts +++ b/frontend/e2e/command-palette.spec.ts @@ -234,7 +234,7 @@ test('every new Atlas command is reachable from the palette by its label', async const labels = [ 'Select all', 'Auto-arrange', - 'Open lens', + 'Open perspective switcher', 'Import atlas', 'Export atlas', 'Add cards from a folder', diff --git a/frontend/e2e/fixtures/server.ts b/frontend/e2e/fixtures/server.ts index c57e4bbc..f5f2be3d 100644 --- a/frontend/e2e/fixtures/server.ts +++ b/frontend/e2e/fixtures/server.ts @@ -301,3 +301,12 @@ export const GUARDRAIL_REVIEW_SERVER_BASE_PORT = 10180 // 10250, so the MCP base must start beyond that or a computed server // port can land on another test's MCP listener. export const GUARDRAIL_REVIEW_MCP_BASE_PORT = 10300 + +// atlas-perspectives.spec.ts's own dedicated pair (goal 0095 slice 2, +// ADR-0041): the active perspective is GLOBAL Atlas session state +// (AtlasSessionState.activePerspectiveID), and its own perspective/ +// membership records are read by every other Atlas spec's board -- +// same shared-global-state reasoning as guardrail-authoring/ +// atlas-session-restore above, applied to this feature's own writes. +export const ATLAS_PERSPECTIVES_SERVER_BASE_PORT = 10320 +export const ATLAS_PERSPECTIVES_MCP_BASE_PORT = 10340 diff --git a/frontend/src/atlas/AtlasCardOverlay.tsx b/frontend/src/atlas/AtlasCardOverlay.tsx index 631261cc..8f7dc203 100644 --- a/frontend/src/atlas/AtlasCardOverlay.tsx +++ b/frontend/src/atlas/AtlasCardOverlay.tsx @@ -12,6 +12,7 @@ import { AtlasCardPageHeader } from './AtlasCardPageHeader' import { AtlasCardPageFields } from './AtlasCardPageFields' import { AtlasCardPageContents } from './AtlasCardPageContents' import { AtlasCardPageMetaRail } from './AtlasCardPageMetaRail' +import { AtlasPerspectiveMembership } from './AtlasPerspectiveMembership' import { AtlasSlotRows } from './AtlasSlotRows' import { useAtlasCardPageFileDrop } from './useAtlasCardPageFileDrop' import { FILE_DROP_CONTEXT_CARD_PAGE } from './atlasFileDropShared' @@ -264,6 +265,7 @@ export function AtlasCardOverlay({ card, kinds, allCards, links, linkKinds, onCl cardID={displayedCard.ID} actionWorkflowIDs={actionWorkflowIDs} onActionsChanged={commitActions} /> + void - peek: boolean - onChangePeek: (peek: boolean) => void -}) { - const { t } = useTranslation('atlas') - const [open, setOpen] = useState(false) - const visibleIDs = presentKinds.map((k) => k.ID).filter((id) => !hiddenKindIDs.includes(id)) - - // atlas.lens's own signal (shared/atlasBoardCommands.ts): a palette/ - // keyboard invocation opens the SAME dialog the toolbar's own lens - // button does. - const lensOpenRequest = useUISignalStore((s) => s.atlasLensOpenRequest) - const lastLensOpenRequest = useRef(lensOpenRequest) - useEffect(() => { - if (lensOpenRequest === lastLensOpenRequest.current) return - lastLensOpenRequest.current = lensOpenRequest - setOpen(true) - }, [lensOpenRequest]) - - return ( - <> - - {open && ( - // data-component, not data-testid: Primer's Dialog only forwards - // its own special-cased "data-component" prop onto the rendered - // element (see AtlasCardOverlay.tsx's identical note). - setOpen(false)} data-component="atlas-lens-dialog"> - onChangePeek(i === 1)}> - {t('lens.depthLevel')} - {t('lens.depthPeek')} - - - {presentKinds.length > 0 && ( - - {t('lens.kindsLabel')} - {presentKinds.map((kind) => ( - // Checkbox is a bare (no - // children/label support of its own, unlike Checkbox's - // sibling controls) -- FormControl.Label is what gives - // it an accessible, visible label, same pairing every - // other Checkbox in this codebase uses. - - { - const nextHidden = e.target.checked - ? hiddenKindIDs.filter((id) => id !== kind.ID) - : [...hiddenKindIDs, kind.ID] - onChangeHidden(nextHidden) - }} - /> - {kind.Icon ? `${kind.Icon} ${kind.Label}` : kind.Label} - - ))} - - )} - - )} - - ) -} diff --git a/frontend/src/atlas/AtlasPerspectiveMembership.module.css b/frontend/src/atlas/AtlasPerspectiveMembership.module.css new file mode 100644 index 00000000..c6d0d955 --- /dev/null +++ b/frontend/src/atlas/AtlasPerspectiveMembership.module.css @@ -0,0 +1,7 @@ +.row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--base-size-4); + margin-block: var(--base-size-8); +} diff --git a/frontend/src/atlas/AtlasPerspectiveMembership.tsx b/frontend/src/atlas/AtlasPerspectiveMembership.tsx new file mode 100644 index 00000000..b5ff8ea7 --- /dev/null +++ b/frontend/src/atlas/AtlasPerspectiveMembership.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ActionMenu, ActionList, Text, Token } from '@primer/react' +import { PlusIcon } from '@primer/octicons-react' +import { AtlasService } from '../shared/bindings' +import { useAtlasStore, refreshAtlasPerspectives } from './atlasStore' +import runbookStyles from '../shared/ListCard.module.css' +import styles from './AtlasPerspectiveMembership.module.css' + +// The card page's own compact membership row (ADR-0041, goal 0095 +// slice 2): self-contained by design -- takes only cardID, reads +// perspectives from the shared atlas store itself, and owns its own +// add/remove calls -- so mounting it is the one-line insertion +// AtlasCardOverlay.tsx carries (goal 0106 slice B is reworking that +// page's own layout concurrently; this file is the entire footprint +// beyond that single line). Renders nothing when no perspective exists +// yet -- an empty affordance for a capability the space hasn't adopted +// would just be page noise (0106's own "empty fields are one-line +// invitations, not boxes" spirit, applied here without waiting on that +// goal's own build). +export function AtlasPerspectiveMembership({ cardID }: { cardID: string }) { + const { t } = useTranslation('atlas') + const perspectives = useAtlasStore((s) => s.perspectives) ?? [] + const [open, setOpen] = useState(false) + + if (perspectives.length === 0) return null + + const member = perspectives.filter((p) => (p.MemberCardIDs ?? []).includes(cardID)) + const addable = perspectives.filter((p) => !(p.MemberCardIDs ?? []).includes(cardID)) + + const add = (perspectiveID: string) => { + setOpen(false) + void AtlasService.AddToPerspective(perspectiveID, cardID).then(() => refreshAtlasPerspectives()) + } + const remove = (perspectiveID: string) => { + void AtlasService.RemoveFromPerspective(perspectiveID, cardID).then(() => refreshAtlasPerspectives()) + } + + return ( +
+ {t('perspective.membershipLabel')} + {member.map((p) => ( + remove(p.ID)} /> + ))} + {addable.length > 0 && ( + + + {t('perspective.addChip')} + + + + {addable.map((p) => ( + add(p.ID)} data-testid={`atlas-page-perspective-add-${p.ID}`}> + {p.Name} + + ))} + + + + )} +
+ ) +} diff --git a/frontend/src/atlas/AtlasPerspectiveSwitcher.module.css b/frontend/src/atlas/AtlasPerspectiveSwitcher.module.css new file mode 100644 index 00000000..22081e55 --- /dev/null +++ b/frontend/src/atlas/AtlasPerspectiveSwitcher.module.css @@ -0,0 +1,6 @@ +.newRow { + padding: var(--base-size-4) var(--base-size-8); +} +.kindRow { + padding: var(--base-size-4) var(--base-size-8); +} diff --git a/frontend/src/atlas/AtlasPerspectiveSwitcher.tsx b/frontend/src/atlas/AtlasPerspectiveSwitcher.tsx new file mode 100644 index 00000000..70998be5 --- /dev/null +++ b/frontend/src/atlas/AtlasPerspectiveSwitcher.tsx @@ -0,0 +1,213 @@ +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 { useUISignalStore } from '../shared/uiSignalStore' +import { ConfirmDialog } from '../shared/ConfirmDialog' +import { ContextMenu, type ContextMenuState } from '../shared/ContextMenu' +import styles from './AtlasPerspectiveSwitcher.module.css' + +// The board toolbar's primary view control (ADR-0041, goal 0095 slice +// 2): absorbs the old Lens popover's slot entirely -- "All cards" (the +// default absence of a Perspective) first, every perspective in this +// space's own order, a divider, then an inline "New perspective..." +// row. The retired Lens control's depth/peek toggle predated the click +// model and was consumed by nothing (docs/SPEC.md's own recorded +// seam) -- it does not come back here. Its still-live half, the +// kind-hide filter, demotes into this popover's own "Hide kinds" +// section per the ADR's UI-naming note: never two near-synonym view +// controls side by side. +export function AtlasPerspectiveSwitcher({ + perspectives, activePerspectiveID, onSwitch, onCreate, onRename, onDelete, onToast, + presentKinds, hiddenKindIDs, onChangeHidden, +}: { + perspectives: Perspective[] + activePerspectiveID: string + onSwitch: (id: string) => void + onCreate: (name: string) => Promise + onRename: (id: string, name: string) => Promise + onDelete: (id: string) => Promise + onToast: (message: string) => void + presentKinds: Kind[] + hiddenKindIDs: string[] + onChangeHidden: (hidden: string[]) => void +}) { + const { t } = useTranslation('atlas') + const [open, setOpen] = useState(false) + const [newName, setNewName] = useState('') + const [renamingID, setRenamingID] = useState(null) + const [renameDraft, setRenameDraft] = useState('') + const [rowMenu, setRowMenu] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const visibleIDs = presentKinds.map((k) => k.ID).filter((id) => !hiddenKindIDs.includes(id)) + + const active = perspectives.find((p) => p.ID === activePerspectiveID) ?? null + + // atlas.perspective's own signal (shared/atlasBoardCommands.ts): a + // palette/keyboard invocation opens the SAME popover the toolbar's + // own button does. + const openRequest = useUISignalStore((s) => s.atlasPerspectiveSwitcherOpenRequest) + const lastOpenRequest = useRef(openRequest) + useEffect(() => { + if (openRequest === lastOpenRequest.current) return + lastOpenRequest.current = openRequest + setOpen(true) + }, [openRequest]) + + const closeRowMenu = () => setRowMenu(null) + + const openRowMenu = (p: Perspective, pos: { x: number; y: number }) => { + setRowMenu({ + x: pos.x, + y: pos.y, + items: [ + { id: 'rename', label: t('perspective.rename'), run: () => { setRenamingID(p.ID); setRenameDraft(p.Name) } }, + { id: 'delete', label: t('perspective.delete'), danger: true, run: () => setDeleteTarget(p) }, + ], + }) + } + + const commitRename = (p: Perspective) => { + const name = renameDraft.trim() + setRenamingID(null) + if (!name || name === p.Name) return + void onRename(p.ID, name).catch((err) => onToast(String(err))) + } + + const submitCreate = () => { + const name = newName.trim() + if (!name) return + setNewName('') + // Closes only on success (symmetric with a row's own onSelect close) + // -- a create failure leaves the popover open with its draft name + // gone but the toast visible, rather than silently vanishing. + void onCreate(name).then(() => setOpen(false)).catch((err) => onToast(String(err))) + } + + const confirmDelete = () => { + if (!deleteTarget) return + const target = deleteTarget + setDeleteTarget(null) + void onDelete(target.ID).catch((err) => onToast(String(err))) + } + + return ( + <> + + + {active ? active.Name : t('perspective.allCards')} + + {/* ActionMenu.Overlay hardcodes its own data-component + ("ActionMenu.Overlay") and drops a caller-supplied one + (AtlasCardOverlay.tsx's Dialog note is the same constraint, + different component) -- the testid goes on ActionList + itself instead, which spreads its own rest props onto the + rendered
    . */} + + + { onSwitch(''); setOpen(false) }} + > + {t('perspective.allCards')} + {activePerspectiveID === '' && ( + + + + )} + + {perspectives.length > 0 && } + {perspectives.map((p) => ( + { onSwitch(p.ID); setOpen(false) }} + onContextMenu={(e) => { e.preventDefault(); openRowMenu(p, { x: e.clientX, y: e.clientY }) }} + > + {renamingID === p.ID ? ( + e.stopPropagation()} + onChange={(e) => setRenameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); commitRename(p) } + if (e.key === 'Escape') { e.preventDefault(); setRenamingID(null) } + }} + onBlur={() => commitRename(p)} + /> + ) : ( + p.Name + )} + + {p.ID === activePerspectiveID && renamingID !== p.ID && } + { e.stopPropagation(); openRowMenu(p, { x: e.clientX, y: e.clientY }) }} + /> + + + ))} + +
    + e.stopPropagation()} + onChange={(e) => setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); submitCreate() } + }} + /> +
    + {presentKinds.length > 0 && ( + <> + + + {presentKinds.map((kind) => ( + + { + const nextHidden = e.target.checked + ? hiddenKindIDs.filter((id) => id !== kind.ID) + : [...hiddenKindIDs, kind.ID] + onChangeHidden(nextHidden) + }} + /> + {kind.Icon ? `${kind.Icon} ${kind.Label}` : kind.Label} + + ))} + + + )} +
    +
    + + + {deleteTarget && ( + setDeleteTarget(null)} + onConfirm={confirmDelete} + /> + )} + + ) +} diff --git a/frontend/src/atlas/AtlasQuietToast.module.css b/frontend/src/atlas/AtlasQuietToast.module.css new file mode 100644 index 00000000..9fbe3bf7 --- /dev/null +++ b/frontend/src/atlas/AtlasQuietToast.module.css @@ -0,0 +1,5 @@ +/* Offset higher than AtlasUndoToast.module.css's own bottom: 76px so + the two never overlap on the rare occasion both fire close together. */ +.toast { + bottom: 132px; +} diff --git a/frontend/src/atlas/AtlasQuietToast.tsx b/frontend/src/atlas/AtlasQuietToast.tsx new file mode 100644 index 00000000..44204b31 --- /dev/null +++ b/frontend/src/atlas/AtlasQuietToast.tsx @@ -0,0 +1,14 @@ +import undoStyles from './AtlasUndoToast.module.css' +import styles from './AtlasQuietToast.module.css' + +// Pure presentation for useAtlasQuietToast.ts -- reuses +// AtlasUndoToast.module.css's own floating-pill visual language (same +// bottom-center container, no button) but its own offset so the two +// can never visually collide when both happen to be showing. +export function AtlasQuietToast({ message }: { message: string }) { + return ( +
    + {message} +
    + ) +} diff --git a/frontend/src/atlas/AtlasToolbar.tsx b/frontend/src/atlas/AtlasToolbar.tsx index 6e47ea5d..78c51276 100644 --- a/frontend/src/atlas/AtlasToolbar.tsx +++ b/frontend/src/atlas/AtlasToolbar.tsx @@ -2,10 +2,10 @@ import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@primer/react' import { ChecklistIcon, DownloadIcon, TableIcon, UploadIcon } from '@primer/octicons-react' -import type { Card, Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import type { Card, Kind, Perspective } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import { useUISignalStore } from '../shared/uiSignalStore' import { AtlasBreadcrumb } from './AtlasBreadcrumb' -import { AtlasLensControl } from './AtlasLensControl' +import { AtlasPerspectiveSwitcher } from './AtlasPerspectiveSwitcher' import { AtlasCreateMenu } from './AtlasCreateMenu' import { AtlasFolderImport } from './AtlasFolderImport' import { AtlasSpaceShareMenu } from './AtlasSpaceShareMenu' @@ -24,7 +24,8 @@ import styles from './AtlasView.module.css' export function AtlasToolbar({ cards, viewedID, onNavigate, kinds, presentKinds, hiddenKindIDs, onChangeHidden, - peek, onChangePeek, onAutoArrange, + onAutoArrange, + perspectives, activePerspectiveID, onSwitchPerspective, onCreatePerspective, onRenamePerspective, onDeletePerspective, onPerspectiveToast, canAddSibling, onCreate, onExport, onImportFile, onShareError, onOpenMatrix, onOpenCoverage, addChildRequest, }: { @@ -35,11 +36,18 @@ export function AtlasToolbar({ presentKinds: Kind[] hiddenKindIDs: string[] onChangeHidden: (hidden: string[]) => void - peek: boolean - onChangePeek: (peek: boolean) => void // Arrange is an action, not a mode (goal 0089): one-shot packer run - // over the current level, persisting the resulting positions. + // over the current level, persisting the resulting positions. Disabled + // while a perspective is active (ADR-0041): a global repack while + // filtered would scramble sibling views. onAutoArrange: () => void + perspectives: Perspective[] + activePerspectiveID: string + onSwitchPerspective: (id: string) => void + onCreatePerspective: (name: string) => Promise + onRenamePerspective: (id: string, name: string) => Promise + onDeletePerspective: (id: string) => Promise + onPerspectiveToast: (message: string) => void canAddSibling: boolean onCreate: (containment: 'sibling' | 'child', kindID: string, title: string) => Promise onExport: () => void @@ -81,7 +89,10 @@ export function AtlasToolbar({ @@ -107,12 +118,17 @@ export function AtlasToolbar({ - diff --git a/frontend/src/atlas/AtlasView.module.css b/frontend/src/atlas/AtlasView.module.css index db672dce..3bf12633 100644 --- a/frontend/src/atlas/AtlasView.module.css +++ b/frontend/src/atlas/AtlasView.module.css @@ -20,6 +20,15 @@ align-items: center; gap: var(--base-size-8); } +/* Auto-arrange while a perspective is active (ADR-0041): kept hoverable + (not pointer-events: none) so the title tooltip explaining why still + fires -- overriding the hover token instead is frontend.md's own + documented pattern for a disabled-but-hoverable control. */ +.arrangeDisabled { + cursor: not-allowed; + opacity: 0.5; + --control-transparent-bgColor-hover: transparent; +} /* A zero-card zero-note space (goal 0081 slice A2 rider a) still renders the board underneath -- creation can't be absent where creation starts. The wrapper is the flex slot the board used to own diff --git a/frontend/src/atlas/AtlasView.tsx b/frontend/src/atlas/AtlasView.tsx index 744f7a95..7be05207 100644 --- a/frontend/src/atlas/AtlasView.tsx +++ b/frontend/src/atlas/AtlasView.tsx @@ -6,11 +6,11 @@ import { ViewMode } from '../../bindings/github.com/alicoding/mill/internal/doma import type { Card, Position } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import { useAtlasCreationRequests } from './useAtlasCreationRequests' import { AtlasService } from '../shared/bindings' -import { useAppStore } from '../shared/store' -import { useUISignalStore } from '../shared/uiSignalStore' import { downloadJSON } from '../shared/downloadJSON' import { refreshAtlas, useAtlasStore } from './atlasStore' import { applyLens, childrenOf, groupByKind, singleRootCard } from './atlasGrouping' +import { useAtlasPerspectives } from './useAtlasPerspectives' +import { useAtlasNavSignals } from './useAtlasNavSignals' import { useAtlasImportConfirm } from './useAtlasImportConfirm' import { AtlasToolbar } from './AtlasToolbar' import { AtlasBoard } from './AtlasBoard' @@ -28,6 +28,8 @@ import { useAtlasLinkMenus } from './useAtlasLinkMenus' import { useAtlasNoteMenu } from './useAtlasNoteMenu' import { useAtlasUndoToast } from './useAtlasUndoToast' import { AtlasUndoToast } from './AtlasUndoToast' +import { useAtlasQuietToast } from './useAtlasQuietToast' +import { AtlasQuietToast } from './AtlasQuietToast' import runbookStyles from '../shared/ListCard.module.css' import styles from './AtlasView.module.css' @@ -44,10 +46,30 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { const linkKinds = useAtlasStore((s) => s.linkKinds) const links = useAtlasStore((s) => s.links) const notes = useAtlasStore((s) => s.notes) + const perspectives = useAtlasStore((s) => s.perspectives) const creationRequests = useAtlasCreationRequests() const [viewedID, setViewedID] = useState('') const [overlayCardID, setOverlayCardID] = useState(null) + + const allCards = cards ?? [] + const allKinds = kinds ?? [] + const allLinkKinds = linkKinds ?? [] + const allLinks = links ?? [] + const allNotes = notes ?? [] + const allPerspectives = perspectives ?? [] + // Board-scoped perspective state + membership filtering (ADR-0041, + // goal 0095 slice 2) -- see useAtlasPerspectives.ts's own header + // comment. Declared early (ahead of the session-restore effects + // below, which need setActivePerspectiveID) rather than grouped with + // the other `allX` derivations further down. The breadcrumb + // (AtlasToolbar's own `cards` prop below) stays on the UNFILTERED + // allCards -- ancestry text is never perspective-narrowed. + const { + activePerspectiveID, setActivePerspectiveID, boardAllCards, boardLinks, + switchPerspective, createPerspective, renamePerspective, deletePerspective, + } = useAtlasPerspectives({ viewedID, allCards, allLinks, allPerspectives }) + // A ⌘K jump's one-shot request into whichever board is currently // mounted (goal 0072 slice B) -- AtlasBoard clears it via // onFocusHandled once its own fly-to-card animation resolves. @@ -56,7 +78,16 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { // board consumes -- the board owns the packer + width, the view owns // the toolbar button. const [arrangeRequest, setArrangeRequest] = useState(0) - const requestAutoArrange = () => setArrangeRequest((n) => n + 1) + // Arrange-disabled-while-active (ADR-0041): a global repack while + // filtered to a perspective's own member set would scramble every + // OTHER perspective's shared positions. Gated at this single choke + // point so neither the toolbar button nor the atlas.arrange + // palette/keyboard command (useAtlasCommandSignals below) can bypass + // the disabled button. + const requestAutoArrange = () => { + if (activePerspectiveID) return + setArrangeRequest((n) => n + 1) + } // Traceability matrix / coverage (docs/goals/0064): both are viewed- // space-scoped dialogs, so a single boolean each is enough state -- // no card/kind selection needs to survive a close/reopen. @@ -134,16 +165,15 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { .then((session) => { if (session?.viewedID) setViewedID(session.viewedID) if (session?.openCardID) setOverlayCardID(session.openCardID) + if (session?.activePerspectiveID) setActivePerspectiveID(session.activePerspectiveID) }) .finally(() => setSessionRestored(true)) // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount landing, same as the deep-link claim }, []) useEffect(() => { if (!sessionRestored) return - // activePerspectiveID: the switcher UI lands in a later slice (goal - // 0095) -- always '' (the everything view) here for now. - void AtlasService.SetAtlasSession({ viewedID, openCardID: overlayCardID ?? '', activePerspectiveID: '' }).catch(() => {}) - }, [sessionRestored, viewedID, overlayCardID]) + void AtlasService.SetAtlasSession({ viewedID, openCardID: overlayCardID ?? '', activePerspectiveID }).catch(() => {}) + }, [sessionRestored, viewedID, overlayCardID, activePerspectiveID]) useEffect(() => { if (initialCardID || !cards || viewedID !== '' || !sessionRestored) return @@ -163,12 +193,6 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { }) }, [viewedID]) - const allCards = cards ?? [] - const allKinds = kinds ?? [] - const allLinkKinds = linkKinds ?? [] - const allLinks = links ?? [] - const allNotes = notes ?? [] - // Never render an interactive board while the mount landing is // still pending (session restore in flight, a deep link not yet // consumed, or the single-root auto-entry not yet applied): the @@ -180,7 +204,7 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { (viewedID === '' && (initialCardID ? !deepLinkConsumed : !!singleRootCard(allCards))) const viewedCard = allCards.find((c) => c.ID === viewedID) ?? null - const childrenAll = childrenOf(allCards, viewedID) + const childrenAll = childrenOf(boardAllCards, viewedID) const presentKinds = groupByKind(childrenAll, allKinds).map((shelf) => shelf.kind) // The lens filters cards by KIND, but containment is a ROLE // orthogonal to kind (ADR-0038 Decision 3): a card currently @@ -189,60 +213,14 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { // declutter notes must never remove a whole area and everything // previewed inside it. const lensed = applyLens(childrenAll, hiddenKindIDs) - const visibleChildren = childrenAll.filter((c) => lensed.includes(c) || isGroupCard(allCards, c)) + const visibleChildren = childrenAll.filter((c) => lensed.includes(c) || isGroupCard(boardAllCards, c)) // A note's own containment is spatial-only, orthogonal to the lens // (which filters by Kind -- a note has none): every note whose // ParentID names the viewed space renders here, unfiltered. const visibleNotes = allNotes.filter((n) => n.ParentID === viewedID) const overlayCard = overlayCardID ? allCards.find((c) => c.ID === overlayCardID) ?? null : null - // atlas.up (⌘↑, shared/commands.ts): one step up the depth ladder. - // At the auto-entered single root there is no "up" (the All spaces - // meta level only exists with 2+ roots) -- the press is a no-op, - // never a broken empty board. - const atlasUpRequest = useAppStore((s) => s.atlasUpRequest) - const lastUpRequest = useRef(atlasUpRequest) - useEffect(() => { - if (atlasUpRequest === lastUpRequest.current) return - lastUpRequest.current = atlasUpRequest - if (!viewedID) return - const parent = allCards.find((c) => c.ID === viewedID)?.ParentID ?? '' - if (parent === '' && singleRootCard(allCards)) return - setViewedID(parent) - // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the signal tick alone; viewedID/allCards are read at fire time - }, [atlasUpRequest]) - - // atlas.jump (⌘K, shared/commands.ts): opens AtlasJumpDialog, now - // purely controlled off this signal (goal 0071's registry - // surface-precedence reconciliation retired its own capture-phase - // window listener). Same ref-compared-counter shape as atlasUpRequest - // above. - const atlasJumpRequest = useUISignalStore((s) => s.atlasJumpRequest) - const [jumpOpen, setJumpOpen] = useState(false) - const lastJumpRequest = useRef(atlasJumpRequest) - useEffect(() => { - if (atlasJumpRequest === lastJumpRequest.current) return - lastJumpRequest.current = atlasJumpRequest - setJumpOpen(true) - }, [atlasJumpRequest]) - - // atlas.matrix / atlas.coverage (goal 0071 G17): same signal shape, - // opening the two projection dialogs already owned locally below. - const atlasMatrixRequest = useUISignalStore((s) => s.atlasMatrixRequest) - const lastMatrixRequest = useRef(atlasMatrixRequest) - useEffect(() => { - if (atlasMatrixRequest === lastMatrixRequest.current) return - lastMatrixRequest.current = atlasMatrixRequest - setMatrixOpen(true) - }, [atlasMatrixRequest]) - - const atlasCoverageRequest = useUISignalStore((s) => s.atlasCoverageRequest) - const lastCoverageRequest = useRef(atlasCoverageRequest) - useEffect(() => { - if (atlasCoverageRequest === lastCoverageRequest.current) return - lastCoverageRequest.current = atlasCoverageRequest - setCoverageOpen(true) - }, [atlasCoverageRequest]) + const { jumpOpen, setJumpOpen } = useAtlasNavSignals({ viewedID, allCards, setViewedID, setMatrixOpen, setCoverageOpen }) const navigate = (id: string) => setViewedID(id) const drill = (id: string) => setViewedID(id) @@ -262,12 +240,17 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { // tray Delete, card/note context-menu Delete, frame-header Delete, // and the card page's own kebab Delete. const undoToast = useAtlasUndoToast() + // A quiet, no-undo toast for membership add/remove and a perspective- + // service refusal (ADR-0041) -- switching itself shows no toast, only + // a membership WRITE does. + const quietToast = useAtlasQuietToast() const linkMenus = useAtlasLinkMenus({ - t, allCards, allLinks, allNotes, linkKinds: allLinkKinds, setMenu, drill, + t, allCards, allLinks, allNotes, linkKinds: allLinkKinds, perspectives: allPerspectives, setMenu, drill, onOpenCard: (id) => setOverlayCardID(id), onError: setShareError, onDeleted: undoToast.registerDelete, + onPerspectiveToast: quietToast.show, requestLinkedCard: creationRequests.requestLinkedCard, }) @@ -299,8 +282,9 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { // header comment for why the area-draw/drag-filing half stays in // AtlasBoard.tsx instead. const containmentMenus = useAtlasContainmentMenus({ - t, allCards, notes: allNotes, setMenu, drill, onError: setShareError, + t, allCards, notes: allNotes, perspectives: allPerspectives, setMenu, drill, onError: setShareError, onDeleted: undoToast.registerDelete, + onPerspectiveToast: quietToast.show, requestPlacementInside: (tool, pos, parentID) => creationRequests.requestPlacement(tool, pos, parentID), requestGroup: (cardIDs, noteIDs, pos) => creationRequests.requestGroup(cardIDs, noteIDs, pos), }) @@ -340,14 +324,13 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { const changeHidden = (hidden: string[]) => { setHiddenKindIDs(hidden) + // peek carries forward whatever the space already had -- its own + // toggle UI retired with the old Lens popover (consumed by nothing, + // per docs/SPEC.md's own recorded seam); this is the value's only + // remaining writer, and it never changes it. void AtlasService.SetLens(viewedID, hidden, peek).catch(console.error) } - const changePeek = (nextPeek: boolean) => { - setPeek(nextPeek) - void AtlasService.SetLens(viewedID, hiddenKindIDs, nextPeek).catch(console.error) - } - const exportAtlas = () => { AtlasService.ExportAtlas() .then((json) => downloadJSON('atlas.json', json)) @@ -393,9 +376,14 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { presentKinds={presentKinds} hiddenKindIDs={hiddenKindIDs} onChangeHidden={changeHidden} - peek={peek} - onChangePeek={changePeek} onAutoArrange={requestAutoArrange} + perspectives={allPerspectives} + activePerspectiveID={activePerspectiveID} + onSwitchPerspective={switchPerspective} + onCreatePerspective={createPerspective} + onRenamePerspective={renamePerspective} + onDeletePerspective={deletePerspective} + onPerspectiveToast={quietToast.show} canAddSibling={viewedID !== ''} onCreate={createCard} onExport={exportAtlas} @@ -419,9 +407,9 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { )} )} + {quietToast.message && } setJumpOpen(false)} cards={allCards} kinds={allKinds} onJump={jumpToCard} /> diff --git a/frontend/src/atlas/atlasPerspectiveFilter.test.ts b/frontend/src/atlas/atlasPerspectiveFilter.test.ts new file mode 100644 index 00000000..8b5bee6f --- /dev/null +++ b/frontend/src/atlas/atlasPerspectiveFilter.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import type { Card, Link, Perspective } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import { filterCardsByPerspective, filterLinksByPerspective } from './atlasPerspectiveFilter' + +function card(id: string): Card { + return { ID: id } as Card +} +function link(id: string, from: string, to: string): Link { + return { ID: id, FromCardID: from, ToCardID: to } as Link +} +function perspective(memberCardIDs: string[], memberLinkIDs: string[]): Perspective { + return { MemberCardIDs: memberCardIDs, MemberLinkIDs: memberLinkIDs } as Perspective +} + +const cards = [card('a'), card('b'), card('c')] +const links = [link('l1', 'a', 'b'), link('l2', 'b', 'c')] + +describe('filterCardsByPerspective', () => { + it('returns every card unfiltered when no perspective is active', () => { + expect(filterCardsByPerspective(cards, null)).toEqual(cards) + }) + + it('keeps only member cards', () => { + expect(filterCardsByPerspective(cards, perspective(['a', 'c'], []))).toEqual([card('a'), card('c')]) + }) +}) + +describe('filterLinksByPerspective', () => { + it('returns every link unfiltered when no perspective is active', () => { + expect(filterLinksByPerspective(links, null)).toEqual(links) + }) + + it('keeps a link only when the link itself and both endpoints are members', () => { + // l1's endpoints (a, b) are both members and l1 itself is a member -- renders. + // l2 (b -> c) has c missing from membership -- must NOT render even though + // l2's own id is listed, matching ADR-0041's stored-not-derived link rule. + const p = perspective(['a', 'b'], ['l1', 'l2']) + expect(filterLinksByPerspective(links, p)).toEqual([link('l1', 'a', 'b')]) + }) + + it('drops a link whose own id is not a member even when both endpoints are', () => { + const p = perspective(['a', 'b'], []) + expect(filterLinksByPerspective(links, p)).toEqual([]) + }) +}) diff --git a/frontend/src/atlas/atlasPerspectiveFilter.ts b/frontend/src/atlas/atlasPerspectiveFilter.ts new file mode 100644 index 00000000..c4255f9f --- /dev/null +++ b/frontend/src/atlas/atlasPerspectiveFilter.ts @@ -0,0 +1,23 @@ +import type { Card, Link, Perspective } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' + +// The board's own client-side mirror of atlas.FilterByPerspective +// (internal/domain/atlas/perspective.go, ADR-0041) -- no bound RPC +// exposes that pure Go function, so the frontend recomputes the exact +// same rule locally: a card renders iff it's a member; a link renders +// iff the link itself AND both its endpoints are members. MemberCardIDs +// is already closed under ancestry by the service's own +// AddToPerspective/authoring-while-active writers, so filtering a flat +// card list by membership alone is enough to keep containment visible -- +// no separate ancestor walk needed here. +export function filterCardsByPerspective(cards: Card[], perspective: Perspective | null): Card[] { + if (!perspective) return cards + const memberCards = new Set(perspective.MemberCardIDs ?? []) + return cards.filter((c) => memberCards.has(c.ID)) +} + +export function filterLinksByPerspective(links: Link[], perspective: Perspective | null): Link[] { + if (!perspective) return links + const memberCards = new Set(perspective.MemberCardIDs ?? []) + const memberLinks = new Set(perspective.MemberLinkIDs ?? []) + return links.filter((l) => memberLinks.has(l.ID) && memberCards.has(l.FromCardID) && memberCards.has(l.ToCardID)) +} diff --git a/frontend/src/atlas/atlasPerspectiveMenuItems.ts b/frontend/src/atlas/atlasPerspectiveMenuItems.ts new file mode 100644 index 00000000..da2a8edb --- /dev/null +++ b/frontend/src/atlas/atlasPerspectiveMenuItems.ts @@ -0,0 +1,70 @@ +import type { TFunction } from 'i18next' +import type { Perspective } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import { AtlasService } from '../shared/bindings' +import type { ContextMenuItem, ContextMenuState } from '../shared/ContextMenu' +import { refreshAtlas } from './atlasStore' + +// The card context menu's "Add to perspective ▸"/"Remove from +// perspective ▸" pair (ADR-0041, goal 0095 slice 2), shared between +// useAtlasLinkMenus.tsx's single-card menu and +// useAtlasContainmentMenus.tsx's multi-select menu -- both call this +// with whichever card ids the click/selection named. Selecting a +// perspective name REPLACES the open menu with that submenu's own +// list (openArteryMenu's "change link kind" already established this +// same click-to-drill shape in this codebase; no separate nested- +// flyout mechanism exists in shared/ContextMenu.tsx). Only cards join +// a perspective (ADR-0041's MemberCardIDs) -- a note id passed in +// `cardIDs` would simply never match a perspective's membership and +// is the caller's own job to exclude. +export function perspectiveMembershipMenuItems({ + t, perspectives, cardIDs, pos, setMenu, onToast, +}: { + t: TFunction<'atlas'> + perspectives: Perspective[] + cardIDs: string[] + pos: { x: number; y: number } + setMenu: (state: ContextMenuState | null) => void + onToast: (message: string) => void +}): ContextMenuItem[] { + if (perspectives.length === 0 || cardIDs.length === 0) return [] + + const addTo = (p: Perspective) => { + Promise.all(cardIDs.map((id) => AtlasService.AddToPerspective(p.ID, id))) + .then(() => { onToast(t('perspective.addedToast', { name: p.Name })); void refreshAtlas() }) + .catch((err) => onToast(String(err))) + } + const removeFrom = (p: Perspective) => { + Promise.all(cardIDs.map((id) => AtlasService.RemoveFromPerspective(p.ID, id))) + .then(() => { onToast(t('perspective.removedToast', { name: p.Name })); void refreshAtlas() }) + .catch((err) => onToast(String(err))) + } + + const items: ContextMenuItem[] = [ + { + id: 'add-to-perspective', + label: t('contextMenu.addToPerspective'), + run: () => setMenu({ + x: pos.x, + y: pos.y, + items: perspectives.map((p): ContextMenuItem => ({ id: `add-${p.ID}`, label: p.Name, run: () => addTo(p) })), + }), + }, + ] + + // Only offer to remove from a perspective at least one selected card + // actually already belongs to -- an empty submenu would be a dead end. + const removable = perspectives.filter((p) => cardIDs.some((id) => (p.MemberCardIDs ?? []).includes(id))) + if (removable.length > 0) { + items.push({ + id: 'remove-from-perspective', + label: t('contextMenu.removeFromPerspective'), + run: () => setMenu({ + x: pos.x, + y: pos.y, + items: removable.map((p): ContextMenuItem => ({ id: `remove-${p.ID}`, label: p.Name, run: () => removeFrom(p) })), + }), + }) + } + + return items +} diff --git a/frontend/src/atlas/atlasStore.ts b/frontend/src/atlas/atlasStore.ts index d8c87714..ce97615e 100644 --- a/frontend/src/atlas/atlasStore.ts +++ b/frontend/src/atlas/atlasStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import { AtlasService } from '../shared/bindings' -import type { Card, Kind, Link, LinkKind, Note } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import type { Card, Kind, Link, LinkKind, Note, Perspective } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' // The Atlas surface's own "one fetch, many consumers" store (mirrors // shared/configureEntityStore.ts's shape) -- kept inside atlas/ rather @@ -17,11 +17,17 @@ interface AtlasState { // consumer (projections/matrix/coverage/jump) starts with them never // sharing a collection. notes: Note[] | null + // Perspectives (ADR-0041, goal 0095 slice 2): every perspective across + // every space -- the switcher/board/membership surfaces scope by + // SpaceID themselves, the same "return everything, caller filters" + // contract Perspectives() itself documents. + perspectives: Perspective[] | null setKinds: (kinds: Kind[]) => void setLinkKinds: (linkKinds: LinkKind[]) => void setCards: (cards: Card[]) => void setLinks: (links: Link[]) => void setNotes: (notes: Note[]) => void + setPerspectives: (perspectives: Perspective[]) => void } export const useAtlasStore = create()((set) => ({ @@ -30,11 +36,13 @@ export const useAtlasStore = create()((set) => ({ cards: null, links: null, notes: null, + perspectives: null, setKinds: (kinds) => set({ kinds }), setLinkKinds: (linkKinds) => set({ linkKinds }), setCards: (cards) => set({ cards }), setLinks: (links) => set({ links }), setNotes: (notes) => set({ notes }), + setPerspectives: (perspectives) => set({ perspectives }), })) export function refreshAtlasKinds(): Promise { @@ -67,12 +75,18 @@ export function refreshAtlasNotes(): Promise { .catch(console.error) } +export function refreshAtlasPerspectives(): Promise { + return AtlasService.Perspectives() + .then((list) => useAtlasStore.getState().setPerspectives(list ?? [])) + .catch(console.error) +} + // The one call site every mounter of the Atlas surface (AtlasView on // mount, App.tsx/QuickPanel.tsx's mill-data-changed 'atlas' handler) -// uses -- refetches all five entity families together since they're one +// uses -- refetches all six entity families together since they're one // storage blob server-side (atlassvc's atlasStateKey) and a single // dataevent.Emit("atlas", ...) never says which family actually // changed. export function refreshAtlas(): Promise { - return Promise.all([refreshAtlasKinds(), refreshAtlasLinkKinds(), refreshAtlasCards(), refreshAtlasLinks(), refreshAtlasNotes()]).then(() => undefined) + return Promise.all([refreshAtlasKinds(), refreshAtlasLinkKinds(), refreshAtlasCards(), refreshAtlasLinks(), refreshAtlasNotes(), refreshAtlasPerspectives()]).then(() => undefined) } diff --git a/frontend/src/atlas/useAtlasContainmentMenus.tsx b/frontend/src/atlas/useAtlasContainmentMenus.tsx index ec2de098..4f141f64 100644 --- a/frontend/src/atlas/useAtlasContainmentMenus.tsx +++ b/frontend/src/atlas/useAtlasContainmentMenus.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import type { TFunction } from 'i18next' -import type { Card, Note } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import type { Card, Note, Perspective } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import type { TombstoneResult } from '../../bindings/github.com/alicoding/mill/internal/services/atlassvc/models' import { AtlasService } from '../shared/bindings' import { refreshAtlas } from './atlasStore' @@ -8,6 +8,7 @@ import { childrenOf } from './atlasGrouping' import { ConfirmDialog } from '../shared/ConfirmDialog' import type { ContextMenuItem, ContextMenuState } from '../shared/ContextMenu' import type { AtlasCreationTool } from './AtlasCreationTray' +import { perspectiveMembershipMenuItems } from './atlasPerspectiveMenuItems' // AtlasView's own frame/multi-select context menus (goal 0081 slice // A2, LOCKED design §6d + item 4's dissolve rule) -- split out of @@ -30,15 +31,17 @@ import type { AtlasCreationTool } from './AtlasCreationTray' // exact same AtlasService.DeleteCard -- only Dissolve's own confirm // copy names the act deliberately, the other doors skip it. export function useAtlasContainmentMenus({ - t, allCards, notes, setMenu, drill, onError, onDeleted, requestPlacementInside, requestGroup, + t, allCards, notes, perspectives, setMenu, drill, onError, onDeleted, onPerspectiveToast, requestPlacementInside, requestGroup, }: { t: TFunction<'atlas'> allCards: Card[] notes: Note[] + perspectives: Perspective[] setMenu: (state: ContextMenuState | null) => void drill: (id: string) => void onError: (message: string) => void onDeleted: (result: TombstoneResult) => void + onPerspectiveToast: (message: string) => void requestPlacementInside: (tool: AtlasCreationTool, pos: { x: number; y: number }, parentID: string) => void requestGroup: (cardIDs: string[], noteIDs: string[], pos: { x: number; y: number }) => void }) { @@ -128,6 +131,14 @@ export function useAtlasContainmentMenus({ if (cardIDs.length >= 2) { items.push({ id: 'group', label: t('contextMenu.groupIntoArea'), commandId: 'atlas.group.selection', run: () => requestGroup(cardIDs, noteIDs, pos) }) } + // Notes never join a perspective (ADR-0041's MemberCardIDs is cards + // only) -- only the selection's card ids are offered. + const perspectiveItems = perspectiveMembershipMenuItems({ t, perspectives, cardIDs, pos, setMenu, onToast: onPerspectiveToast }) + if (perspectiveItems.length > 0) { + if (items.length > 0) items.push({ id: 'd1', divider: true }) + items.push(...perspectiveItems) + items.push({ id: 'd2', divider: true }) + } items.push({ id: 'delete-selection', label: t('contextMenu.delete'), commandId: 'atlas.delete.selection', danger: true, run: () => deleteSelection(cardIDs, noteIDs) }) setMenu({ x: pos.x, y: pos.y, items }) } diff --git a/frontend/src/atlas/useAtlasLinkMenus.tsx b/frontend/src/atlas/useAtlasLinkMenus.tsx index 76ce71a4..6875f529 100644 --- a/frontend/src/atlas/useAtlasLinkMenus.tsx +++ b/frontend/src/atlas/useAtlasLinkMenus.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import type { TFunction } from 'i18next' -import type { Card, Link, LinkKind, Note } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import type { Card, Link, LinkKind, Note, Perspective } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import type { TombstoneResult } from '../../bindings/github.com/alicoding/mill/internal/services/atlassvc/models' import { AtlasService } from '../shared/bindings' import { refreshAtlas } from './atlasStore' @@ -8,6 +8,7 @@ import { isGroupCard } from './atlasBoardLayout' import { atlasCardShareActions } from './atlasCardShare' import type { ContextMenuItem, ContextMenuState } from '../shared/ContextMenu' import { AtlasEdgeLabelPopover } from './AtlasEdgeLabelPopover' +import { perspectiveMembershipMenuItems } from './atlasPerspectiveMenuItems' const ARTERY_MENU_TITLE_MAX = 28 @@ -27,18 +28,20 @@ function truncateTitle(title: string): string { // (count === 1), since acting on one specific link within a count>1 // aggregated artery has no per-link picker in this slice. export function useAtlasLinkMenus({ - t, allCards, allLinks, allNotes, linkKinds, setMenu, drill, onOpenCard, onError, onDeleted, requestLinkedCard, + t, allCards, allLinks, allNotes, linkKinds, perspectives, setMenu, drill, onOpenCard, onError, onDeleted, onPerspectiveToast, requestLinkedCard, }: { t: TFunction<'atlas'> allCards: Card[] allLinks: Link[] allNotes: Note[] linkKinds: LinkKind[] + perspectives: Perspective[] setMenu: (state: ContextMenuState | null) => void drill: (id: string) => void onOpenCard: (id: string) => void onError: (message: string) => void onDeleted: (result: TombstoneResult) => void + onPerspectiveToast: (message: string) => void requestLinkedCard: (fromCardID: string, pos: { x: number; y: number }) => void }) { const [labelTarget, setLabelTarget] = useState<{ linkID: string; pos: { x: number; y: number }; initialLabel: string } | null>(null) @@ -62,6 +65,7 @@ export function useAtlasLinkMenus({ // unreachable from the board (visibleNotes only renders once // viewedID equals the note's own ParentID). const place = isGroupCard(allCards, card) || allNotes.some((n) => n.ParentID === card.ID) + const perspectiveItems = perspectiveMembershipMenuItems({ t, perspectives, cardIDs: [card.ID], pos, setMenu, onToast: onPerspectiveToast }) const mirrorItems: ContextMenuItem[] = card.MirrorPath ? [ { id: 'open-file', label: t('contextMenu.openFile'), run: () => void AtlasService.OpenCardMirror(card.ID).catch((err) => onError(String(err))) }, @@ -82,6 +86,7 @@ export function useAtlasLinkMenus({ { id: 'copy-context', label: t('share.copyContext'), run: () => void share.copyAsContext(false) }, { id: 'copy-link', label: t('share.copyCloudLink'), run: () => void share.copyCloudLink() }, ...(card.MirrorPath ? [{ id: 'reveal', label: t('share.revealFile'), run: () => void share.revealFile() }] : []), + ...(perspectiveItems.length > 0 ? [{ id: 'd1b', divider: true } as ContextMenuItem, ...perspectiveItems] : []), { id: 'd2', divider: true }, { id: 'delete', label: t('overlay.delete'), danger: true, run: () => deleteCard(card.ID) }, ], diff --git a/frontend/src/atlas/useAtlasNavSignals.ts b/frontend/src/atlas/useAtlasNavSignals.ts new file mode 100644 index 00000000..682c29a5 --- /dev/null +++ b/frontend/src/atlas/useAtlasNavSignals.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from 'react' +import type { Card } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import { useAppStore } from '../shared/store' +import { useUISignalStore } from '../shared/uiSignalStore' +import { singleRootCard } from './atlasGrouping' + +// AtlasView's own one-shot navigation/dialog-opening signals (goal +// 0071 G17, goal 0072 slice B) -- split out of AtlasView.tsx +// (architecture.md's 500-line convention): atlas.up/atlas.jump/ +// atlas.matrix/atlas.coverage each bump a shared store counter a +// palette/keyboard invocation fires, consumed here with the same +// ref-compared-counter shape every other Atlas signal in this +// codebase uses. +export function useAtlasNavSignals({ viewedID, allCards, setViewedID, setMatrixOpen, setCoverageOpen }: { + viewedID: string + allCards: Card[] + setViewedID: (id: string) => void + setMatrixOpen: (open: boolean) => void + setCoverageOpen: (open: boolean) => void +}) { + // atlas.up (⌘↑): one step up the depth ladder. At the auto-entered + // single root there is no "up" (the All spaces meta level only + // exists with 2+ roots) -- the press is a no-op, never a broken + // empty board. + const atlasUpRequest = useAppStore((s) => s.atlasUpRequest) + const lastUpRequest = useRef(atlasUpRequest) + useEffect(() => { + if (atlasUpRequest === lastUpRequest.current) return + lastUpRequest.current = atlasUpRequest + if (!viewedID) return + const parent = allCards.find((c) => c.ID === viewedID)?.ParentID ?? '' + if (parent === '' && singleRootCard(allCards)) return + setViewedID(parent) + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the signal tick alone; viewedID/allCards are read at fire time + }, [atlasUpRequest]) + + // atlas.jump (⌘K): opens AtlasJumpDialog, purely controlled off this signal. + const atlasJumpRequest = useUISignalStore((s) => s.atlasJumpRequest) + const [jumpOpen, setJumpOpen] = useState(false) + const lastJumpRequest = useRef(atlasJumpRequest) + useEffect(() => { + if (atlasJumpRequest === lastJumpRequest.current) return + lastJumpRequest.current = atlasJumpRequest + setJumpOpen(true) + }, [atlasJumpRequest]) + + // atlas.matrix / atlas.coverage: same signal shape, opening the two + // projection dialogs AtlasView itself owns. + const atlasMatrixRequest = useUISignalStore((s) => s.atlasMatrixRequest) + const lastMatrixRequest = useRef(atlasMatrixRequest) + useEffect(() => { + if (atlasMatrixRequest === lastMatrixRequest.current) return + lastMatrixRequest.current = atlasMatrixRequest + setMatrixOpen(true) + }, [atlasMatrixRequest]) + + const atlasCoverageRequest = useUISignalStore((s) => s.atlasCoverageRequest) + const lastCoverageRequest = useRef(atlasCoverageRequest) + useEffect(() => { + if (atlasCoverageRequest === lastCoverageRequest.current) return + lastCoverageRequest.current = atlasCoverageRequest + setCoverageOpen(true) + }, [atlasCoverageRequest]) + + return { jumpOpen, setJumpOpen } +} diff --git a/frontend/src/atlas/useAtlasPerspectives.ts b/frontend/src/atlas/useAtlasPerspectives.ts new file mode 100644 index 00000000..7bb7652a --- /dev/null +++ b/frontend/src/atlas/useAtlasPerspectives.ts @@ -0,0 +1,59 @@ +import { useState } from 'react' +import type { Card, Link, Perspective } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import { AtlasService } from '../shared/bindings' +import { refreshAtlas } from './atlasStore' +import { filterCardsByPerspective, filterLinksByPerspective } from './atlasPerspectiveFilter' + +// AtlasView's own perspective state + writers (ADR-0041, goal 0095 +// slice 2) -- split out of AtlasView.tsx (architecture.md's 500-line +// convention). Owns activePerspectiveID (persisted into +// AtlasSessionState by AtlasView's own existing session effect, via +// setActivePerspectiveID exposed here for that one restore call) and +// the board-scoped membership filtering every board render below it +// consumes. Switching itself never calls the service -- it's a local, +// session-persisted selection; create/rename/delete call +// AtlasService directly and refresh the shared store. +export function useAtlasPerspectives({ viewedID, allCards, allLinks, allPerspectives }: { + viewedID: string + allCards: Card[] + allLinks: Link[] + allPerspectives: Perspective[] +}) { + // "" is the default "everything" view -- the ABSENCE of a Perspective, + // never an empty one. + const [activePerspectiveID, setActivePerspectiveID] = useState('') + const activePerspective = allPerspectives.find((p) => p.ID === activePerspectiveID) ?? null + + // MemberCardIDs is already closed under ancestry by the service's own + // writers, so filtering the flat card list keeps containment visible + // with no separate ancestor walk needed on this side. A link + // additionally needs both its endpoints to be members (the stored, + // not derived, link rule). + const boardAllCards = filterCardsByPerspective(allCards, activePerspective) + const boardLinks = filterLinksByPerspective(allLinks, activePerspective) + + const switchPerspective = (id: string) => setActivePerspectiveID(id) + + const createPerspective = async (name: string) => { + const p = await AtlasService.CreatePerspective(viewedID, name, '') + await refreshAtlas() + setActivePerspectiveID(p.ID) + } + + const renamePerspective = async (id: string, name: string) => { + await AtlasService.RenamePerspective(id, name, '') + await refreshAtlas() + } + + const deletePerspective = async (id: string) => { + await AtlasService.DeletePerspective(id) + if (activePerspectiveID === id) setActivePerspectiveID('') + await refreshAtlas() + } + + return { + activePerspectiveID, setActivePerspectiveID, activePerspective, + boardAllCards, boardLinks, + switchPerspective, createPerspective, renamePerspective, deletePerspective, + } +} diff --git a/frontend/src/atlas/useAtlasQuietToast.ts b/frontend/src/atlas/useAtlasQuietToast.ts new file mode 100644 index 00000000..1d5adc89 --- /dev/null +++ b/frontend/src/atlas/useAtlasQuietToast.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef, useState } from 'react' + +const TOAST_DURATION_MS = 3_000 + +// A one-line, no-button toast for a membership add/remove ("Added to +// {name}"/"Removed from {name}") or a perspective-service refusal +// message -- quieter than useAtlasUndoToast.ts's own delete guard +// (no undo action to offer, so it clears itself sooner). One message +// at a time: a later show() replaces whatever was still showing. +export function useAtlasQuietToast() { + const [message, setMessage] = useState(null) + const timerRef = useRef | null>(null) + + const show = (text: string) => { + if (timerRef.current !== null) clearTimeout(timerRef.current) + setMessage(text) + timerRef.current = setTimeout(() => setMessage(null), TOAST_DURATION_MS) + } + + useEffect(() => () => { + if (timerRef.current !== null) clearTimeout(timerRef.current) + }, []) + + return { message, show } +} diff --git a/frontend/src/locales/en/atlas.json b/frontend/src/locales/en/atlas.json index f0815601..d3ff23aa 100644 --- a/frontend/src/locales/en/atlas.json +++ b/frontend/src/locales/en/atlas.json @@ -7,7 +7,8 @@ "ariaLabel": "View mode", "shelves": "Auto-arrange", "canvas": "Free", - "arrangeAction": "Auto-arrange" + "arrangeAction": "Auto-arrange", + "arrangeDisabledTooltip": "Arranging works on all cards. Switch to All cards first." }, "board": { "cardAriaLabel": "Open {{title}}", @@ -43,13 +44,19 @@ "areaFacetLabel": "Area", "suggestionsAriaLabel": "Search type suggestions" }, - "lens": { - "openButton": "Lens", - "title": "Space lens", - "depthAriaLabel": "Depth", - "depthLevel": "This level only", - "depthPeek": "Peek into children", - "kindsLabel": "Show kinds" + "perspective": { + "allCards": "All cards", + "newPerspectivePlaceholder": "New perspective…", + "hideKindsLabel": "Hide kinds", + "rename": "Rename", + "delete": "Delete", + "rowMenuAriaLabel": "{{name}} actions", + "deleteConfirmTitle": "Delete {{name}}?", + "deleteConfirmBody": "Cards and links stay exactly as they are everywhere else.", + "addedToast": "Added to {{name}}", + "removedToast": "Removed from {{name}}", + "membershipLabel": "In:", + "addChip": "Add to perspective" }, "creationTray": { "ariaLabel": "Creation tools", @@ -212,7 +219,9 @@ "addLinkedCard": "Add linked card…", "changeLinkKind": "Change link kind", "editLabel": "Edit label…", - "removeLink": "Remove link" + "removeLink": "Remove link", + "addToPerspective": "Add to perspective ▸", + "removeFromPerspective": "Remove from perspective ▸" }, "confirm": { "dissolveTitle": "Dissolve {{title}}?", diff --git a/frontend/src/shared/atlasBoardCommands.ts b/frontend/src/shared/atlasBoardCommands.ts index 7a448b04..b856da6b 100644 --- a/frontend/src/shared/atlasBoardCommands.ts +++ b/frontend/src/shared/atlasBoardCommands.ts @@ -53,11 +53,11 @@ export const ATLAS_BOARD_COMMANDS: Command[] = [ run: () => useUISignalStore.getState().requestAtlasShareCopyLinks(), }, { - id: 'atlas.lens', - label: 'Open lens', + id: 'atlas.perspective', + label: 'Open perspective switcher', defaultBinding: null, surface: ['atlas'], - run: () => useUISignalStore.getState().requestAtlasLensOpen(), + run: () => useUISignalStore.getState().requestAtlasPerspectiveSwitcherOpen(), }, { // ⌘A select-all: the real keydown is a dedicated, editable-target- diff --git a/frontend/src/shared/uiSignalStore.ts b/frontend/src/shared/uiSignalStore.ts index 8ee87334..1f0eb991 100644 --- a/frontend/src/shared/uiSignalStore.ts +++ b/frontend/src/shared/uiSignalStore.ts @@ -96,8 +96,10 @@ interface UISignalState { requestAtlasShareCopyContext: () => void atlasShareCopyLinksRequest: number requestAtlasShareCopyLinks: () => void - atlasLensOpenRequest: number - requestAtlasLensOpen: () => void + // The switcher absorbs Lens (ADR-0041): same signal shape, renamed to + // match the toolbar control it now opens. + atlasPerspectiveSwitcherOpenRequest: number + requestAtlasPerspectiveSwitcherOpen: () => void atlasSelectAllRequest: number requestAtlasSelectAll: () => void } @@ -135,8 +137,8 @@ export const useUISignalStore = create()((set) => ({ requestAtlasShareCopyContext: () => set((s) => ({ atlasShareCopyContextRequest: s.atlasShareCopyContextRequest + 1 })), atlasShareCopyLinksRequest: 0, requestAtlasShareCopyLinks: () => set((s) => ({ atlasShareCopyLinksRequest: s.atlasShareCopyLinksRequest + 1 })), - atlasLensOpenRequest: 0, - requestAtlasLensOpen: () => set((s) => ({ atlasLensOpenRequest: s.atlasLensOpenRequest + 1 })), + atlasPerspectiveSwitcherOpenRequest: 0, + requestAtlasPerspectiveSwitcherOpen: () => set((s) => ({ atlasPerspectiveSwitcherOpenRequest: s.atlasPerspectiveSwitcherOpenRequest + 1 })), atlasSelectAllRequest: 0, requestAtlasSelectAll: () => set((s) => ({ atlasSelectAllRequest: s.atlasSelectAllRequest + 1 })), }))